Skip to content

Commit 9e085e2

Browse files
committed
fix: 7 wenshao/copilot review fold-ins on #4360 (1 Critical + 6 Suggestion)
Adopts all 7 review threads from the first wenshao + Copilot review round on PR #4360. All technical fixes (no judgment calls). **[Critical] BridgeTimeoutError constructor blocks tsc** (wenshao PRRT_kwDOPB-92c6DfcRI) `server.test.ts:4670` called `new BridgeTimeoutError('initialize timed out')` but the constructor signature is `(label: string, timeoutMs: number)` — TS2554 blocked `tsc --noEmit` and `npm run build`. Fixed to `new BridgeTimeoutError('initialize', 5000)` per suggested fix; resulting message `"HttpAcpBridge initialize timed out after 5000ms"` still satisfies the existing `.toContain('timed out')` assertion. **[Suggestion] Copilot JSDoc package name** (Copilot PRRT_kwDOPB-92c6De-Sm, ToolCallEmitter.ts:210) JSDoc referenced `@qwen-code/core/mcp-tool` but the actual package is `@qwen-code/qwen-code-core` with the file at `packages/core/src/tools/mcp-tool.ts`. Updated the reference. **[Suggestion] Copilot errorKind type widening** (Copilot PRRT_kwDOPB-92c6De-Ro, events.ts:244) `DaemonStreamErrorData.errorKind` was typed as `string` and the JSDoc said "7-value" closed enum — but `DAEMON_ERROR_KINDS` actually has 8 values, and `SERVE_ERROR_KINDS` (daemon-side) has 9 (adds `stat_failed`). Typed as `DaemonErrorKind | (string & {})` for forward-compat: SDK consumers get IDE autocomplete on the known 8 kinds while still accepting future daemon-side additions (like `stat_failed`) without a type error. Updated JSDoc to accurately list 8 current values + call out the forward-compat widening. Side observation (NOT in scope of this PR): `DAEMON_ERROR_KINDS` (SDK) lacks `stat_failed` that exists in `SERVE_ERROR_KINDS` (daemon). That's a separate drift fix. **[Suggestion] TERMINAL wording misleading** (wenshao PRRT_kwDOPB-92c6Dj-JL, eventBus.ts:369) Comment called `state_resync_required` a "TERMINAL synthetic frame" but it's emitted FIRST (before replay) and the stream stays OPEN. Genuine terminals like `client_evicted` close the stream after the frame. Rewrote the comment per suggestion: "id-less synthetic frame... Unlike `client_evicted`, the stream stays OPEN" — so an oncall reading the source at 3am gets the right mental model. **[Suggestion] `_meta` merge dead code + stale reference** (wenshao PRRT_kwDOPB-92c6Dj-JF, server.ts:2569) The `existingMeta` merge reads `event._meta` at BridgeEvent top level, but ToolCallEmitter's `_meta` lives nested inside `event.data._meta` (publish path goes through `events.publish({type: 'session_update', data: params})`). In production `existingMeta` is always undefined — the merge is a forward-compat escape hatch, not an active merge. Also the comment referenced `extractServerTimestamp` (sdk-typescript) which grep confirms doesn't exist yet (it's planned in chiga0 PR #4353). Rewrote the comment block to (1) acknowledge no current producer sets `_meta` at the top level — it's a forward-compat hook for future envelope-level metadata; (2) drop the stale `extractServerTimestamp` reference and instead note that chiga0 PR #4353 plans the 3-location probe. Code shape unchanged (forward-compat spread stays). **[Suggestion] session_closed + client_evicted passthrough tests** (wenshao PRRT_kwDOPB-92c6Dj-JW, daemonEvents.test.ts:2284) `RESYNC_PASSTHROUGH_TYPES` has 5 members but only `session_died` and `stream_error` had passthrough tests. Added two missing tests: `session_closed` and `client_evicted` while awaitingResync. Critical because if a future refactor accidentally drops either from the set, a consumer in resync limbo would silently swallow the terminal signal and the UI would hang on "loading resync state…". **[Suggestion] readTextFile non-FsError passthrough test** (wenshao PRRT_kwDOPB-92c6Dj-JX, bridgeClient.test.ts:251) The non-FsError pass-through test only covered `writeTextFile`. Added a symmetric `readTextFile` test — the two `try/catch` blocks in `bridgeClient.ts` are independent, so test parity guards against divergent refactors (e.g. someone adding wrapping on one side but not the other). Verification - `packages/acp-bridge`: 6 files, 114/114 pass (+1 new readTextFile non-FsError test). - `packages/sdk-typescript`: 75/75 pass on daemonEvents.test.ts (+2 new session_closed / client_evicted passthrough tests). - `packages/cli/src/serve/server.test.ts`: 248 tests pass on touched cases (5 SSE / serverTimestamp / stream_error tests). Pre-existing F3 (#4335 merge) test failures unrelated to this PR's changes — verified by stash-test-restore on clean tree. - TypeScript clean on touched regions; `BridgeTimeoutError` 2-arg fix unblocks `tsc --noEmit` for the test file.
1 parent 7441291 commit 9e085e2

