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:
@@ -302,6 +302,33 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
|||||||
return undefined
|
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. */
|
/** 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> {
|
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
|
||||||
const values: 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.
|
// Always present (tool-todo unit composed): null when no plan stands.
|
||||||
values['todos'] = backscanTodos(log) ?? null
|
values['todos'] = backscanTodos(log) ?? null
|
||||||
|
// Always present (plan-mode unit composed): the {active, pending} view.
|
||||||
|
values['plan'] = planViewOf(log)
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +362,17 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
|||||||
seq: event.seq,
|
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 []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,6 +844,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|||||||
nextTurn.set(id, turn + 1)
|
nextTurn.set(id, turn + 1)
|
||||||
setRunning(id, true)
|
setRunning(id, true)
|
||||||
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
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) })
|
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||||
startReply(
|
startReply(
|
||||||
id,
|
id,
|
||||||
@@ -935,6 +981,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|||||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||||
{ name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '<objective>' } },
|
{ 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:已压缩(假动作)',
|
compact: 'fixture:已压缩(假动作)',
|
||||||
echo: args.trim(),
|
echo: args.trim(),
|
||||||
'goal-fixture': `fixture:goal 已设置(${id})`,
|
'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]
|
const text = name === undefined ? undefined : outcomes[name]
|
||||||
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
|
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
|
|||||||
expect(response.rpcId).toBe(request.rpcId)
|
expect(response.rpcId).toBe(request.rpcId)
|
||||||
if (!response.result.ok) throw new Error('list failed')
|
if (!response.result.ok) throw new Error('list failed')
|
||||||
const commands = response.result.value.commands
|
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.
|
// input hint rides only the commands declaring it.
|
||||||
const echo = commands.find(c => c.name === 'echo')
|
const echo = commands.find(c => c.name === 'echo')
|
||||||
expect(echo?.input?.hint).toBeTruthy()
|
expect(echo?.input?.hint).toBeTruthy()
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ describe('createFixtureApi', () => {
|
|||||||
// tail block still rides it — empty-log cut at -1, the host convention.
|
// 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 }))
|
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
|
||||||
if (!empty.result.ok) throw new Error('empty failed')
|
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({
|
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>[] = []
|
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||||
envelopes.push(envelope)
|
envelopes.push(envelope)
|
||||||
if (envelopes.length >= 4) abort.abort()
|
if (envelopes.length >= 6) abort.abort()
|
||||||
}
|
}
|
||||||
return envelopes
|
return envelopes
|
||||||
}
|
}
|
||||||
@@ -214,13 +216,14 @@ describe('createFixtureApi', () => {
|
|||||||
const second = await openOnce()
|
const second = await openOnce()
|
||||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
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[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[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
|
||||||
expect(first[3]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
|
||||||
expect(second[3]?.rpcId).toBe(first[3]?.rpcId) // stable rpcId across replays (host replay semantics)
|
expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||||
expect(first[4]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||||
expect(second[4]?.rpcId).toBe(first[4]?.rpcId)
|
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 () => {
|
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user