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' }] })
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user