Sfoglia il codice sorgente

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.
imccyu 2 mesi fa
parent
commit
da68c6b401

+ 51 - 0
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<string, unknown> }
+    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<string, unknown> {
   const values: Record<string, unknown> = {}
@@ -311,6 +338,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
   }
   // Always present (tool-todo unit composed): null when no plan stands.
   values['todos'] = backscanTodos(log) ?? null
+  // Always present (plan-mode unit composed): the {active, pending} view.
+  values['plan'] = planViewOf(log)
   return values
 }
 
@@ -333,6 +362,17 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
       seq: event.seq,
     }]
   }
+  // The plan unit advances on its two folded event kinds.
+  if (type === 'plan/mode' || (type === 'command/run'
+    && (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
+    return [{
+      type: 'session/projection',
+      sessionId: id,
+      key: 'plan',
+      value: planViewOf(log),
+      seq: event.seq,
+    }]
+  }
   return []
 }
 
@@ -804,6 +844,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         nextTurn.set(id, turn + 1)
         setRunning(id, true)
         append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
+        // Boundary flush parallel (the host's agent/step seam): an outstanding
+        // /plan selection commits as plan/mode inside the opened turn.
+        const plan = foldPlan(logOf(id))
+        if (plan.wanted !== null && plan.wanted !== plan.active) {
+          append(id, { type: 'plan/mode', data: { active: plan.wanted } })
+        }
         append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
         startReply(
           id,
@@ -935,6 +981,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
             { name: 'compact', description: 'fixture:压缩当前会话上下文' },
             { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
             { name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '<objective>' } },
+            { 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 })

+ 1 - 1
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()

+ 11 - 8
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<MuxFrame>[] = []
       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 () => {