refactor(agent): unify sourced message delivery

This commit is contained in:
_Kerman
2026-07-24 22:38:50 +08:00
parent 009d113e0e
commit 992cf894af
197 changed files with 1890 additions and 2132 deletions

View File

@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/step` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## 7. Content blocks

View File

@@ -54,14 +54,13 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
installAgentLlmTarget,
type AdditionalContext,
type Agent,
type AgentLlmTarget as LlmTarget,
type AgentLlmTargetRef as LlmTargetRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
import { SessionId, type JsonValue, type UserMessageData } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -1056,7 +1055,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
const { text } = referencedPrompt
let preparedContent: ContentBlock[] = [{ type: 'text', text }]
let additionalContext: AdditionalContext | undefined
let additionalContext: UserMessageData | undefined
if (referencedPrompt.references.length > 0) {
const sessionReferences = ctx.get('sessionReferences')
if (sessionReferences === undefined) {
@@ -1083,15 +1082,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
// Install the in-flight slot BEFORE send() (send does not synchronously
// flip status to running; the session/event listener records the turn
// number and settle/rejects it). Capture the log length now as the
// A turn that ends in error rejects this promise (the codec never
// produces an error stop reason).
// number and settles or rejects it). Admission may also finish without
// opening a turn; the idle waiter closes that RPC without inventing a
// durable turn boundary. A turn that ends in error rejects this promise
// because the codec has no error stop reason.
const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined }
const inflight: NonNullable<SessionRecord['inflight']> = { resolve, reject, turn: undefined }
rec.inflight = inflight
if (additionalContext !== undefined) {
rec.agent.inject(additionalContext.content, { source: additionalContext.source })
rec.agent.inject({ content: additionalContext.content, source: additionalContext.source })
}
rec.agent.followup(preparedContent, { source: { kind: 'user' } })
rec.agent.followup({ content: preparedContent, source: { kind: 'user' } })
void rec.agent.whenIdle().then(() => {
if (rec.inflight !== inflight || inflight.turn !== undefined) return
rec.inflight = undefined
inflight.resolve('cancelled')
})
})
return { stopReason }
},

View File

@@ -406,7 +406,7 @@ describe('acp bridge — session config options', () => {
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.inject([{ type: 'text', text: 'checkpoint' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'test' } })
await agent.whenIdle()
await h.dispose()
h = undefined

View File

@@ -264,7 +264,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const handle = await harness.ctx.agents.create({
sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.followup([{ type: 'text', text: 'go' }])
handle.agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await handle.agent.whenIdle()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
@@ -288,7 +288,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
// disposed — its exit runs a final session/flush we can gate to hold the
// teardown observably in-flight.
handle.agent.followup([{ type: 'text', text: 'go' }])
handle.agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await new Promise(r => setTimeout(r, 30))
expect(handle.agent.status).toBe('running')
// Both callers join the same teardown and observe registry removal.

View File

@@ -30,7 +30,7 @@ describe('acp bridge — demux & config edges', () => {
const before = harness.updates.length
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
foreign.followup([{ type: 'text', text: 'hi' }])
foreign.followup({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
await foreign.whenIdle()
await new Promise(r => setTimeout(r, 10))

View File

@@ -51,11 +51,50 @@ describe('acp bridge — turn outcomes', () => {
it('rejects an ordinary plugin turn failure through the same ACP boundary', async () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('must not run')] })
harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') })
harness.ctx.on('agent/step', () => { throw new Error('plugin step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed: plugin pre-step failed/)
.rejects.toThrow(/turn failed: plugin step failed/)
})
it('settles a prompt rejected during admission without opening a turn', async () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('must not run')] })
harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block', reason: 'policy veto' }))
const sessionId = await newSession(harness)
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'blocked' }],
})).resolves.toEqual({ stopReason: 'cancelled' })
const agent = harness.ctx.agents.get(SessionId(sessionId))
expect(agent?.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('does not classify an asynchronous allowed admission as a no-turn rejection', async () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
const entered = Promise.withResolvers<true>()
const release = Promise.withResolvers<true>()
harness.ctx.on('agent/prompt-submit', async () => {
entered.resolve(true)
await release.promise
return { kind: 'allow' }
})
const sessionId = await newSession(harness)
let settled = false
const prompt = harness.client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'allowed' }],
}).then((result) => {
settled = true
return result
})
await entered.promise
await Promise.resolve()
expect(settled).toBe(false)
release.resolve(true)
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
})
it('streams a tool call as tool_call then tool_call_update', async () => {
@@ -291,7 +330,7 @@ describe('acp bridge — turn outcomes', () => {
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' } })
agent.inject({ content: [{ type: 'text', text: 'ctx note' }], source: { kind: 'plugin', plugin: 'test' } })
}
})
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })

View File

@@ -152,7 +152,7 @@ export class HarnessSdkServer {
rec.activePrompt = true
try {
rec.lastTurnEnd = undefined
rec.handle.agent.followup(params.contentBlocks)
rec.handle.agent.followup({ content: params.contentBlocks, source: { kind: 'user' } })
await rec.handle.agent.whenIdle()
const status = this.finishedStatus(rec.lastTurnEnd)
this.transport.notify('session.finished', {

View File

@@ -152,7 +152,7 @@ describe('HarnessSdkServer', () => {
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
})
orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }])
orphanHandle.agent.followup({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } })
await orphanHandle.agent.whenIdle()
await orphanHandle.dispose()
expect(llmServer.requests).toHaveLength(3)
@@ -227,15 +227,12 @@ describe('HarnessSdkServer', () => {
const session = ctx.sessions.create(SessionId('message-outcome'))
const agent = ({
session,
followup(content: { type: 'text'; text: string }[]) {
followup(input: { content: { type: 'text'; text: string }[]; source: { kind: 'user' } }) {
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
trigger: { kind: 'message', source: input.source },
})
session.append('user/message', {
content,
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('user/message', input, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 2,

View File

@@ -2781,11 +2781,10 @@ export function createTuiChat(
if (disposed) {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') {
const source = { kind: 'user' } as const
pendingSteering.add(agent.steer(content, { source }))
pendingSteering.add(agent.steer({ content, source: { kind: 'user' } }))
refreshStatus()
} else {
agent.followup(content, { source: { kind: 'user' } })
agent.followup({ content, source: { kind: 'user' } })
}
}
@@ -3073,7 +3072,7 @@ export function createTuiChat(
editor.addToHistory(text)
if (editor.getText() === value) editor.setText('')
if (prepared.additionalContext !== undefined) {
agent.inject(prepared.additionalContext.content, { source: prepared.additionalContext.source })
agent.inject({ content: prepared.additionalContext.content, source: prepared.additionalContext.source })
}
dispatchMessage(prepared.content)
}, (error: unknown) => {

View File

@@ -4,14 +4,13 @@ import AgentRegistry, {
AgentMessageId,
type Agent,
type AgentCancelCause,
type AliasSendOptions,
type AgentOptions,
type AgentStatus,
type SendOptions,
} from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -21,12 +20,12 @@ import { TestSessionQueryService } from './session-query.ts'
interface FakeAgent extends Agent {
status: AgentStatus
sent: ContentBlock[][]
sentOptions: (SendOptions | AliasSendOptions | undefined)[]
sentOptions: (SendOptions | undefined)[]
steered: ContentBlock[][]
steeredIds: AgentMessageId[]
steeredOptions: (AliasSendOptions | undefined)[]
steeredOptions: UserMessageData[]
injected: ContentBlock[][]
injectedOptions: (AliasSendOptions | undefined)[]
injectedOptions: UserMessageData[]
cancelled: AgentCancelCause[]
}
@@ -163,10 +162,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
const steeredIds: AgentMessageId[] = []
const sentOptions: (SendOptions | AliasSendOptions | undefined)[] = []
const steeredOptions: (AliasSendOptions | undefined)[] = []
const sentOptions: (SendOptions | undefined)[] = []
const steeredOptions: UserMessageData[] = []
const injected: ContentBlock[][] = []
const injectedOptions: (AliasSendOptions | undefined)[] = []
const injectedOptions: UserMessageData[] = []
const cancelled: AgentCancelCause[] = []
const agent: FakeAgent = {
id: sessionId,
@@ -182,26 +181,26 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
injected,
injectedOptions,
cancelled,
send(content, options) {
sent.push(content)
send(input, options) {
sent.push(input.content)
sentOptions.push(options)
return AgentMessageId('stub')
},
followup(content, options) {
sent.push(content)
sentOptions.push(options)
followup(input) {
sent.push(input.content)
sentOptions.push(undefined)
return AgentMessageId('stub')
},
steer(content, options) {
steered.push(content)
steeredOptions.push(options)
steer(input) {
steered.push(input.content)
steeredOptions.push(input)
const id = AgentMessageId(`steering-${steeredIds.length + 1}`)
steeredIds.push(id)
return id
},
inject(content, options) {
injected.push(content)
injectedOptions.push(options)
inject(input) {
injected.push(input.content)
injectedOptions.push(input)
return AgentMessageId('stub')
},
cancel(cause) {

View File

@@ -11,18 +11,18 @@ buffer
2| " mock • target-session"
style 1-23 dim
3| <blank>
4| "▌ "
4| " Referenced sessions · Source session (source-session) "
style 1-53 dim
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
5| "▌ You "
7| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Use @Source session "
8| "▌ Use @Source session "
style 0-0 fg=bright-blue
7| "▌ "
9| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Referenced sessions · Source session (source-session) "
style 1-53 dim
10| <blank>
11| " Assistant "
style 1-9 fg=bright-magenta bold

View File

@@ -1,7 +1,7 @@
terminal 100x34 buffer=normal length=38 base=4 viewport=4
terminal 100x34 buffer=normal length=36 base=2 viewport=2
lifecycle started=1 stopped=0 progress=inactive
title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
cursor hidden column=100 viewportRow=33 bufferRow=37
cursor hidden column=100 viewportRow=33 bufferRow=35
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -55,23 +55,20 @@ buffer
24| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-60 fg=bright-black
25| <blank>
26| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-75 fg=yellow
27| <blank>
28| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
26| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
29| <blank>
30| " "
31| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
27| <blank>
28| " "
29| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 2-90 fg=bright-black
32| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
33| " "
34| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
30| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
31| " "
32| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
style 2-65 fg=bright-blue bold
style 67-97 fg=bright-black
35| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
33| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
style 2-64 dim
36| " "
37| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
34| " "
35| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 73-99 dim

View File

@@ -475,11 +475,6 @@ describe('TUI terminal-state snapshots', () => {
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
}, { surfaceOp: 'append' })
session.append('prompt/blocked', {
content: [{ type: 'text', text: 'blocked' }],
source: { kind: 'user' },
reason: `Unsafe policy ${CONTROL_PROBE}`,
})
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,

View File

@@ -417,7 +417,6 @@ describe('resume command and /resume', () => {
[{ kind: 'error', step: 1, message: 'failed' }, 'error'],
[{ kind: 'disposed' }, 'disposed'],
[{ kind: 'max-tokens' }, 'max tokens'],
[{ kind: 'rejected', reason: 'policy' }, 'rejected'],
[{ kind: 'interrupted' }, 'interrupted'],
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
] as const)('renders the last turn result %s', async (reason, label) => {
@@ -1182,7 +1181,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A non-plugin injected source (goal) has no `plugin` field, so its context
// card label falls back to the source kind.
result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' })
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
appendAssistant(result.session, [])
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
@@ -1263,7 +1261,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Steering')
expect(result.terminal.output).toContain('user context')
expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind
expect(result.terminal.output).toContain('Prompt blocked')
expect(result.terminal.output).toContain('Turn cancelled')
expect(result.terminal.progress).toContain(true)
@@ -2610,12 +2607,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 5, reason: { kind: 'rejected', reason: 'policy' } })
events.session.append('turn/end', { turn: 5, reason: { kind: 'interrupted' } })
events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 6, reason: { kind: 'interrupted' } })
events.session.append('turn/start', { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', {
turn: 7,
turn: 6,
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
})
agentEvents(events.ctx, events.agent).emit('agent/disposed')
@@ -2625,7 +2620,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(events.terminal.output).toContain('Turn cancelled')
expect(events.terminal.output).toContain('structured provider failure')
expect(events.terminal.output).toContain('output-token limit')
expect(events.terminal.output).toContain('Turn rejected')
expect(events.terminal.output).toContain('previous process ended')
expect(events.terminal.output).toContain('was disposed')
await dispose(events)

View File

@@ -269,10 +269,10 @@ export class ApprovalService extends Service {
// to go out states the truth, and there is no delta to explain.
if (told === undefined || told === current) return
const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
agent.inject(
[{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
{ source: { kind: 'plugin', plugin: 'user-approval' } },
)
agent.inject({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
})
})
}

View File

@@ -365,7 +365,9 @@ describe('approval policy (the approval/policy fold)', () => {
const agent = {
id,
session,
inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') },
inject: (input: { content: Array<{ type: string; text: string }> }) => {
injected.push(input.content[0]?.text ?? '')
},
} as unknown as Agent
return { agent, session, injected }
}