refactor(agent-loop): drop the steering/message session event
Steer, inject, and followup now land as durable user/message events on the session surface; the steering/message event type and its ConversationNode kind are removed from the client projection. Update tests, docs, generated catalogs, and agent notes to match, and align the steering e2e fixture and prompt inventory assertions with the durable user/message landing.
This commit is contained in:
@@ -576,8 +576,8 @@ describe('Agent.cancel()', () => {
|
||||
expect(turnStarts.length).toBe(1) // only the original (cancelled) turn
|
||||
// The steering text was dropped — it never reached the log.
|
||||
const flat = agent.session.events
|
||||
.filter(e => e.type === 'steering/message')
|
||||
.flatMap(e => e.type === 'steering/message' ? e.data.message.content : [])
|
||||
.filter(e => e.type === 'user/message')
|
||||
.flatMap(e => e.data.content)
|
||||
.flatMap(b => b.type === 'text' ? [b.text] : [])
|
||||
expect(flat).not.toContain('steer text')
|
||||
})
|
||||
|
||||
@@ -448,7 +448,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
||||
})
|
||||
|
||||
it('durable inbox splices carry exact messages and steering/message preserves its source', async () => {
|
||||
it('durable inbox splices carry exact messages and the claimed steer preserves its source', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -293,7 +293,7 @@ describe('agent/pre-step', () => {
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
|
||||
const staged = events(agent).filter(event =>
|
||||
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
|
||||
event.type === 'turn/start' || event.type === 'user/message')
|
||||
expect(staged.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
@@ -350,7 +350,7 @@ describe('agent/pre-step', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const staged = events(agent).filter(event =>
|
||||
event.type === 'user/message' || event.type === 'steering/message')
|
||||
event.type === 'user/message')
|
||||
expect(staged.map(event => event.type)).toEqual([
|
||||
'user/message',
|
||||
'user/message',
|
||||
|
||||
@@ -544,7 +544,6 @@ describe('agent loop', () => {
|
||||
[{ type: 'text', text: 'first idle steer' }],
|
||||
[{ type: 'text', text: 'second idle steer' }],
|
||||
])
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('first idle steer')
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('second idle steer')
|
||||
@@ -574,7 +573,6 @@ describe('agent loop', () => {
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
|
||||
})
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
|
||||
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision.
|
||||
A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message` and `tool/result` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision.
|
||||
|
||||
`tool/result` persists one identified user-role tool-result message, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message.
|
||||
|
||||
@@ -100,7 +100,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model receives the complete messages from `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
|
||||
The model receives the complete messages from `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
|
||||
|
||||
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或 pre-step 领取前创建的标识。无论它是直接人类提示词、合成注入,还是进入步骤的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围;`agent.inject()` 会把输入排队,直到某次 pre-step 返回 enter 并在轮次内记录它。
|
||||
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或 pre-step 领取前创建的标识。无论它是直接人类提示词、合成注入,还是进入步骤的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message` 和 `tool/result` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围;`agent.inject()` 会把输入排队,直到某次 pre-step 返回 enter 并在轮次内记录它。
|
||||
|
||||
`tool/result` 持久保存一条带标识、user-role 的工具结果消息,以及可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型会原样接收 `user/message`、`assistant/message`、`tool/result` 和 `steering/message` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。
|
||||
模型会原样接收 `user/message`、`assistant/message` 和 `tool/result` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -155,7 +155,6 @@ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
|
||||
break
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
case 'steering/message':
|
||||
deepFreeze(snapshot.data.message)
|
||||
break
|
||||
default:
|
||||
@@ -207,7 +206,7 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
|
||||
}
|
||||
const type = event['type']
|
||||
if (type !== 'user/message' && type !== 'assistant/message'
|
||||
&& type !== 'tool/result' && type !== 'steering/message') return
|
||||
&& type !== 'tool/result') return
|
||||
assertMessageEventShape(event, `seed ${type} at index ${index}`)
|
||||
}
|
||||
|
||||
@@ -235,7 +234,7 @@ function assertAdapterDefaults(
|
||||
function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
|
||||
const type = event['type']
|
||||
if (type !== 'user/message' && type !== 'assistant/message'
|
||||
&& type !== 'tool/result' && type !== 'steering/message') return
|
||||
&& type !== 'tool/result') return
|
||||
const data = event['data']
|
||||
const record = typeof data === 'object' && data !== null
|
||||
? data as Record<string, unknown>
|
||||
@@ -665,10 +664,9 @@ export class Session {
|
||||
// trace/replay data.
|
||||
|
||||
switch (event.type) {
|
||||
// Ordinary prompts, injected context, and mid-turn steering project
|
||||
// identically in user role: the event's model-facing content stays
|
||||
// verbatim. Steering's `turn` is log-only. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
|
||||
// Ordinary prompts and injected context project in user role: the
|
||||
// event's model-facing content stays verbatim. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`) here: framing is
|
||||
// caller-owned — a producer bakes it into `content`, as workspace-context
|
||||
// does with `<system-reminder>` — or, if reintroduced, must be driven by
|
||||
// the event `meta` map and a dedicated renderer, keeping this projection a
|
||||
@@ -677,9 +675,6 @@ export class Session {
|
||||
case 'user/message': {
|
||||
return event.data
|
||||
}
|
||||
case 'steering/message': {
|
||||
return event.data.message
|
||||
}
|
||||
case 'assistant/message': {
|
||||
// Skip an empty-content assistant/message: it exists only to host a
|
||||
// max-tokens step's usage and must not inject a content-less assistant
|
||||
@@ -747,7 +742,7 @@ export class SessionStore extends Service {
|
||||
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
* loop's final flush is captured before the store attachment ends), do NOT use this
|
||||
* loop's final events are published before the store attachment ends), do NOT use this
|
||||
* — fold the session lifecycle into the agent's own effect via
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see
|
||||
* `dsh-agent-loop`'s creation transaction).
|
||||
@@ -779,7 +774,7 @@ export class SessionStore extends Service {
|
||||
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
|
||||
* effect so a fiber unload tears the session + agent down as a single ORDERED
|
||||
* chain rather than as racing sibling effects — which would remove the publication hooks
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
* before the driver's closing events commit, dropping them.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
|
||||
@@ -151,7 +151,6 @@ function validateEvent(
|
||||
case 'session/end-seed':
|
||||
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
|
||||
break
|
||||
case 'steering/message':
|
||||
case 'todo/write':
|
||||
case 'request/header':
|
||||
case 'request/context': {
|
||||
|
||||
@@ -15,13 +15,12 @@ const SURFACE_EVENT_TYPES = new Set<string>([
|
||||
'user/message',
|
||||
'assistant/message',
|
||||
'tool/result',
|
||||
'steering/message',
|
||||
])
|
||||
|
||||
/**
|
||||
* Whether an event type can join the model-visible surface.
|
||||
* @param type - event type to test.
|
||||
* @returns true for one of the four message-producing event types.
|
||||
* @returns true for one of the three message-producing event types.
|
||||
*/
|
||||
export function isSurfaceEligibleType(type: string): boolean {
|
||||
return SURFACE_EVENT_TYPES.has(type)
|
||||
|
||||
@@ -190,10 +190,11 @@ export interface SessionEventMap {
|
||||
'turn/start': { turn: number }
|
||||
/**
|
||||
* Closes turn `turn` after `step`, the last entered step (`0` when none),
|
||||
* with the {@link TurnEndReason} that ended it. The loop awaits
|
||||
* `session/flush` after an ordinary turn ends before claiming the next queued
|
||||
* item. Success commits the turn; rejection is reported live and does not
|
||||
* prevent later work.
|
||||
* with the {@link TurnEndReason} that ended it. The loop does not await a
|
||||
* flush at turn boundaries: `dsh-session-checkpoint-policy` owns the
|
||||
* per-request durability checkpoint, and consumers that read storage after
|
||||
* `whenIdle()` flush themselves. Success commits the turn; rejection is
|
||||
* reported live and does not prevent later work.
|
||||
*/
|
||||
'turn/end': { turn: number; step: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
@@ -241,8 +242,6 @@ export interface SessionEventMap {
|
||||
error?: { name: string; code: string }
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': { turn: number; message: UserMessage }
|
||||
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
/**
|
||||
@@ -292,7 +291,6 @@ export type SurfaceEventType =
|
||||
| 'user/message'
|
||||
| 'assistant/message'
|
||||
| 'tool/result'
|
||||
| 'steering/message'
|
||||
|
||||
/**
|
||||
* A {@link SessionEvent} that is **on** the ordered surface — its
|
||||
@@ -309,7 +307,7 @@ export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: Surface
|
||||
* How a session event entered the ordered surface. Only valid on
|
||||
* {@link SurfaceEventType} events.
|
||||
*
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool
|
||||
* messages.
|
||||
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
|
||||
* (inclusive) through `end` (inclusive) with this node. Both must exist as
|
||||
@@ -344,7 +342,7 @@ export interface SurfaceIntent {
|
||||
*
|
||||
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
|
||||
* they only exist on {@link SurfaceEventType} variants (`user/message`,
|
||||
* `assistant/message`, `tool/result`, `steering/message`).
|
||||
* `assistant/message`, `tool/result`).
|
||||
* Non-surface events (boundary markers, chunks, usage, errors) never carry
|
||||
* surface metadata — the compiler enforces this at `Session.append()`
|
||||
* call sites.
|
||||
|
||||
@@ -136,13 +136,6 @@ describe('session-log invariants', () => {
|
||||
content: [{ type: 'text', text: 'idle context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), { surfaceOp: 'append' })).not.toThrow()
|
||||
expect(() => outside.append('steering/message', {
|
||||
turn: 1,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
|
||||
// Route capacity is core execution state like the header beside it.
|
||||
expect(() => outside.append('request/context', {
|
||||
provider: 'mock',
|
||||
|
||||
@@ -81,19 +81,16 @@ describe('Session', () => {
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it('renders injected-context and steering messages as plain user content', () => {
|
||||
it('renders injected-context and user messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
source: { kind: 'plugin', plugin: 'watcher' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('steering/message', {
|
||||
turn: 1,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'focus on tests' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'focus on tests' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
const [contextMessage, steeringMessage] = session.deriveMessages()
|
||||
expect(contextMessage!.role).toBe('user')
|
||||
@@ -247,17 +244,6 @@ describe('Session', () => {
|
||||
},
|
||||
message: 'message must have model source',
|
||||
},
|
||||
{
|
||||
name: 'content block',
|
||||
event: {
|
||||
type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 1,
|
||||
message: { ...user, content: 'not-an-array' },
|
||||
},
|
||||
},
|
||||
message: 'message has invalid content',
|
||||
},
|
||||
{
|
||||
name: 'tool source',
|
||||
event: {
|
||||
|
||||
@@ -711,18 +711,15 @@ describe('deriveMessages with surface', () => {
|
||||
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
|
||||
})
|
||||
|
||||
it('injected-context and steering/message appear on surface', () => {
|
||||
it('injected-context and user messages appear on surface', () => {
|
||||
const s = new Session(SessionId('ctx'))
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
|
||||
}), { surfaceOp: 'append' })
|
||||
s.append('steering/message', {
|
||||
turn: 1,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'focus' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'focus' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const messages = s.deriveMessages()
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
|
||||
@@ -831,7 +828,6 @@ describe('surface type guards', () => {
|
||||
expect(isSurfaceEligibleType('user/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('assistant/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('tool/result')).toBe(true)
|
||||
expect(isSurfaceEligibleType('steering/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('turn/start')).toBe(false)
|
||||
expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user