7 files changed

Lines changed: 145 additions & 30 deletions

File tree

packages/acp-bridge/src/bridgeClient.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,27 @@ describe('BridgeClient — BridgeFileSystem injection seam (F1 step 5)', () => {
272272
expect(err.data).toBeUndefined();
273273
});
274274

275+
it('readTextFile passes non-FsError errors through unchanged (wenshao #4360 review)', async () => {
276+
// Symmetric guard for the read-side `preserveFsErrorOverAcp`
277+
// call. The write- and read-side catch blocks are independent
278+
// try/catch wrappers in `bridgeClient.ts`; if a future refactor
279+
// diverges them (e.g. adds Error-wrapping to one but not the
280+
// other), this test catches the read-side regression.
281+
const readText = vi.fn(async (): Promise<ReadTextFileResponse> => {
282+
throw new Error('generic read failure');
283+
});
284+
const client = makeClient({ writeText: vi.fn(), readText });
285+
286+
const err = (await client
287+
.readTextFile({ path: '/x', sessionId: 'sess:test' })
288+
.catch((e) => e)) as Error & { code?: number; data?: unknown };
289+
290+
expect(err.name).toBe('Error');
291+
expect(err.message).toBe('generic read failure');
292+
expect(err.code).toBeUndefined();
293+
expect(err.data).toBeUndefined();
294+
});
295+
275296
it('preserves hint field when present on the FsError', async () => {
276297
const writeText = vi.fn(async (): Promise<WriteTextFileResponse> => {
277298
throw makeFsError(

packages/acp-bridge/src/eventBus.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -366,21 +366,26 @@ export class EventBus {
366366
// caught up!") even though the SDK reducer's state was now
367367
// diverged from the daemon's truth.
368368
//
369-
// Emit `state_resync_required` as a TERMINAL synthetic frame
370-
// (no `id` — same pattern as `client_evicted` so it doesn't
371-
// burn a slot in the per-session monotonic sequence other
372-
// subscribers observe). The SDK reducer treats this as "your
373-
// state is stale; call loadSession before applying any further
374-
// deltas" — see `awaitingResync` flag in the SDK reducer.
369+
// Emit `state_resync_required` as an id-less synthetic frame
370+
// (no `id` — same no-burn pattern as `client_evicted`, so it
371+
// doesn't occupy a slot in the per-session monotonic sequence
372+
// other subscribers observe). **Unlike `client_evicted`, the
373+
// stream stays OPEN after this frame** — the resync frame is
374+
// emitted FIRST (before replay), and replay + live frames
375+
// continue flowing afterward. The SDK reducer treats this as
376+
// "your state is stale; call loadSession before applying any
377+
// further deltas" — see `awaitingResync` flag in the SDK
378+
// reducer. Wenshao review (#4360) corrected the prior wording
379+
// that called this "TERMINAL" — that's misleading for oncall;
380+
// `client_evicted` is genuinely terminal (closes stream),
381+
// `state_resync_required` is recovery-oriented (keeps stream
382+
// open).
375383
//
376384
// Replay continues after the resync frame (per design): the
377385
// SDK reducer will auto-skip delta application until
378386
// loadSession clears the flag, but the frames stay on the
379387
// wire so SDK has the option to compute a "what you missed"
380-
// diff later. This is network-friendly (no extra reconnect)
381-
// and matches the existing `client_evicted` semantics
382-
// (terminal-on-close, but consumer can still drain queued
383-
// frames first).
388+
// diff later. This is network-friendly (no extra reconnect).
384389
const earliestInRing = this.ring[0]?.id;
385390
if (
386391
earliestInRing !== undefined &&

packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,11 @@ export class ToolCallEmitter extends BaseEmitter {
205205
* - `subagentMeta` present → `'subagent'` (a Task tool / Codex
206206
* subagent / etc. wrapping its own tool calls)
207207
* - toolName matches `mcp__<server>__<tool>` → `'mcp'` with
208-
* `serverId: <server>`. Naming convention from `@qwen-code/core/
209-
* mcp-tool` — mirrors the SDK's same heuristic fallback so SDK
210-
* consumers stay consistent with daemon classification.
208+
* `serverId: <server>`. Naming convention from
209+
* `packages/core/src/tools/mcp-tool.ts` in the
210+
* `@qwen-code/qwen-code-core` package — mirrors the SDK's same
211+
* heuristic fallback so SDK consumers stay consistent with
212+
* daemon classification.
211213
* - everything else → `'builtin'`
212214
*
213215
* Static + pure so it can be unit-tested without an emitter

packages/cli/src/serve/server.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4667,7 +4667,11 @@ describe('GET /session/:id/events (SSE)', () => {
46674667
const bridge = fakeBridge({
46684668
async *subscribeImpl(_sessionId, _opts) {
46694669
yield { id: 1, v: 1, type: 'session_update', data: 'first' };
4670-
throw new BridgeTimeoutError('initialize timed out');
4670+
// `BridgeTimeoutError(label, timeoutMs)` — 2 positional args
4671+
// (wenshao #4360 review). The resulting message is
4672+
// `"HttpAcpBridge initialize timed out after 5000ms"` which
4673+
// satisfies the `.toContain('timed out')` assertion below.
4674+
throw new BridgeTimeoutError('initialize', 5000);
46714675
},
46724676
});
46734677
handle = await runQwenServe(

packages/cli/src/serve/server.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2559,13 +2559,28 @@ function formatSseFrame(event: BridgeEvent | OmitId<BridgeEvent>): string {
25592559
// ordering and "X minutes ago" UIs use the server's clock rather than
25602560
// each client's drifting local clock. Stamped at the wire boundary
25612561
// (NOT at EventBus.publish) so the in-memory `BridgeEvent` type stays
2562-
// unchanged and internal consumers don't see `_meta`. Pre-existing
2563-
// `_meta` keys on the event (e.g. tool_call carries `_meta.toolName` /
2564-
// `_meta.timestamp` from ToolCallEmitter) are preserved by spreading
2565-
// first. SDK consumers read via the 3-location probe in
2566-
// `extractServerTimestamp` (sdk-typescript) — `_meta.serverTimestamp`
2567-
// is one of the supported locations; the other two stay free for
2568-
// future use.
2562+
// unchanged and internal consumers don't see `_meta`.
2563+
//
2564+
// **Where `_meta` lives**: this code merges with `event._meta` at the
2565+
// BridgeEvent top level. **No current producer in the daemon sets
2566+
// `_meta` at the top level** — wenshao #4360 review noted that
2567+
// ToolCallEmitter's `_meta` lives nested at `event.data._meta` (the
2568+
// ACP `session/update` payload's own metadata), and `bridgeClient.ts`
2569+
// publishes via `events.publish({ type: 'session_update', data:
2570+
// params })` so the emitter's `_meta` stays inside `data`. The
2571+
// top-level merge here is a **forward-compat escape hatch** — if a
2572+
// future emitter ever wants to stamp envelope-level metadata, it can
2573+
// do so via `_meta` and this spread preserves it. Today
2574+
// `existingMeta` is always `undefined` in production and the spread
2575+
// is a no-op merge with `{}`.
2576+
//
2577+
// SDK consumer plan: a 3-location probe (event.serverTimestamp /
2578+
// event._meta.serverTimestamp / event.data._meta.serverTimestamp)
2579+
// is planned in chiga0's separate PR #4353 (not yet merged into
2580+
// `daemon_mode_b_main`). When that ships, SDK readers find this
2581+
// field at the `_meta.serverTimestamp` location. Until then this
2582+
// stamp is daemon-side only; SDK consumers can read it through an
2583+
// `as any` cast or wait for #4353.
25692584
const existingMeta = (event as { _meta?: Record<string, unknown> })._meta;
25702585
const stamped = {
25712586
...event,

packages/sdk-typescript/src/daemon/events.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import type {
88
DaemonEvent,
9+
DaemonErrorKind,
910
DaemonMcpTransport,
1011
PermissionOutcome,
1112
} from './types.js';
@@ -232,16 +233,23 @@ export interface DaemonStreamErrorData {
232233
error: string;
233234
/**
234235
* #4175 F4 prereq (chiga0 issue #19 P0). Classified error kind from
235-
* the daemon's `mapDomainErrorToErrorKind` — one of the 7-value
236-
* closed enum (`init_timeout` / `protocol_error` / `missing_binary`
237-
* / `auth_env_error` / `blocked_egress` / `missing_file` /
238-
* `parse_error`). Absent for unclassified errors — the daemon omits
239-
* the field rather than stamping a meaningless value. UI consumers
240-
* key on this for typed retry / remediation rendering (retry on
241-
* init_timeout vs install on missing_binary, etc.) instead of
242-
* regex-matching the `error` string.
236+
* the daemon's `mapDomainErrorToErrorKind` — typed as the closed
237+
* `DaemonErrorKind` enum (currently 8 values: `missing_binary` /
238+
* `blocked_egress` / `auth_env_error` / `init_timeout` /
239+
* `protocol_error` / `missing_file` / `parse_error` /
240+
* `budget_exhausted`) with a `(string & {})` widening for forward-
241+
* compat. A future daemon may emit additional kinds (e.g.
242+
* `stat_failed` already exists on the daemon's `SERVE_ERROR_KINDS`
243+
* but is not yet mirrored on this SDK constant) — the union shape
244+
* preserves IDE autocomplete on the known values while accepting
245+
* forward-compat strings without a type error. Absent for
246+
* unclassified errors — the daemon omits the field rather than
247+
* stamping a meaningless value. UI consumers key on this for typed
248+
* retry / remediation rendering (retry on init_timeout vs install
249+
* on missing_binary, etc.) instead of regex-matching the `error`
250+
* string.
243251
*/
244-
errorKind?: string;
252+
errorKind?: DaemonErrorKind | (string & {});
245253
[key: string]: unknown;
246254
}
247255

packages/sdk-typescript/test/unit/daemonEvents.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2334,6 +2334,66 @@ describe('PR 21 — auth device-flow events', () => {
23342334
expect(errored.streamError).toEqual({ error: 'transport gone' });
23352335
});
23362336

2337+
it('still applies session_closed while awaitingResync (wenshao #4360 review)', () => {
2338+
// session_closed is in RESYNC_PASSTHROUGH_TYPES alongside
2339+
// session_died — terminal session lifecycle signals must still
2340+
// surface even when the consumer is in resync limbo. Otherwise
2341+
// a session that closes during resync would silently keep
2342+
// `alive: true` in view state and the UI would render "loading
2343+
// resync state…" indefinitely.
2344+
const afterResync = reduceDaemonSessionEvent(
2345+
createDaemonSessionViewState(),
2346+
{
2347+
v: 1,
2348+
type: 'state_resync_required',
2349+
data: {
2350+
reason: 'ring_evicted',
2351+
lastDeliveredId: 5,
2352+
earliestAvailableId: 12,
2353+
},
2354+
},
2355+
);
2356+
const closed = reduceDaemonSessionEvent(afterResync, {
2357+
id: 15,
2358+
v: 1,
2359+
type: 'session_closed',
2360+
data: { sessionId: 's-1', reason: 'client_initiated' },
2361+
});
2362+
expect(closed.alive).toBe(false);
2363+
expect(closed.terminalEvent?.type).toBe('session_closed');
2364+
// awaitingResync stays set (consumer never recovered) — the
2365+
// terminal event takes precedence for UI rendering but the
2366+
// resync flag remains as observability state.
2367+
expect(closed.awaitingResync).toBe(true);
2368+
});
2369+
2370+
it('still applies client_evicted while awaitingResync (wenshao #4360 review)', () => {
2371+
// client_evicted is the 5th member of RESYNC_PASSTHROUGH_TYPES.
2372+
// It happens when the subscriber's queue overflows (the daemon
2373+
// closes the stream after force-pushing the synthetic frame).
2374+
// Even in resync limbo, the SDK must see the eviction so the
2375+
// adapter can stop pretending the stream is alive.
2376+
const afterResync = reduceDaemonSessionEvent(
2377+
createDaemonSessionViewState(),
2378+
{
2379+
v: 1,
2380+
type: 'state_resync_required',
2381+
data: {
2382+
reason: 'ring_evicted',
2383+
lastDeliveredId: 5,
2384+
earliestAvailableId: 12,
2385+
},
2386+
},
2387+
);
2388+
const evicted = reduceDaemonSessionEvent(afterResync, {
2389+
v: 1,
2390+
type: 'client_evicted',
2391+
data: { reason: 'queue_overflow', droppedAfter: 17 },
2392+
});
2393+
expect(evicted.alive).toBe(false);
2394+
expect(evicted.terminalEvent?.type).toBe('client_evicted');
2395+
});
2396+
23372397
it('reseeding view state via createDaemonSessionViewState clears awaitingResync (consumer recovery)', () => {
23382398
// Consumer recovery path: after observing awaitingResync, call
23392399
// loadSession (out of band) and reconstruct view state. The

0 commit comments

Comments
 (0)