From da68c6b401e3151f92f9ac5349f2788b6a5f2b67 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:04:27 +0800 Subject: [PATCH] feat(web): teach the fixture the plan projection and /plan channel The fixture mirrors the host's plan unit: a double-event fold over command/run (name plan) and plan/mode serves the {active, pending} value in the projections baseline and advances it with session/projection frames; /plan joins the command catalog with the host handler's wording; the prompt path commits an outstanding selection as plan/mode inside the opened turn (the agent/step boundary parallel). Baseline expectations in the fixture specs gain the always-present plan key. --- .../client/connection/src/client/fixture.ts | 51 +++++++++++++++++++ .../connection/tests/fixture-commands.spec.ts | 2 +- .../client/connection/tests/fixture.spec.ts | 19 ++++--- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4bb5d2baa3..effdd6db6e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -302,6 +302,33 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi return undefined } +/** + * Fixture parallel of the plan unit's double-event fold: `command/run` + * records named `plan` set the wanted target (`off` → false, else true); + * `plan/mode` commits and clears it. `wanted` is exposed for the prompt + * boundary (the fixture's agent/step parallel). + */ +function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { + let active = false + let wanted: boolean | null = null + for (const event of log) { + const item = event as unknown as { type: string; data?: Record } + if (item.type === 'command/run' && item.data?.['name'] === 'plan') { + wanted = String(item.data['args'] ?? '').trim() !== 'off' + } else if (item.type === 'plan/mode') { + active = item.data?.['active'] === true + wanted = null + } + } + return { active, pending: wanted !== null && wanted !== active, wanted } +} + +/** The plan projection's wire view over the full log. */ +function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: boolean } { + const plan = foldPlan(log) + return { active: plan.active, pending: plan.pending } +} + /** Fixture parallel of the host's projection units: whole current values per key over the full log. */ function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} @@ -311,6 +338,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, ], }) }, @@ -954,6 +1001,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { compact: 'fixture:已压缩(假动作)', echo: args.trim(), 'goal-fixture': `fixture:goal 已设置(${id})`, + // Mirrors the host handler's wording; the projection frame carries the state. + plan: args.trim() === 'off' + ? 'Leaving plan mode (applies from the next step).' + : 'Entering plan mode (applies from the next step). Use /plan off to leave.', } const text = name === undefined ? undefined : outcomes[name] if (name === undefined || text === undefined) return ok(request, { matched: false as const }) diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index bd66d124a4..c9bb0249e3 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => { expect(response.rpcId).toBe(request.rpcId) if (!response.result.ok) throw new Error('list failed') const commands = response.result.value.commands - expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture']) + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 2d8015506f..3661acd880 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -69,9 +69,11 @@ describe('createFixtureApi', () => { // tail block still rides it — empty-log cut at -1, the host convention. const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 })) if (!empty.result.ok) throw new Error('empty failed') - // Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write. + // Fixture composes the todos + plan units (host parallel when tool-todo + // and plan-mode are mounted): the empty-log values. expect(empty.result.value).toEqual({ - events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } }, + events: [], hasMore: false, + projections: { asOfSeq: -1, values: { todos: null, plan: { active: false, pending: false } } }, }) }) @@ -206,7 +208,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 4) abort.abort() + if (envelopes.length >= 6) abort.abort() } return envelopes } @@ -214,13 +216,14 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow the subscribed frame (title + todos units). + // Projection baseline frames follow the subscribed frame (title + todos + plan units). expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[3]?.rpcId).toBe(first[3]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[4]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[4]?.rpcId).toBe(first[4]?.rpcId) + expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) + expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[5]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[5]?.rpcId).toBe(first[5]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {