feat(agent): unify send(target × wakeup), coalesce context/message into user/message

Replace send/steer/inject with one Agent.send primitive over the
(target × wakeup) matrix; followup/steer/inject become fixed-preset
alias methods on the now-abstract Agent class. Coalesce context/message
into user/message (injected context is a non-user source). Replace
agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel
keepInbox, and add a FIFO-conservation invariant.
This commit is contained in:
Turtle
2026-07-23 19:15:45 +08:00
parent 7c0c516f60
commit 44fd93fd06
117 changed files with 1249 additions and 728 deletions

View File

@@ -1333,8 +1333,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* generic fallback (title = tool name, raw args as input) when no registry is
* available (e.g. pure translator tests).
*
* Other event types (turn/step boundaries, context/message, …) produce
* no client update.
* Other event types (turn/step boundaries, injected-context user messages, …)
* produce no client update.
* @param sessionId - the ACP session id stamped on every emitted notification.
* @param event - the harness session event to translate.
* @param notify - sink for each produced `session/update` notification; called
@@ -1374,6 +1374,9 @@ export function streamSessionEventUpdate(
}
case 'user/message': {
if (!includeUserMessages) return
// Only a direct human prompt replays as a user message; injected context
// (plugin/goal source) is not the user's turn and produces no update.
if (event.data.source.kind !== 'user') return
// Replay the user's prompt so a loaded session shows both sides of each
// turn. Live prompt turns suppress this path to avoid duplicating what
// the client just sent.
@@ -1420,7 +1423,7 @@ export function streamSessionEventUpdate(
notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } })
return
}
// non-error turn/step boundaries, context/message, steering,
// non-error turn/step boundaries, injected-context user messages, steering,
// assistant/message — no direct ACP client update.
default:
return

View File

@@ -383,7 +383,7 @@ describe('acp bridge', () => {
},
}],
})
expect(target.events.some(event => event.type === 'context/message')).toBe(false)
expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background')

View File

@@ -285,10 +285,10 @@ describe('acp bridge — turn outcomes', () => {
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
// On the queued prompt, synchronously inject a one-shot context turn (idle
// inject writes turn/start{injection} → context/message → turn/end). Fire
// inject writes turn/start{injection} → user/message → turn/end). Fire
// once so it lands between install and the prompt turn.
let injected = false
harness.ctx.on('agent/queued', (subject) => {
harness.ctx.on('agent/inbox/enqueue', (subject) => {
if (subject === agent && !injected) {
injected = true
agent.inject([{ type: 'text', text: 'ctx note' }], { source: { kind: 'plugin', plugin: 'test' } })