feat(sdk,test): align SDK types + integration tests with §02 single-workspace

Round-4 review caught one type-drift gap + a set of integration-test
assumptions that the §02 refactor invalidated.

**SDK type drift.** `DaemonCapabilities` in
`packages/sdk-typescript/src/daemon/types.ts` was the SDK-side mirror
of `CapabilitiesEnvelope` on the daemon side. The §02 PR added
`workspaceCwd: string` to the daemon envelope (and the round-3 doc
example reads `caps.workspaceCwd` off the SDK client) but the SDK
type wasn't updated. A TypeScript consumer copying the doc snippet
verbatim would hit `TS2339 'workspaceCwd' does not exist on type
'DaemonCapabilities'`. The wire field is present so JS consumers
wouldn't notice — but the SDK is marketed as a TypeScript quickstart,
so this is a real onboarding break.

Fix: add `workspaceCwd: string` to `DaemonCapabilities` (parallel to
`DaemonSession.workspaceCwd` which is already there). The SDK unit
test for `client.capabilities()` was updated to put the new field
in the mocked response.

**Integration tests.** `qwen-serve-routes.test.ts` spawns a real
`qwen serve` daemon in `beforeAll`. Three breakages exposed:

1. The daemon was launched without `--workspace`, so it inherited
   the test runner's `cwd`. Tests then POST `workspaceCwd: REPO_ROOT`
   assuming the daemon is bound to the repo root — true when run via
   `npm test` from the repo, brittle from IDEs / launchers that have
   a different `cwd`. Added `'--workspace', REPO_ROOT` to the spawn
   args so the bound workspace is deterministic regardless of where
   the test runner is launched.

2. The `bad modelServiceId` test used `cwd: '/tmp'`. Under §02 this
   would now return 400 workspace_mismatch before the session was
   spawned. Switched to `REPO_ROOT` and softened the `attached`
   assertion (REPO_ROOT may already have a session from earlier
   tests in the suite under sessionScope:single).

3. Added three new integration tests pinning the §02 surface
   end-to-end through a real daemon process:
   - `rejects cross-workspace cwd with 400 workspace_mismatch` —
     posts `/tmp` and asserts the full structured error body
     (`code`, `boundWorkspace`, `requestedWorkspace`).
   - `omits cwd → falls back to bound workspace` — posts an empty
     body and asserts the response's `workspaceCwd` matches REPO_ROOT
     (verifies the runQwenServe → createServeApp → bridge fallback
     plumbing).
   - `GET /capabilities surfaces workspaceCwd` — asserts the new
     SDK type field is populated correctly off the wire.

All 422 unit tests pass (cli serve + sdk). Integration tests
typecheck clean.
This commit is contained in:
wenshao 2026-05-13 19:49:45 +08:00
parent cdaa760f5c
commit 5e309a90bb
3 changed files with 87 additions and 7 deletions

View file

@ -60,6 +60,14 @@ beforeAll(async () => {
TOKEN,
'--hostname',
'127.0.0.1',
// Per #3803 §02 (1 daemon = 1 workspace), pin the bound
// workspace so test assertions that POST `workspaceCwd:
// REPO_ROOT` succeed regardless of where the test runner
// happens to be cwd'd. Without this the daemon would inherit
// the test runner's cwd, which is brittle across CI / local
// / IDE-launcher environments.
'--workspace',
REPO_ROOT,
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
@ -221,20 +229,77 @@ describe('qwen serve — POST /session validation + concurrent coalescing', () =
// Tearing the session down on model-switch failure would force
// the caller into a 500 with no way to recover. The
// `model_switch_failed` SSE event is the visible failure signal.
const cwd = '/tmp';
//
// Use REPO_ROOT (the daemon's bound workspace) — under #3803 §02
// any other cwd would return 400 workspace_mismatch before the
// session is even spawned.
const cwd = REPO_ROOT;
const session = await client.createOrAttachSession({
workspaceCwd: cwd,
modelServiceId: 'definitely-not-a-real-model',
});
expect(session.sessionId).toBeTypeOf('string');
expect(session.attached).toBe(false);
// `attached` may be true or false depending on whether earlier
// tests in this file already created a REPO_ROOT session. The
// shape of the response is what matters here (sessionId present,
// listWorkspaceSessions sees it).
expect(typeof session.attached).toBe('boolean');
const sessions = await client.listWorkspaceSessions(cwd);
expect(sessions).toHaveLength(1);
expect(sessions[0]?.sessionId).toBe(session.sessionId);
expect(sessions.some((s) => s.sessionId === session.sessionId)).toBe(true);
// No teardown — Stage 1 has no DELETE /session route, and the
// session persists in `byId` until daemon shutdown. The other
// tests in this file use unique workspace cwds so the surviving
// session here doesn't interfere.
// session persists in `byId` until daemon shutdown.
});
it('rejects cross-workspace cwd with 400 workspace_mismatch (#3803 §02)', async () => {
// The daemon is bound to REPO_ROOT (via `--workspace` in beforeAll).
// A POST /session with `cwd: '/tmp'` (or any other absolute path
// that doesn't canonicalize to REPO_ROOT) must reject with 400
// `workspace_mismatch`, carrying both paths in the body so an
// orchestrator-aware client can spawn / route to the right
// daemon.
const res = await fetch(`${base}/session`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'content-type': 'application/json',
},
body: JSON.stringify({ cwd: '/tmp' }),
});
expect(res.status).toBe(400);
const body = (await res.json()) as {
code?: string;
boundWorkspace?: string;
requestedWorkspace?: string;
};
expect(body.code).toBe('workspace_mismatch');
expect(body.boundWorkspace).toBe(REPO_ROOT);
expect(body.requestedWorkspace).toBe('/tmp');
});
it('omits cwd → falls back to bound workspace (#3803 §02)', async () => {
// The route accepts an empty body and falls back to the daemon's
// bound workspace. Asserting this end-to-end through a real
// daemon process verifies the runQwenServe → createServeApp →
// bridge plumbing for the fallback path.
const res = await fetch(`${base}/session`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'content-type': 'application/json',
},
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const session = (await res.json()) as {
sessionId?: string;
workspaceCwd?: string;
};
expect(session.workspaceCwd).toBe(REPO_ROOT);
});
it('GET /capabilities surfaces workspaceCwd (#3803 §02)', async () => {
const caps = await client.capabilities();
expect(caps.workspaceCwd).toBe(REPO_ROOT);
});
});

View file

@ -25,6 +25,17 @@ export interface DaemonCapabilities {
*/
features: string[];
modelServices: string[];
/**
* Absolute canonical workspace path this daemon is bound to
* (per #3803 §02: 1 daemon = 1 workspace). Clients use this to
* (a) detect mismatch before posting `/session` (vs. waiting for
* a 400 `workspace_mismatch` response), and (b) omit `cwd` on
* `POST /session` the route falls back to this path when the
* body has no `cwd` field. Multi-workspace deployments expose
* multiple daemons on different ports, each advertising its own
* `workspaceCwd`.
*/
workspaceCwd: string;
}
/** Returned from `POST /session`. */

View file

@ -96,11 +96,15 @@ describe('DaemonClient', () => {
mode: 'http-bridge' as const,
features: ['health', 'capabilities'],
modelServices: [],
workspaceCwd: '/work/bound',
};
const { fetch } = recordingFetch(() => jsonResponse(200, envelope));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const caps = await client.capabilities();
expect(caps).toEqual(envelope);
// #3803 §02: clients use `workspaceCwd` to pre-flight check +
// omit `cwd` from `POST /session` (route falls back).
expect(caps.workspaceCwd).toBe('/work/bound');
});
});