fix(3591): forward workstream to native registry dispatch in GSDToolsRuntime
createGSDToolsRuntime accepted opts.workstream and forwarded it to the
QuerySubprocessAdapter (line 38) but the QueryNativeDirectAdapter's
dispatch closure dropped it:
dispatch: (registryCommand, registryArgs) =>
registry.dispatch(registryCommand, registryArgs, opts.projectDir)
`registry.dispatch(command, args, projectDir, workstream?)` accepts a
4th workstream argument and forwards it to handlers. When a GSDTools
instance was created with a workstream, the native fast-path silently
routed planning-path queries to the root `.planning/` tree instead of
`.planning/workstreams/<name>/`. Subprocess dispatch correctly carried
the workstream; native dispatch did not — runtime-bridge mode parity
broke for any workstream-aware GSDTools consumer using the native path.
One-line fix: pass opts.workstream as the 4th arg to registry.dispatch.
Regression test exercises three paths:
1. Constructor-seam unit test: spy on QueryNativeDirectAdapter,
capture the dispatch closure, verify it reaches a registry that
reports the unknown-command error message.
2. Back-compat: same with workstream omitted — closure still reaches
the registry.
3. End-to-end: spy on createRegistry to inject a probe registry with a
registered handler that records its args. Invoke through
runtime.bridge.dispatchHotpath(). Assert the handler observed
workstream='frontend-ws' as its 3rd arg.
RED verified: end-to-end probe fails on pre-fix tree with
`expected undefined to be 'frontend-ws'`. GREEN after the fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/3591-gsdtools-native-workstream.md
Normal file
5
.changeset/3591-gsdtools-native-workstream.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
issue: 3591
|
||||
---
|
||||
**`createGSDToolsRuntime` now forwards the workstream to native registry dispatch** — the closure passed to `QueryNativeDirectAdapter.dispatch` previously called `registry.dispatch(command, args, projectDir)` and silently dropped `opts.workstream`. As a result, GSDTools-native query handlers ran against the root `.planning/` tree even when the GSDTools instance was created with a `workstream`. The closure now passes `opts.workstream` as the 4th argument, matching the `QuerySubprocessAdapter` path that already forwards it. Every native dispatch through the runtime bridge now routes planning-path queries to `.planning/workstreams/<name>/` when a workstream is set.
|
||||
181
sdk/src/bug-3591-gsdtools-runtime-workstream.test.ts
Normal file
181
sdk/src/bug-3591-gsdtools-runtime-workstream.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Bug #3591: createGSDToolsRuntime accepts a `workstream` option, but the
|
||||
* native dispatch closure passed to QueryNativeDirectAdapter dropped it
|
||||
* before forwarding to registry.dispatch(). The omission silently routed
|
||||
* native query handlers to root `.planning` instead of
|
||||
* `.planning/workstreams/<name>` whenever a GSDTools instance was created
|
||||
* with a workstream and native dispatch was used.
|
||||
*
|
||||
* The fix passes `opts.workstream` as the 4th argument to
|
||||
* `registry.dispatch(command, args, projectDir, workstream)`. This test
|
||||
* captures the dispatch closure via a constructor-seam spy on
|
||||
* QueryNativeDirectAdapter, builds a mock registry whose dispatch records
|
||||
* its arguments, then invokes the captured closure to verify the
|
||||
* workstream is forwarded.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createGSDToolsRuntime } from './query-gsd-tools-runtime.js';
|
||||
import * as adapterModule from './query-native-direct-adapter.js';
|
||||
|
||||
describe('bug #3591: createGSDToolsRuntime forwards workstream to registry.dispatch', () => {
|
||||
it('native dispatch closure passes opts.workstream as 4th arg to registry.dispatch', async () => {
|
||||
// Capture the `dispatch` option passed into QueryNativeDirectAdapter.
|
||||
let capturedDispatch:
|
||||
| ((command: string, args: string[]) => Promise<unknown>)
|
||||
| null = null;
|
||||
const adapterSpy = vi
|
||||
.spyOn(adapterModule, 'QueryNativeDirectAdapter')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockImplementation((deps: any) => {
|
||||
capturedDispatch = deps.dispatch;
|
||||
// Return a minimal stub satisfying the runtime constructor.
|
||||
return {
|
||||
dispatchResult: vi.fn(),
|
||||
dispatchJson: vi.fn(),
|
||||
dispatchRaw: vi.fn(),
|
||||
} as unknown as adapterModule.QueryNativeDirectAdapter;
|
||||
});
|
||||
|
||||
try {
|
||||
createGSDToolsRuntime({
|
||||
projectDir: '/tmp/3591-proj',
|
||||
gsdToolsPath: '/tmp/gsd-tools.cjs',
|
||||
timeoutMs: 1_000,
|
||||
workstream: 'frontend-ws',
|
||||
shouldUseNativeQuery: () => true,
|
||||
execJsonFallback: vi.fn(async () => ({})),
|
||||
execRawFallback: vi.fn(async () => ''),
|
||||
});
|
||||
|
||||
expect(adapterSpy).toHaveBeenCalled();
|
||||
expect(capturedDispatch).not.toBeNull();
|
||||
} finally {
|
||||
adapterSpy.mockRestore();
|
||||
}
|
||||
|
||||
// The captured closure must call registry.dispatch with four args:
|
||||
// (registryCommand, registryArgs, projectDir, workstream).
|
||||
//
|
||||
// We can't easily intercept the *real* registry created inside
|
||||
// createGSDToolsRuntime, so we exercise the closure shape: the dispatch
|
||||
// function from createGSDToolsRuntime is bound to a real registry that
|
||||
// throws GSDError for unknown commands. Calling it with an unknown
|
||||
// command MUST reach the registry — which proves the closure is wired —
|
||||
// and we then verify the projectDir/workstream are passed by inspecting
|
||||
// a `register`d probe handler.
|
||||
//
|
||||
// For a tighter assertion, swap in a spied registry via a second
|
||||
// createGSDToolsRuntime call that re-uses the captured `dispatch`
|
||||
// closure's structure: we re-build the closure inline and verify
|
||||
// structurally.
|
||||
//
|
||||
// (The end-to-end "real registry, real handler" path is exercised by
|
||||
// the existing query-runtime-seam-coverage.test.ts; here we focus on
|
||||
// the closure-arg-forwarding contract.)
|
||||
//
|
||||
// Drive the closure with a unique unknown command name and assert the
|
||||
// resulting GSDError mentions that command — proving the closure
|
||||
// forwarded to a real registry.
|
||||
let thrownMessage: string | null = null;
|
||||
try {
|
||||
await capturedDispatch!('__bug-3591-unknown-cmd__', ['x']);
|
||||
} catch (err) {
|
||||
thrownMessage = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
expect(thrownMessage).toMatch(/__bug-3591-unknown-cmd__/);
|
||||
});
|
||||
|
||||
it('forwards undefined workstream when the option is omitted (back-compat)', async () => {
|
||||
// Same shape as above but no workstream. The closure must still pass
|
||||
// projectDir; passing `undefined` for the 4th slot is the documented
|
||||
// signature of registry.dispatch.
|
||||
let capturedDispatch:
|
||||
| ((command: string, args: string[]) => Promise<unknown>)
|
||||
| null = null;
|
||||
const adapterSpy = vi
|
||||
.spyOn(adapterModule, 'QueryNativeDirectAdapter')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockImplementation((deps: any) => {
|
||||
capturedDispatch = deps.dispatch;
|
||||
return {
|
||||
dispatchResult: vi.fn(),
|
||||
dispatchJson: vi.fn(),
|
||||
dispatchRaw: vi.fn(),
|
||||
} as unknown as adapterModule.QueryNativeDirectAdapter;
|
||||
});
|
||||
|
||||
try {
|
||||
createGSDToolsRuntime({
|
||||
projectDir: '/tmp/3591-proj',
|
||||
gsdToolsPath: '/tmp/gsd-tools.cjs',
|
||||
timeoutMs: 1_000,
|
||||
// workstream intentionally omitted
|
||||
shouldUseNativeQuery: () => true,
|
||||
execJsonFallback: vi.fn(async () => ({})),
|
||||
execRawFallback: vi.fn(async () => ''),
|
||||
});
|
||||
|
||||
expect(capturedDispatch).not.toBeNull();
|
||||
} finally {
|
||||
adapterSpy.mockRestore();
|
||||
}
|
||||
|
||||
let thrownMessage: string | null = null;
|
||||
try {
|
||||
await capturedDispatch!('__bug-3591-unknown-cmd-2__', []);
|
||||
} catch (err) {
|
||||
thrownMessage = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
expect(thrownMessage).toMatch(/__bug-3591-unknown-cmd-2__/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3591: end-to-end — workstream-aware probe handler receives the workstream', () => {
|
||||
it('a registered probe handler receives opts.workstream as its 3rd arg', async () => {
|
||||
// End-to-end path: register a probe handler on a real registry (via
|
||||
// module-level export), build the runtime with a workstream, and
|
||||
// assert the handler observed the workstream when invoked through the
|
||||
// native dispatch closure.
|
||||
const registryModule = await import('./query/index.js');
|
||||
const probeRegistry = registryModule.createRegistry();
|
||||
const seen: Array<{ args: string[]; projectDir: string; workstream?: string }> = [];
|
||||
probeRegistry.register('__bug-3591-probe__', async (args, projectDir, workstream) => {
|
||||
seen.push({ args, projectDir, workstream });
|
||||
return { data: { ok: true } };
|
||||
});
|
||||
|
||||
// The runtime builds its OWN registry internally; we can't substitute
|
||||
// ours unless we mock createRegistry. Hoist a module mock for that.
|
||||
const createRegistrySpy = vi
|
||||
.spyOn(registryModule, 'createRegistry')
|
||||
.mockReturnValue(probeRegistry);
|
||||
|
||||
try {
|
||||
const runtime = createGSDToolsRuntime({
|
||||
projectDir: '/tmp/3591-proj',
|
||||
gsdToolsPath: '/tmp/gsd-tools.cjs',
|
||||
timeoutMs: 1_000,
|
||||
workstream: 'frontend-ws',
|
||||
shouldUseNativeQuery: () => true,
|
||||
execJsonFallback: vi.fn(async () => ({})),
|
||||
execRawFallback: vi.fn(async () => ''),
|
||||
});
|
||||
|
||||
await runtime.bridge.dispatchHotpath(
|
||||
'__bug-3591-probe-legacy__',
|
||||
[],
|
||||
'__bug-3591-probe__',
|
||||
['payload'],
|
||||
'json',
|
||||
);
|
||||
} finally {
|
||||
createRegistrySpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.args).toEqual(['payload']);
|
||||
expect(seen[0]?.projectDir).toBe('/tmp/3591-proj');
|
||||
expect(seen[0]?.workstream).toBe('frontend-ws');
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,13 @@ export function createGSDToolsRuntime(opts: {
|
||||
|
||||
const nativeDirectAdapter = new QueryNativeDirectAdapter({
|
||||
timeoutMs: opts.timeoutMs,
|
||||
dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir),
|
||||
// #3591: forward opts.workstream to the registry so native dispatch
|
||||
// routes planning-path queries to .planning/workstreams/<name>/
|
||||
// instead of the root .planning tree. createGSDToolsRuntime accepts
|
||||
// workstream and the QuerySubprocessAdapter already forwards it
|
||||
// (line 38); the native dispatch closure was the only seam that
|
||||
// dropped it, silently routing GSDTools-native queries to root.
|
||||
dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir, opts.workstream),
|
||||
...nativeErrorFactory,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user