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.
This commit is contained in:
imccyu
2026-07-28 21:04:27 +08:00
parent 8dab7d2f92
commit da68c6b401
3 changed files with 63 additions and 9 deletions

View File

@@ -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()

View File

@@ -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 () => {