refactor(agent): unify sourced message delivery
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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' }] })
|
||||
|
||||
Reference in New Issue
Block a user