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:
@@ -168,7 +168,7 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start ack → completion notice as context/message → task_output collects it', async () => {
|
||||
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh TaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
@@ -188,10 +188,12 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// durable context/message into the owning agent's session (settlement may
|
||||
// race turn end, so poll for it).
|
||||
await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
|
||||
const notice = findEvent(events(agent), 'context/message')
|
||||
// durable plugin-sourced user/message into the owning agent's session
|
||||
// (settlement may race turn end, so poll for it).
|
||||
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
|
||||
e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
await pollUntil(() => events(agent).some(isNotice))
|
||||
const notice = events(agent).find(isNotice)!
|
||||
expect(notice.data.content.some(
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
|
||||
@@ -42,7 +42,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
|
||||
if (turn % 9 === 4) {
|
||||
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
|
||||
}
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
const withTool = turn % 5 === 2
|
||||
|
||||
@@ -38,6 +38,14 @@ function materializeNode(
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
// Injected context (plugin/goal source) folds to a context node, not a
|
||||
// user message; only a direct human prompt is a user node.
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
}
|
||||
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
|
||||
case 'assistant/message':
|
||||
return {
|
||||
@@ -46,11 +54,6 @@ function materializeNode(
|
||||
}
|
||||
case 'steering/message':
|
||||
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
|
||||
case 'context/message':
|
||||
return {
|
||||
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
return {
|
||||
@@ -63,7 +66,7 @@ function materializeNode(
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
|
||||
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
|
||||
surface-eligible types, and each has a case above; reachable only if core
|
||||
adds an eligible type. */
|
||||
default:
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('FoldAdapter', () => {
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
|
||||
at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(5, 0, 'c1', '结果'),
|
||||
]
|
||||
|
||||
@@ -563,7 +563,10 @@ describe('pressure measurement and retention', () => {
|
||||
const result = await compactIfNeeded(compact, session)
|
||||
expect(result).not.toBeNull()
|
||||
expect(prefix).toHaveLength(1)
|
||||
expect(session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
// The routed request prefix must not reach the surface as its own message
|
||||
// (the compaction summary itself is an expected plugin-sourced checkpoint).
|
||||
expect(session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the latest logged request envelope without an AgentOptions override', async () => {
|
||||
@@ -959,7 +962,7 @@ describe('compaction region transaction', () => {
|
||||
const compact = service()
|
||||
const session = conversation(2)
|
||||
compact.mutateDuringSummary = () => {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'concurrent surface mutation' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
@@ -96,23 +96,23 @@ describe('tool-pairing boundaries', () => {
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
midStep.append('context/message', {
|
||||
midStep.append('user/message', {
|
||||
content: [{ type: 'text', text: 'background update' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, SURFACE)
|
||||
midStep.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(before(midStep, 'context/message')).toBe(false)
|
||||
expect(after(midStep, 'context/message')).toBe(false)
|
||||
expect(before(midStep, 'user/message')).toBe(false)
|
||||
expect(after(midStep, 'user/message')).toBe(false)
|
||||
|
||||
const free = new Session(SessionId('neutral-free'))
|
||||
free.append('context/message', {
|
||||
free.append('user/message', {
|
||||
content: [{ type: 'text', text: 'idle injection' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(before(free, 'context/message')).toBe(true)
|
||||
expect(after(free, 'context/message')).toBe(true)
|
||||
expect(before(free, 'user/message')).toBe(true)
|
||||
expect(after(free, 'user/message')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
|
||||
break
|
||||
}
|
||||
case 'tool/result':
|
||||
case 'context/message':
|
||||
break
|
||||
/* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */
|
||||
default:
|
||||
|
||||
@@ -61,7 +61,7 @@ function appendConversation(session: Session): void {
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append(
|
||||
'context/message',
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'plugin', plugin: 'workspace' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
|
||||
@@ -64,7 +64,6 @@ function precedingMessageTime(agent: Agent): number | undefined {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
return event.time
|
||||
default:
|
||||
@@ -79,7 +78,7 @@ function precedingMessageTime(agent: Agent): number | undefined {
|
||||
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
|
||||
if (event.type === 'context/message'
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === name) {
|
||||
return event.time
|
||||
@@ -91,7 +90,7 @@ function precedingStepContextTime(agent: Agent, turn: number): number | undefine
|
||||
/** Find this plugin's latest durable injection, including a shadowed surface event. */
|
||||
function latestInjectionTime(agent: Agent): number | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'context/message'
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === name) {
|
||||
return event.time
|
||||
|
||||
@@ -48,7 +48,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
|
||||
/** Validate one plugin-attributed time reading against its session position and timestamp. */
|
||||
function validateReading(
|
||||
history: readonly SessionEvent[],
|
||||
event: SessionEvent<'context/message'>,
|
||||
event: SessionEvent<'user/message'>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
const [block] = event.data.content
|
||||
@@ -84,7 +84,7 @@ function validateReading(
|
||||
/** Validate all package-owned readings already present in one session. */
|
||||
function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
for (const [index, event] of session.events.entries()) {
|
||||
if (event.type !== 'context/message'
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) continue
|
||||
validateReading(session.events.slice(0, index), event, fail)
|
||||
@@ -97,7 +97,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'context/message'
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) return
|
||||
validateReading(session.events, event, fail)
|
||||
|
||||
@@ -17,7 +17,7 @@ async function setup(): Promise<Context> {
|
||||
|
||||
function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent {
|
||||
return {
|
||||
type: 'context/message',
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time,
|
||||
data: {
|
||||
@@ -56,7 +56,7 @@ function preparing(turn: number, step: number): Session {
|
||||
}
|
||||
|
||||
function appendReading(session: Session, text: string): void {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -162,7 +162,7 @@ describe('time-context invariants', () => {
|
||||
|
||||
it('ignores context messages owned by another package', async () => {
|
||||
const ctx = await setup()
|
||||
const other = event('unrelated') as SessionEvent<'context/message'>
|
||||
const other = event('unrelated') as SessionEvent<'user/message'>
|
||||
other.data.source = { kind: 'plugin', plugin: 'other' }
|
||||
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
|
||||
other.data.source = { kind: 'user' }
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('time-context through a real headless cordis.yml', () => {
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
|
||||
|
||||
const contexts = events.filter(event => event.type === 'context/message')
|
||||
const contexts = events.filter(event => event.type === 'user/message')
|
||||
const starts = events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(starts).toHaveLength(2)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -43,9 +43,10 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
status: 'running',
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject(content, options) {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -66,7 +67,7 @@ function openMessageTurn(session: Session, turn: number): void {
|
||||
function contextTexts(session: Session): string[] {
|
||||
const texts: string[] = []
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'context/message'
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context') {
|
||||
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
|
||||
@@ -151,8 +152,8 @@ describe('durable step context', () => {
|
||||
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
|
||||
])
|
||||
const event = session.events.at(-1)
|
||||
expect(event?.type).toBe('context/message')
|
||||
if (event?.type !== 'context/message') throw new Error('missing time context')
|
||||
expect(event?.type).toBe('user/message')
|
||||
if (event?.type !== 'user/message') throw new Error('missing time context')
|
||||
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
@@ -230,10 +231,10 @@ describe('durable step context', () => {
|
||||
const original = new Session(SessionId('seed-source'))
|
||||
openMessageTurn(original, 1)
|
||||
await fire(ctx, sessionAgent(original), 1, 1)
|
||||
const user = original.events.find(event => event.type === 'user/message')
|
||||
const reading = original.events.find(event => event.type === 'context/message')
|
||||
const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
|
||||
const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
|
||||
original.append('context/message', {
|
||||
original.append('user/message', {
|
||||
content: [{ type: 'text', text: 'compacted history' }],
|
||||
source: { kind: 'plugin', plugin: 'compact-basic' },
|
||||
}, {
|
||||
@@ -292,7 +293,7 @@ describe('durable step context', () => {
|
||||
openMessageTurn(session, 1)
|
||||
let ordinarySawContext = false
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
|
||||
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
|
||||
})
|
||||
|
||||
await fire(ctx, agent, 1, 1)
|
||||
@@ -401,7 +402,8 @@ describe('real agent-loop request history', () => {
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const contexts = agent.session.events.filter(event => event.type === 'context/message')
|
||||
const contexts = agent.session.events.filter(
|
||||
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
const starts = agent.session.events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(adapter.requests.length)
|
||||
expect(starts).toHaveLength(adapter.requests.length)
|
||||
|
||||
@@ -145,7 +145,7 @@ function visibleInstructionChanges(
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes)
|
||||
const visible = new Map<string, WorkspaceInstructionChange>()
|
||||
for (const [seq, event] of agent.session.events.entries()) {
|
||||
if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue
|
||||
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
|
||||
const changes = workspaceInstructionChanges(event.data.meta)
|
||||
for (const change of changes) {
|
||||
const waiting = pending.get(change.scope)
|
||||
@@ -281,7 +281,7 @@ export function observeInstructionSessionEvent(
|
||||
if (pending === undefined) return
|
||||
|
||||
switch (event.type) {
|
||||
case 'context/message': {
|
||||
case 'user/message': {
|
||||
if (!isWorkspaceContextSource(event.data.source)) return
|
||||
for (const change of workspaceInstructionChanges(event.data.meta)) {
|
||||
const waiting = pending.get(change.scope)
|
||||
|
||||
@@ -107,15 +107,15 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
const events = [...live.agent.session.events]
|
||||
const update = events.find(event => event.type === 'context/message'
|
||||
const update = events.find(event => event.type === 'user/message'
|
||||
&& typeof event.data.meta === 'object'
|
||||
&& event.data.meta !== null
|
||||
&& !Array.isArray(event.data.meta)
|
||||
&& event.data.meta.kind === 'workspace-instructions')
|
||||
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
|
||||
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
|
||||
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||||
})
|
||||
const updateText = update?.type === 'context/message'
|
||||
const updateText = update?.type === 'user/message'
|
||||
? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
: ''
|
||||
expect(updateText).toContain('Updated instructions from: AGENTS.md')
|
||||
|
||||
@@ -175,9 +175,10 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
session,
|
||||
status: 'idle',
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject(content, options) {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
...options?.meta !== undefined ? { meta: options.meta } : {},
|
||||
@@ -219,7 +220,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext {
|
||||
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined {
|
||||
let lastSeq: number | undefined
|
||||
for (const context of result.additionalContexts ?? []) {
|
||||
lastSeq = agent.session.append('context/message', {
|
||||
lastSeq = agent.session.append('user/message', {
|
||||
content: context.content,
|
||||
source: context.source,
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
@@ -971,7 +972,7 @@ describe('workspace context request injection', () => {
|
||||
const second = await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(second).toEqual(first)
|
||||
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
|
||||
expect(derivedText(agent)).toContain('repo rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -1143,7 +1144,7 @@ describe('workspace context request injection', () => {
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
|
||||
expect(derivedText(agent)).not.toContain('workspace-context:')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -1711,12 +1712,12 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
agent.send([{ type: 'text', text: 'read and abort' }])
|
||||
await agent.whenIdle()
|
||||
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
|
||||
|
||||
agent.send([{ type: 'text', text: 'retry the read' }])
|
||||
await agent.whenIdle()
|
||||
|
||||
const contexts = agent.session.events.filter(event => event.type === 'context/message')
|
||||
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
// The aborted batch drained its accepted context before step close, so the
|
||||
// retry sees durable history without producing a duplicate instruction.
|
||||
expect(contexts).toHaveLength(1)
|
||||
@@ -2490,10 +2491,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
const resumed = {
|
||||
...agent,
|
||||
session: new Session(agent.session.id, [...agent.session.events], agent.session.header),
|
||||
}
|
||||
const resumed = stubAgent(root, [...agent.session.events])
|
||||
|
||||
const afterResume = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
@@ -2531,11 +2529,11 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
await composeBaselinePrefix(ctx, resumed)
|
||||
|
||||
const update = resumed.session.events.findLast(event => event.type === 'context/message')
|
||||
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
|
||||
const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
|
||||
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||||
})
|
||||
expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
|
||||
expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -2681,7 +2679,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
agent.session.append('context/message', {
|
||||
agent.session.append('user/message', {
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
|
||||
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
|
||||
@@ -2698,12 +2696,12 @@ describe('dynamic nested workspace context injection', () => {
|
||||
],
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('context/message', {
|
||||
agent.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'stale metadata version' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
meta: { kind: 'workspace-instructions', version: 0, changes: [] },
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('context/message', {
|
||||
agent.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'foreign plugin context' }],
|
||||
source: { kind: 'plugin', plugin: 'other' },
|
||||
meta: {
|
||||
@@ -3216,14 +3214,14 @@ describe('workspace context pending state', () => {
|
||||
path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
|
||||
}]]))
|
||||
|
||||
const unrelated = agent.session.append('context/message', {
|
||||
const unrelated = agent.session.append('user/message', {
|
||||
content: [], source: { kind: 'plugin', plugin: 'other' },
|
||||
}, { surfaceOp: 'append' })
|
||||
observeInstructionSessionEvent(agent.session, unrelated, pending, versions)
|
||||
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
|
||||
|
||||
const otherContext = workspaceChangeContext('other', 'other')
|
||||
const otherWorkspaceEvent = agent.session.append('context/message', {
|
||||
const otherWorkspaceEvent = agent.session.append('user/message', {
|
||||
content: otherContext.content,
|
||||
source: otherContext.source,
|
||||
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
|
||||
@@ -3232,7 +3230,7 @@ describe('workspace context pending state', () => {
|
||||
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
|
||||
|
||||
const context = workspaceChangeContext('pkg', 'one')
|
||||
const confirmed = agent.session.append('context/message', {
|
||||
const confirmed = agent.session.append('user/message', {
|
||||
content: context.content,
|
||||
source: context.source,
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
|
||||
@@ -843,6 +843,27 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'A step or turn errored.',
|
||||
},
|
||||
{
|
||||
name: 'agent/inbox/dequeue',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/inbox/dequeue\'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void',
|
||||
jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param info - the claimed item\'s accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.',
|
||||
},
|
||||
{
|
||||
name: 'agent/inbox/discard',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, items: InboxItemInfo[]): void',
|
||||
jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.',
|
||||
},
|
||||
{
|
||||
name: 'agent/inbox/enqueue',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void',
|
||||
jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param info - the accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).',
|
||||
},
|
||||
{
|
||||
name: 'agent/post-step',
|
||||
mode: 'serial',
|
||||
@@ -864,13 +885,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.',
|
||||
},
|
||||
{
|
||||
name: 'agent/queued',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/queued\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void',
|
||||
jsDoc: '/**\n * Detached, frozen content entered the agent\'s inbox. Source defaults have\n * already been applied, so these are the exact values retained for the log.\n * @param agent - the agent whose inbox received the message.\n * @param content - the accepted content blocks retained by the inbox.\n * @param info - the accepted source, contexts, and whether it entered as steering.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'Detached, frozen content entered the agent\'s inbox.',
|
||||
},
|
||||
{
|
||||
name: 'agent/request',
|
||||
mode: 'waterfall',
|
||||
@@ -1127,14 +1141,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
|
||||
/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */
|
||||
export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
{
|
||||
name: 'Agent',
|
||||
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentCancelCause',
|
||||
declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'AgentFactory',
|
||||
declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
|
||||
@@ -1147,10 +1153,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'AgentOptions',
|
||||
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentStatus',
|
||||
declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';',
|
||||
},
|
||||
{
|
||||
name: 'ApprovalOutcome',
|
||||
declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';',
|
||||
@@ -1451,10 +1453,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'HookContext',
|
||||
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InjectOptions',
|
||||
declaration: 'export interface InjectOptions extends Omit<SendOptions, \'contexts\'> {\n meta?: JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InvariantFailure',
|
||||
declaration: 'export type InvariantFailure = (message: string) => never;',
|
||||
@@ -1525,7 +1523,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PromptMessageData',
|
||||
declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}',
|
||||
declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptMessageEnvelope',
|
||||
@@ -1627,6 +1625,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ReasoningBlock',
|
||||
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'RequestHeaderReason',
|
||||
declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';',
|
||||
},
|
||||
{
|
||||
name: 'ResumeAgentOptions',
|
||||
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
@@ -1659,17 +1661,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ScopeKey',
|
||||
declaration: 'export type ScopeKey = object;',
|
||||
},
|
||||
{
|
||||
name: 'SendOptions',
|
||||
declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEvent',
|
||||
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMap',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: Req /* …truncated — full shape in source */',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventReadRequest',
|
||||
@@ -1881,7 +1879,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SurfaceEventType',
|
||||
declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'context/message\' | \'steering/message\';',
|
||||
declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';',
|
||||
},
|
||||
{
|
||||
name: 'SurfaceOp',
|
||||
|
||||
@@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore materialize only after admission. Absent or `separate` placement appends an independent `context/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
|
||||
The unified `send()` primitive materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON record, then routes it by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO (waking the driver unless `wakeup: false`); if claimed, it is the sole ordinary message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. A running `next-step`/wakeup `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `next-step`/no-wakeup `inject()` bypasses the FIFOs and appends durable context directly: an open-turn injection uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append.
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, InboxItemInfo, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -100,7 +100,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context):
|
||||
* the loop driver. Everything observable happens through session events and
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
export class ReactLoopAgent extends Agent {
|
||||
/** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */
|
||||
readonly #inbox = new Inbox()
|
||||
|
||||
@@ -161,6 +161,7 @@ export class ReactLoopAgent implements Agent {
|
||||
public readonly session: Session,
|
||||
maxParallelToolCalls: number,
|
||||
) {
|
||||
super()
|
||||
this.maxParallelToolCalls = maxParallelToolCalls
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.disposed = promise
|
||||
@@ -190,25 +191,25 @@ export class ReactLoopAgent implements Agent {
|
||||
for (const resolve of waiters) resolve()
|
||||
}
|
||||
|
||||
private resolveSource(options?: SendOptions): MessageSource {
|
||||
return options?.source ?? { kind: 'user' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one public message payload as a detached record. Lossless-JSON
|
||||
* materialization reads every nested field once; deep freeze prevents later
|
||||
* caller mutation before an inbox or deferred-injection queue drains it.
|
||||
*/
|
||||
private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
|
||||
const source = this.resolveSource(options)
|
||||
private acceptMessage(content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions): InboxMessage {
|
||||
const contexts = options?.contexts ?? []
|
||||
const accepted = snapshotJsonValue({ content, source, contexts })
|
||||
const accepted = snapshotJsonValue({ content, source, contexts, wakeup })
|
||||
if (accepted === undefined) {
|
||||
throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable')
|
||||
}
|
||||
return deepFreeze(accepted)
|
||||
}
|
||||
|
||||
/** Build the `agent/inbox/*` payload for one accepted item. */
|
||||
private inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo {
|
||||
return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
|
||||
}
|
||||
|
||||
/** Detach one context before it can outlive its caller in the active-batch FIFO. */
|
||||
private acceptContext(context: HookContext): HookContext {
|
||||
const accepted = snapshotJsonValue(context)
|
||||
@@ -225,24 +226,26 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertNotDisposed()
|
||||
const accepted = this.acceptMessage(content, options)
|
||||
this.#inbox.enqueue(accepted)
|
||||
const info = { source: accepted.source, contexts: accepted.contexts, steering: false } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
const target = options?.target ?? 'next-turn'
|
||||
const wakeup = options?.wakeup ?? true
|
||||
// next-step/no-wakeup is injection: durable context without running the model.
|
||||
if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return }
|
||||
// next-step/wakeup is steering into the running turn; idle falls back to a
|
||||
// woken follow-up turn (there is no active turn to attach to).
|
||||
const steering = target === 'next-step' && this._status === 'running'
|
||||
const source = options?.source ?? { kind: 'user' }
|
||||
const accepted = this.acceptMessage(content, source, wakeup, options)
|
||||
if (steering) {
|
||||
this.#inbox.steer(accepted)
|
||||
} else {
|
||||
this.#inbox.enqueue(accepted, wakeup)
|
||||
}
|
||||
agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', this.inboxInfo(accepted, steering))
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertNotDisposed()
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const accepted = this.acceptMessage(content, options)
|
||||
this.#inbox.steer(accepted)
|
||||
const info = { source: accepted.source, contexts: accepted.contexts, steering: true } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: InjectOptions): void {
|
||||
this.assertNotDisposed()
|
||||
const source = this.resolveSource(options)
|
||||
/** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */
|
||||
private injectContext(content: ContentBlock[], options?: SendOptions): void {
|
||||
const source = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
const context = {
|
||||
content,
|
||||
source,
|
||||
@@ -257,7 +260,7 @@ export class ReactLoopAgent implements Agent {
|
||||
this.deferredInjections.push(accepted)
|
||||
return
|
||||
}
|
||||
this.session.append('context/message', accepted, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', accepted, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
@@ -269,7 +272,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// are contained by Session and cannot create a false append failure.
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', context, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', context, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. A pre-commit veto
|
||||
// must escape rather than being mistaken for a committed turn/end.
|
||||
@@ -301,7 +304,7 @@ export class ReactLoopAgent implements Agent {
|
||||
private drainDeferredInjections(): void {
|
||||
const pending = this.deferredInjections.splice(0)
|
||||
for (const accepted of pending) {
|
||||
this.session.append('context/message', accepted, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', accepted, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,10 +328,14 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
cancel(cause?: AgentCancelCause): void {
|
||||
cancel(cause?: AgentCancelCause, options?: CancelOptions): void {
|
||||
const resolvedCause = cause ?? { kind: 'user' }
|
||||
const keepInbox = options?.keepInbox ?? false
|
||||
const cancellation = this.turnCancellation
|
||||
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
|
||||
// keepInbox preserves pending work, so un-started items must not arm the
|
||||
// pre-run cancel path that would otherwise drop the next queued turn.
|
||||
const preRun = !keepInbox && cancellation === undefined
|
||||
&& (this.#inbox.hasQueued || this.#inbox.hasSteering)
|
||||
if (cancellation !== undefined || preRun) {
|
||||
if (preRun) this.preRunCancelled = true
|
||||
// Coordination consumers must update their own state before this call
|
||||
@@ -336,9 +343,18 @@ export class ReactLoopAgent implements Agent {
|
||||
// contained by the fused dispatcher and cannot veto cancellation.
|
||||
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
|
||||
}
|
||||
// Clear work already present before abort observers run. A replacement
|
||||
// synchronously enqueued by an observer belongs to the next turn.
|
||||
this.#inbox.clear()
|
||||
if (!keepInbox) {
|
||||
// Snapshot before clearing so the discard notification carries the exact
|
||||
// dropped items; a replacement synchronously enqueued by an
|
||||
// `agent/cancel-requested` observer belongs to the next turn, not here.
|
||||
const discarded = this.#inbox.pending()
|
||||
// Clear work already present before abort observers run.
|
||||
this.#inbox.clear()
|
||||
if (discarded.length > 0) {
|
||||
const items = discarded.map(({ message, steering }) => this.inboxInfo(message, steering))
|
||||
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
|
||||
}
|
||||
}
|
||||
cancellation?.request(resolvedCause)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Per-agent message inbox: queued and steering FIFOs. Purely an in-memory
|
||||
* mechanism of the loop driver — the public surface is `Agent.send()` and
|
||||
* `Agent.steer()`.
|
||||
* mechanism of the loop driver — the public surface is `Agent.send()` and its
|
||||
* fixed-preset aliases.
|
||||
*
|
||||
* @module dsh-agent-loop/inbox
|
||||
*/
|
||||
@@ -14,12 +14,14 @@ export interface InboxMessage {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
contexts: HookContext[]
|
||||
/** Whether the item is marked to wake the driver or force a continuation. */
|
||||
wakeup: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
|
||||
* (drained between steps of a running turn). Purely an in-memory mechanism of
|
||||
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
|
||||
* the loop — the public surface is `Agent.send()` and its aliases.
|
||||
*/
|
||||
export class Inbox {
|
||||
private queuedMessages: InboxMessage[] = []
|
||||
@@ -37,18 +39,21 @@ export class Inbox {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
|
||||
* Add a message to the queued FIFO, waking a parked {@link waitForQueued}
|
||||
* unless the item opted out. A non-waking item still runs once any woken
|
||||
* item or later wakeup drives the parked loop.
|
||||
* @param message - the message to queue for the next turn start.
|
||||
* @param wake - whether to wake a parked idle wait (default true).
|
||||
*/
|
||||
enqueue(message: InboxMessage): void {
|
||||
enqueue(message: InboxMessage, wake = true): void {
|
||||
this.queuedMessages.push(message)
|
||||
this.wakeup?.()
|
||||
if (wake) this.wakeup?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
|
||||
* drained between steps of a running turn, never by the idle wait —
|
||||
* `Agent.steer()` on an idle agent falls back to `send()` instead.
|
||||
* `Agent.steer()` on an idle agent falls back to a woken follow-up instead.
|
||||
* @param message - the message to inject between steps of the running turn.
|
||||
*/
|
||||
steer(message: InboxMessage): void {
|
||||
@@ -71,6 +76,18 @@ export class Inbox {
|
||||
return this.steeringMessages.splice(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the pending items (queued then steering, FIFO order) without
|
||||
* removing them — the discard notification's payload source.
|
||||
* @returns the pending items paired with whether each is steering.
|
||||
*/
|
||||
pending(): { message: InboxMessage; steering: boolean }[] {
|
||||
return [
|
||||
...this.queuedMessages.map(message => ({ message, steering: false })),
|
||||
...this.steeringMessages.map(message => ({ message, steering: true })),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard all pending messages (queued + steering) without delivering them —
|
||||
* used by `cancel()`, which drops un-started work rather than draining it into
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFai
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, InboxItemInfo, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
@@ -19,9 +19,14 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
import type { Inbox, InboxMessage } from './inbox.ts'
|
||||
import type { TurnCancellation } from './cancellation.ts'
|
||||
|
||||
/** Build the `agent/inbox/dequeue` payload for one claimed inbox item. */
|
||||
function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo {
|
||||
return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
|
||||
}
|
||||
|
||||
/** Normalize thrown values while preserving an existing error code. */
|
||||
function toError(error: unknown): RequestError {
|
||||
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
|
||||
@@ -279,10 +284,11 @@ async function runTurn(
|
||||
const drainSteering = (): boolean => {
|
||||
const messages = handle.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
events.emit('agent/inbox/dequeue', inboxInfo(message, true))
|
||||
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
|
||||
session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' })
|
||||
for (const context of prepared.separateContexts) {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: context.content,
|
||||
source: context.source,
|
||||
...context.meta === undefined ? {} : { meta: context.meta },
|
||||
@@ -296,6 +302,7 @@ async function runTurn(
|
||||
const message = handle.inbox.dequeueQueued()
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
events.emit('agent/inbox/dequeue', inboxInfo(message, false))
|
||||
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
||||
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
@@ -538,7 +545,7 @@ async function runTurn(
|
||||
|
||||
// A continuation reason becomes next-step steering.
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [] })
|
||||
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true })
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('Agent', () => {
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.at(-1)!.type).toBe('context/message')
|
||||
expect(agent.session.events.at(-1)!.type).toBe('user/message')
|
||||
|
||||
// Close the turn; now inject must wrap its own one-shot injection turn.
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
@@ -151,6 +151,16 @@ describe('Agent', () => {
|
||||
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
|
||||
})
|
||||
|
||||
it('inject() defaults its source to an empty plugin, never user', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.inject([{ type: 'text', text: 'no explicit source' }])
|
||||
const injected = agent.session.events.at(-1)!
|
||||
expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' })
|
||||
})
|
||||
|
||||
it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -202,7 +212,7 @@ describe('Agent', () => {
|
||||
|
||||
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toEqual(['turn/start', 'context/message', 'turn/end']) // balanced
|
||||
expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener
|
||||
})
|
||||
|
||||
@@ -98,6 +98,25 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
})
|
||||
|
||||
it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const discards: unknown[] = []
|
||||
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
|
||||
|
||||
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
|
||||
agent.send([{ type: 'text', text: 'preserved' }], { target: 'next-turn', wakeup: false })
|
||||
// keepInbox cancel: no active turn, work preserved, no discard event.
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
expect(discards).toEqual([])
|
||||
|
||||
// The preserved item still runs once the driver is woken by a later send.
|
||||
send(agent, 'wake it')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
|
||||
})
|
||||
|
||||
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -275,7 +275,9 @@ describe('abort during tool execution ends the turn', () => {
|
||||
order.push(`tool/result:${event.data.callId}:${outcome}`)
|
||||
break
|
||||
}
|
||||
case 'context/message': order.push('context/message'); break
|
||||
// Injected context is a plugin-sourced user/message; the direct human
|
||||
// prompt (user source) is not tracked in this ordering.
|
||||
case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break
|
||||
case 'steering/message': order.push('steering/message'); break
|
||||
case 'step/end': order.push('step/end'); break
|
||||
case 'turn/end': {
|
||||
@@ -354,13 +356,14 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
|
||||
expect(events
|
||||
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
|
||||
.filter(event => event.type === 'tool/result' || isInjected(event)
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => event.type))
|
||||
.map(event => isInjected(event) ? 'context/message' : event.type))
|
||||
.toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end'])
|
||||
expect(events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.filter(isInjected)
|
||||
.map(event => event.data.content))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted before abort' }],
|
||||
@@ -410,12 +413,13 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
|
||||
expect(events
|
||||
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
|
||||
.filter(event => event.type === 'tool/result' || isInjected(event)
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => event.type))
|
||||
.map(event => isInjected(event) ? 'context/message' : event.type))
|
||||
.toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end'])
|
||||
expect(events.find(event => event.type === 'context/message')?.data.content)
|
||||
expect(events.find(isInjected)?.data.content)
|
||||
.toEqual([{ type: 'text', text: 'accepted after first result' }])
|
||||
})
|
||||
|
||||
@@ -456,7 +460,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await fiber.dispose()
|
||||
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
.map(event => event.data.content))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted before disposal' }],
|
||||
@@ -507,7 +511,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
send(agent, 'start a text-only turn')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.find(event => event.type === 'context/message')?.data.content)
|
||||
expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content)
|
||||
.toEqual([{ type: 'text', text: 'new turn context' }])
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
|
||||
})
|
||||
@@ -763,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
||||
})
|
||||
|
||||
it('agent/queued carries the resolved source; steering/message records its source', async () => {
|
||||
it('agent/inbox/enqueue carries the resolved source; steering/message records 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' })
|
||||
@@ -778,7 +782,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
}))
|
||||
|
||||
const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = []
|
||||
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
|
||||
ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering }))
|
||||
|
||||
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -800,11 +804,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
let notifiedContexts: HookContext[] | undefined
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
ctx.on('agent/inbox/enqueue', (subject, info) => {
|
||||
if (subject !== agent || info.steering) return
|
||||
// Retain the exact notification references: cloning here would test the
|
||||
// listener's copy rather than the event/inbox ownership boundary.
|
||||
notifiedContent = acceptedContent
|
||||
notifiedContent = info.content
|
||||
notifiedSource = info.source
|
||||
notifiedContexts = info.contexts
|
||||
})
|
||||
@@ -863,9 +867,9 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
let notifiedContexts: HookContext[] | undefined
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
ctx.on('agent/inbox/enqueue', (subject, info) => {
|
||||
if (subject !== agent || !info.steering) return
|
||||
notifiedContent = acceptedContent
|
||||
notifiedContent = info.content
|
||||
notifiedSource = info.source
|
||||
notifiedContexts = info.contexts
|
||||
})
|
||||
@@ -951,7 +955,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
expect(request).not.toContain('caller-mutated-steering-context-without-meta')
|
||||
|
||||
const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message')
|
||||
const contextIndex = agent.session.events.findIndex(event => event.type === 'context/message'
|
||||
const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context')
|
||||
expect(steeringIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(contextIndex).toBe(steeringIndex + 1)
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('inbox acceptance', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let queued = 0
|
||||
ctx.on('agent/queued', () => { queued += 1 })
|
||||
ctx.on('agent/inbox/enqueue', () => { queued += 1 })
|
||||
|
||||
expect(() => {
|
||||
agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Inbox } from '../src/inbox.ts'
|
||||
|
||||
function message(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [] }
|
||||
return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true }
|
||||
}
|
||||
|
||||
function resolverPair() {
|
||||
@@ -25,6 +25,32 @@ describe('Inbox', () => {
|
||||
expect(inbox.dequeueQueued()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('enqueue(msg, false) queues without waking a parked waiter', async () => {
|
||||
const inbox = new Inbox()
|
||||
let woke = false
|
||||
const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true })
|
||||
inbox.enqueue(message('quiet'), false)
|
||||
// The item is queued, but the parked waiter was not resolved by it.
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(woke).toBe(false)
|
||||
// A later waking enqueue resolves the same waiter.
|
||||
inbox.enqueue(message('loud'))
|
||||
await waiter
|
||||
expect(woke).toBe(true)
|
||||
})
|
||||
|
||||
it('pending() snapshots queued then steering without removing them', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue(message('q'))
|
||||
inbox.steer(message('s'))
|
||||
const pending = inbox.pending()
|
||||
expect(pending.map(p => p.steering)).toEqual([false, true])
|
||||
// Snapshot does not drain the FIFOs.
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
expect(inbox.hasSteering).toBe(true)
|
||||
})
|
||||
|
||||
it('pushes and drains steering messages separately from queued', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.steer(message('steer'))
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
|
||||
})
|
||||
|
||||
it('allow with additionalContexts injects separate context/message events into the turn', async () => {
|
||||
it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -107,12 +107,12 @@ describe('agent/prompt-submit', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const userMsg = log.find(e => e.type === 'user/message')
|
||||
const ctxMsg = log.find(e => e.type === 'context/message')
|
||||
const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user')
|
||||
const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta)
|
||||
const sent = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
@@ -155,7 +155,7 @@ describe('agent/prompt-submit', () => {
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(log.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
|
||||
expect(adapter.requests[0]?.messages.at(-1)).toEqual({
|
||||
role: 'user',
|
||||
content: [
|
||||
@@ -215,7 +215,6 @@ describe('agent/prompt-submit', () => {
|
||||
expect(log.some(e => e.type === 'turn/start')).toBe(true)
|
||||
expect(log.some(e => e.type === 'turn/end')).toBe(true)
|
||||
expect(log.some(e => e.type === 'user/message')).toBe(false)
|
||||
expect(log.some(e => e.type === 'context/message')).toBe(false)
|
||||
expect(log.some(e => e.type === 'step/start')).toBe(false)
|
||||
// the veto is recorded durably as a prompt/blocked in the open turn
|
||||
const blocked = log.find(e => e.type === 'prompt/blocked')
|
||||
@@ -340,8 +339,8 @@ describe('agent/session-start', () => {
|
||||
// the injected context reached the model on the first (only) request
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
// and is recorded with the plugin source, never mislabeled as a user prompt
|
||||
const ctxMsg = events(agent).find(e => e.type === 'context/message')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
})
|
||||
|
||||
it('a throwing session-start listener does not abort agent construction', async () => {
|
||||
@@ -624,23 +623,22 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Event order in the log: both tool/results, THEN both context/messages —
|
||||
// Event order in the log: both tool/results, THEN both injected contexts —
|
||||
// never interleaved (which would break tool-call/result adjacency).
|
||||
const types = events(agent).map(e => e.type)
|
||||
const firstResult = types.indexOf('tool/result')
|
||||
const lastResult = types.lastIndexOf('tool/result')
|
||||
const firstCtx = types.indexOf('context/message')
|
||||
const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
const seqs = events(agent)
|
||||
const firstResult = seqs.findIndex(e => e.type === 'tool/result')
|
||||
const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result')
|
||||
const firstCtx = seqs.findIndex(e => e === injected[0])
|
||||
expect(firstResult).toBeGreaterThanOrEqual(0)
|
||||
expect(lastResult).toBeGreaterThan(firstResult) // two results
|
||||
expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
|
||||
// both contexts present
|
||||
const ctxTexts = events(agent)
|
||||
.filter(e => e.type === 'context/message')
|
||||
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
|
||||
const ctxTexts = injected
|
||||
.flatMap(e => (e.type === 'user/message' ? e.data.content : []))
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
const contextEvents = events(agent).filter(e => e.type === 'context/message')
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
})
|
||||
|
||||
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
|
||||
@@ -661,14 +659,14 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
|
||||
const log = events(agent)
|
||||
const resultIndex = log.findIndex(event => event.type === 'tool/result')
|
||||
const contextEvents = log.filter(event => event.type === 'context/message')
|
||||
const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
expect(resultIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
|
||||
expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([
|
||||
expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'a' },
|
||||
{ kind: 'plugin', plugin: 'b' },
|
||||
])
|
||||
expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
|
||||
expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -750,13 +748,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
|
||||
const log = events(agent)
|
||||
// session-start preamble injected
|
||||
expect(log.some(e => e.type === 'context/message'
|
||||
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
|
||||
// prompt allowed → user/message recorded
|
||||
expect(log.some(e => e.type === 'user/message')).toBe(true)
|
||||
// prompt allowed → user-sourced user/message recorded
|
||||
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true)
|
||||
// tool ran (echo allowed) and post-execute attached "audited" context
|
||||
expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
|
||||
expect(log.some(e => e.type === 'context/message'
|
||||
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
|
||||
// NO hook/* events — a native plugin needs none
|
||||
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('request-reconstruction invariant', () => {
|
||||
|
||||
it('uses the step boundary rather than content appended afterward', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
|
||||
session.append('user/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
|
||||
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -380,7 +380,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
|
||||
// The idle inject records a self-contained turn (turn/start → context/message
|
||||
// The idle inject records a self-contained turn (turn/start → user/message
|
||||
// → turn/end) so the event stays turn-enclosed, but does NOT run the model.
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(agent.status).toBe('idle')
|
||||
@@ -416,8 +416,8 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
|
||||
const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
expect(contextEvent?.type === 'user/message' && contextEvent.data).toMatchObject({ meta })
|
||||
const requestText = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(requestText).not.toContain('<context source=')
|
||||
@@ -445,7 +445,7 @@ describe('agent loop', () => {
|
||||
})
|
||||
first.text = 'mutated after inject'
|
||||
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
|
||||
visibleDuringTool = agent.session.events.some(e => e.type === 'context/message')
|
||||
visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
}))
|
||||
@@ -462,13 +462,13 @@ describe('agent loop', () => {
|
||||
const ts0 = turnStarts[0]!
|
||||
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
|
||||
const result = agent.session.events.find(e => e.type === 'tool/result')!
|
||||
const contexts = agent.session.events.filter(e => e.type === 'context/message')
|
||||
const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(result.seq).toBeLessThan(contexts[0]!.seq)
|
||||
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
|
||||
expect(contexts[0]?.type === 'user/message' && contexts[0].data).toMatchObject({
|
||||
meta,
|
||||
})
|
||||
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
|
||||
expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : []))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'mid-turn notice' },
|
||||
{ type: 'text', text: 'second notice' },
|
||||
@@ -512,7 +512,7 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
|
||||
})
|
||||
|
||||
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
|
||||
@@ -621,7 +621,7 @@ describe('agent loop', () => {
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
subject.session.append('context/message', {
|
||||
subject.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -639,7 +639,7 @@ describe('agent loop', () => {
|
||||
// And the injected event sits BEFORE the first step/start in the log —
|
||||
// the seam fired outside the step.
|
||||
const events = agent.session.events
|
||||
const injectedSeq = events.find(e => e.type === 'context/message')!.seq
|
||||
const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq
|
||||
const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq
|
||||
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
|
||||
})
|
||||
@@ -1017,13 +1017,13 @@ describe('agent loop', () => {
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
|
||||
})
|
||||
|
||||
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
|
||||
it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let nested = false
|
||||
ctx.on('agent/queued', (subject) => {
|
||||
ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject !== agent || nested) return
|
||||
nested = true
|
||||
send(agent, 'queued listener message')
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('request stability across the loop', () => {
|
||||
preStep()
|
||||
const session = agent.session
|
||||
const nodes = session.surface.nodes
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: '[summary of turn 1]' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compact' },
|
||||
}, {
|
||||
@@ -180,7 +180,7 @@ describe('request stability across the loop', () => {
|
||||
const first = adapter.requests[0]!
|
||||
// The inject landed in the log after the boundary: not in THIS request…
|
||||
expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false)
|
||||
expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true)
|
||||
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -154,12 +154,15 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
const order: string[] = []
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
// Injected context is a plugin-sourced user/message; the direct human
|
||||
// prompt (user source) stays untracked as before.
|
||||
const isInjected = event.type === 'user/message' && event.data.source.kind !== 'user'
|
||||
if (
|
||||
event.type === 'assistant/message' || event.type === 'tool/call'
|
||||
|| event.type === 'tool/result' || event.type === 'context/message'
|
||||
|| event.type === 'tool/result' || isInjected
|
||||
|| event.type === 'steering/message' || event.type === 'step/end'
|
||||
) {
|
||||
if (!('step' in event.data) || event.data.step === 1) order.push(event.type)
|
||||
if (!('step' in event.data) || event.data.step === 1) order.push(isInjected ? 'context/message' : event.type)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/post-step', (subject, turn, step, signal) => {
|
||||
@@ -271,7 +274,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
|
||||
expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
attempts.push(history.length)
|
||||
subject.session.append('context/message', {
|
||||
subject.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
|
||||
source: { kind: 'plugin', plugin: 'test-recovery' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -288,7 +291,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const ends = agent.session.events.filter(event => event.type === 'step/end')
|
||||
expect(starts.map(event => event.data.step)).toEqual([1, 2])
|
||||
expect(ends.map(event => event.data.step)).toEqual([1, 2])
|
||||
const recovery = agent.session.events.find(event => event.type === 'context/message')!
|
||||
const recovery = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')!
|
||||
expect(ends[0]!.seq).toBeLessThan(recovery.seq)
|
||||
expect(recovery.seq).toBeLessThan(starts[1]!.seq)
|
||||
})
|
||||
|
||||
@@ -403,11 +403,11 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const contextTexts = log.filter(e => e.type === 'context/message')
|
||||
.map(e => (e.data.content[0] as { text: string }).text)
|
||||
const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
.map(e => ((e.data as { content: { text: string }[] }).content[0]!).text)
|
||||
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
const lastResult = log.findLastIndex(e => e.type === 'tool/result')
|
||||
const firstContext = log.findIndex(e => e.type === 'context/message')
|
||||
const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(lastResult).toBeLessThan(firstContext)
|
||||
})
|
||||
|
||||
@@ -544,10 +544,11 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
|
||||
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
|
||||
])
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result'
|
||||
|| (e.type === 'user/message' && e.data.source.kind === 'plugin'))
|
||||
expect(settled.map(e => e.type))
|
||||
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'context/message', 'context/message'])
|
||||
expect(settled.filter(e => e.type === 'context/message')
|
||||
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message'])
|
||||
expect(settled.filter(e => e.type === 'user/message')
|
||||
.map(e => (e.data.content[0] as { text: string }).text))
|
||||
.toEqual(['ctx-c1', 'ctx-c2'])
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
|
||||
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent `context/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
|
||||
`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message` (plugin/goal source); `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
|
||||
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
@@ -56,10 +56,11 @@ Turn and step boundaries and the model token stream are durable `session/event`
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become `context/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to `send()`. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
|
||||
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
|
||||
- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
|
||||
- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
|
||||
- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
|
||||
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event.
|
||||
- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
|
||||
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
|
||||
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
|
||||
|
||||
@@ -107,6 +108,6 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
|
||||
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
|
||||
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
|
||||
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
|
||||
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
|
||||
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).
|
||||
|
||||
@@ -24,6 +24,27 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
}
|
||||
lastStatus.set(agent, status)
|
||||
}, { global: true })
|
||||
|
||||
// Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped
|
||||
// (discard) only after it entered (enqueue), so the live outstanding count
|
||||
// per agent can never go negative. Injection bypasses the FIFOs entirely and
|
||||
// never appears on these events.
|
||||
const outstanding = new WeakMap<Agent, number>()
|
||||
ctx.on('agent/inbox/enqueue', (agent) => {
|
||||
outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1)
|
||||
}, { global: true })
|
||||
ctx.on('agent/inbox/dequeue', (agent) => {
|
||||
const count = outstanding.get(agent) ?? 0
|
||||
if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue')
|
||||
outstanding.set(agent, count - 1)
|
||||
}, { global: true })
|
||||
ctx.on('agent/inbox/discard', (agent, items) => {
|
||||
const count = outstanding.get(agent) ?? 0
|
||||
if (items.length > count) {
|
||||
fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`)
|
||||
}
|
||||
outstanding.set(agent, count - items.length)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,10 +26,33 @@ export interface AgentOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Message options. An omitted source attests direct human input as `{ kind: 'user' }`
|
||||
* and may authorize policy consumers, so non-human producers must label their content.
|
||||
* Which inbox queue a {@link Agent.send} item joins:
|
||||
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
|
||||
* - `next-step` — the item joins the active turn between steps as steering,
|
||||
* or, when no turn is active, is promoted per its `wakeup` flag.
|
||||
*/
|
||||
export type SendTarget = 'next-turn' | 'next-step'
|
||||
|
||||
/**
|
||||
* Options for the unified {@link Agent.send} primitive over the
|
||||
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
|
||||
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
|
||||
* {@link Agent.inject} (`next-step`/no-wakeup).
|
||||
*
|
||||
* An omitted source attests direct human input as `{ kind: 'user' }` and may
|
||||
* authorize policy consumers, so non-human producers must label their content.
|
||||
*/
|
||||
export interface SendOptions {
|
||||
/** Queue the item joins; defaults to `next-turn`. */
|
||||
target?: SendTarget
|
||||
/**
|
||||
* Whether this item makes the model run: wake a parked driver (`next-turn`)
|
||||
* or force a continuation step (`next-step` while running). Defaults to
|
||||
* `true`. A `false` `next-turn` item queues without waking; a `false`
|
||||
* `next-step` item attaches durable context without forcing another step
|
||||
* (the injection preset).
|
||||
*/
|
||||
wakeup?: boolean
|
||||
source?: MessageSource
|
||||
/**
|
||||
* Model-facing contexts captured with this inbox item. A queued prompt exposes
|
||||
@@ -37,19 +60,44 @@ export interface SendOptions {
|
||||
* records them directly at its next checkpoint.
|
||||
*/
|
||||
contexts?: HookContext[]
|
||||
/** Opaque JSON state retained on the durable message but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/** Options specific to durable synthetic context injection. */
|
||||
export interface InjectOptions extends Omit<SendOptions, 'contexts'> {
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
|
||||
export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>
|
||||
|
||||
/**
|
||||
* The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*`
|
||||
* live events. Source defaults are already applied, so these are the exact
|
||||
* values the item was accepted with. `steering` is true for a `next-step`
|
||||
* item drained between steps; a `next-turn` item is claimed at a turn boundary.
|
||||
*/
|
||||
export interface InboxItemInfo {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
contexts: HookContext[]
|
||||
/** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */
|
||||
steering: boolean
|
||||
/** Whether the item is marked to wake the driver or force a continuation. */
|
||||
wakeup: boolean
|
||||
}
|
||||
|
||||
/** Options for {@link Agent.cancel}. */
|
||||
export interface CancelOptions {
|
||||
/**
|
||||
* Preserve queued and steering inbox items instead of discarding them. The
|
||||
* active turn is still aborted, but un-started and pending work survives for a
|
||||
* later turn and no `agent/inbox/discard` fires.
|
||||
*/
|
||||
keepInbox?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (the driver is draining
|
||||
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
|
||||
* transition leaves it, and `send`/`steer`/`inject` throw).
|
||||
* transition leaves it, and `send`/`followup`/`steer`/`inject` throw).
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
@@ -58,8 +106,8 @@ export interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
/**
|
||||
* Model placement. Absent or `separate` records an independent
|
||||
* `context/message`; `prompt-prefix` prepends this context and a stable
|
||||
* Model placement. Absent or `separate` records an independent injected
|
||||
* `user/message`; `prompt-prefix` prepends this context and a stable
|
||||
* request delimiter to the same user-role message as its attached prompt.
|
||||
*/
|
||||
placement?: 'separate' | 'prompt-prefix'
|
||||
@@ -109,58 +157,100 @@ export type AgentCancelCause =
|
||||
/** Runtime reason carried by the signal that controls one live turn. */
|
||||
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
||||
|
||||
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
|
||||
export interface Agent {
|
||||
/**
|
||||
* Public agent handle; its concrete implementation is internal to
|
||||
* `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so
|
||||
* the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer},
|
||||
* {@link Agent.inject}) are shared concrete delegates over the single abstract
|
||||
* {@link Agent.send} primitive; concrete drivers implement `send` once.
|
||||
*/
|
||||
export abstract class Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
readonly options: AgentOptions
|
||||
readonly session: Session
|
||||
readonly status: AgentStatus
|
||||
abstract readonly id: SessionId
|
||||
/** The provider route and model this agent's requests use. */
|
||||
abstract readonly options: AgentOptions
|
||||
/** The live session this agent drives; its log is the durable source of truth. */
|
||||
abstract readonly session: Session
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
abstract readonly status: AgentStatus
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
abstract readonly ctx: Context
|
||||
|
||||
/**
|
||||
* Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
|
||||
* ordinary message in its FIFO-ordered turn; the next claimed item waits for
|
||||
* that turn's checkpoint.
|
||||
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
|
||||
* Detaches, validates, and freezes one lossless-JSON item, then routes it:
|
||||
*
|
||||
* - `next-turn` (default) queues an item that becomes the sole ordinary
|
||||
* message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a
|
||||
* parked driver, while `wakeup:false` queues without waking.
|
||||
* - `next-step` with `wakeup:true` submits steering into the active turn
|
||||
* (idle falls back to a woken `next-turn`).
|
||||
* - `next-step` with `wakeup:false` injects durable model-facing context
|
||||
* without running the model: an open turn joins at the current log position
|
||||
* (deferred behind an executing tool batch until it settles), and an idle
|
||||
* inject records a one-shot turn with its own durability checkpoint.
|
||||
*
|
||||
* Attached contexts share the same snapshot and ownership boundary. Invalid
|
||||
* input throws synchronously before notification or enqueue.
|
||||
* input throws synchronously before any notification, enqueue, or append.
|
||||
* @param content - the model-facing content blocks to deliver.
|
||||
* @param options - target queue, wakeup decision, source, contexts, and meta.
|
||||
*/
|
||||
send(content: ContentBlock[], options?: SendOptions): void
|
||||
abstract send(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* Submit steering while the agent is `running`. An open turn records it at
|
||||
* the next steering checkpoint before a request or continuation decision;
|
||||
* policy may stop before another step. After turn close and its checkpoint,
|
||||
* any remainder is queued for a later turn; terminal `agent/turn-stop`,
|
||||
* cancellation, or disposal may discard it. Uses the same synchronous
|
||||
* snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* Append detached model-facing context without running the model. An open-turn
|
||||
* injection joins at the current log position unless the current tool batch is
|
||||
* executing; then it waits FIFO until that batch settles and drains before turn
|
||||
* close even when interrupted. Idle injection uses a one-shot turn and durability
|
||||
* checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`.
|
||||
*/
|
||||
inject(content: ContentBlock[], options?: InjectOptions): void
|
||||
|
||||
/**
|
||||
* Clear all queued and steering work, including items waiting to start, and
|
||||
* abort the active turn. An effective call first emits
|
||||
* `agent/cancel-requested` with the resolved typed cause. The first cause wins
|
||||
* for the active turn, and `whenIdle()` resolves after cancellation reaches
|
||||
* quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op
|
||||
* and does not arm later work. The active turn snapshots and freezes the cause.
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
* turn. An effective call first emits `agent/cancel-requested` with the
|
||||
* resolved typed cause. The first cause wins for the active turn, and
|
||||
* `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
|
||||
* means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
|
||||
* later work. The active turn snapshots and freezes the cause.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
*/
|
||||
cancel(cause?: AgentCancelCause): void
|
||||
abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void
|
||||
|
||||
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||
whenIdle(): Promise<void>
|
||||
abstract whenIdle(): Promise<void>
|
||||
|
||||
/**
|
||||
* Queue an ordinary follow-up turn and wake the driver — the
|
||||
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
|
||||
* ordinary message of its own turn.
|
||||
* @param content - the prompt content blocks.
|
||||
* @param options - source and attached contexts.
|
||||
*/
|
||||
followup(content: ContentBlock[], options?: AliasSendOptions): void {
|
||||
this.send(content, { ...options, target: 'next-turn', wakeup: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
||||
* {@link send}. An open turn records it at the next steering checkpoint before
|
||||
* a request or continuation decision; policy may stop before another step.
|
||||
* After turn close and its checkpoint, any remainder is queued for a later
|
||||
* turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it.
|
||||
* Idle steering falls back to a woken follow-up turn.
|
||||
* @param content - the steering content blocks.
|
||||
* @param options - source and attached contexts.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: AliasSendOptions): void {
|
||||
this.send(content, { ...options, target: 'next-step', wakeup: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append detached model-facing context without running the model — the
|
||||
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins
|
||||
* at the current log position unless the current tool batch is executing;
|
||||
* then it waits FIFO until that batch settles and drains before turn close
|
||||
* even when interrupted. Idle injection uses a one-shot turn and durability
|
||||
* checkpoint. Disposal awaits idle checkpoints; flush failures report through
|
||||
* `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`.
|
||||
* @param content - the injected context content blocks.
|
||||
* @param options - source and durable model-hidden meta.
|
||||
*/
|
||||
inject(content: ContentBlock[], options?: AliasSendOptions): void {
|
||||
this.send(content, { ...options, target: 'next-step', wakeup: false })
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -196,15 +286,37 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* Detached, frozen content entered the agent's inbox. Source defaults have
|
||||
* already been applied, so these are the exact values retained for the log.
|
||||
* @param agent - the agent whose inbox received the message.
|
||||
* @param content - the accepted content blocks retained by the inbox.
|
||||
* @param info - the accepted source, contexts, and whether it entered as steering.
|
||||
* A detached, frozen item entered the agent's inbox (queued or steering
|
||||
* FIFO). Source defaults are already applied, so `info` holds the exact
|
||||
* accepted values. This is the enqueue-time live signal; the durable record
|
||||
* is the eventual `user/message`/`steering/message`. Injection
|
||||
* (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.
|
||||
* @param agent - the agent whose inbox received the item.
|
||||
* @param info - the accepted content, source, contexts, steering, and wakeup facts.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void
|
||||
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void
|
||||
/**
|
||||
* The driver claimed one item out of the inbox: a queued item at a turn
|
||||
* boundary, or steering drained between steps. Fires after the item leaves
|
||||
* its FIFO and before it becomes a durable message.
|
||||
* @param agent - the agent whose inbox item was claimed.
|
||||
* @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void
|
||||
/**
|
||||
* `cancel()` (without `keepInbox`) dropped pending inbox items without
|
||||
* delivering them. Fires once per effective clearing call with every
|
||||
* discarded item, after `agent/cancel-requested` and before the abort.
|
||||
* @param agent - the agent whose inbox was cleared.
|
||||
* @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItemInfo[]): void
|
||||
/**
|
||||
* Effective broad cancellation was requested, before queued/steering work
|
||||
* is cleared or the active turn is aborted. This observe-only notification
|
||||
|
||||
@@ -3,26 +3,28 @@ import { Context, Service, symbols } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, {
|
||||
Agent,
|
||||
agentEvents,
|
||||
agentInterruptReasonOf,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
const id = SessionId(rawId)
|
||||
return {
|
||||
// Agent is an abstract class, so its alias methods live on the prototype and
|
||||
// object spread would drop them; build the full literal and merge overrides.
|
||||
return Object.assign(Object.create(Agent.prototype) as Agent, {
|
||||
id,
|
||||
options: {},
|
||||
session: new Session(id),
|
||||
status: 'idle',
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
@@ -56,7 +58,7 @@ describe('AgentRegistry', () => {
|
||||
it('rejects an agent whose registry and session identities differ', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) }
|
||||
const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) })
|
||||
|
||||
expect(() => ctx.agents.enter(agent, undefined))
|
||||
.toThrow('agent id "agent-id" does not match session id "session-id"')
|
||||
|
||||
@@ -56,3 +56,41 @@ describe('agent status invariants', () => {
|
||||
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent inbox invariants', () => {
|
||||
const info = (steering: boolean) => ({ content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true })
|
||||
|
||||
it('accepts a dequeue and a discard covered by prior enqueues', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('i1')
|
||||
const at = scopeTarget(agent, agent)
|
||||
expect(() => {
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(true))
|
||||
ctx.emit(at, 'agent/inbox/dequeue', agent, info(false))
|
||||
ctx.emit(at, 'agent/inbox/discard', agent, [info(true)])
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a dequeue with no outstanding item', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('i2')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(false)) })
|
||||
.toThrow(/without a matching prior enqueue/)
|
||||
})
|
||||
|
||||
it('rejects a discard larger than the outstanding count', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('i3')
|
||||
const at = scopeTarget(agent, agent)
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
|
||||
expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(false), info(true)]) })
|
||||
.toThrow(/dropped 2 items but only 1 were outstanding/)
|
||||
})
|
||||
|
||||
it('accepts an empty discard against a fresh agent', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('i4')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,10 +12,12 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
|
||||
'agent/created': args => args[0],
|
||||
'agent/disposed': args => args[0],
|
||||
'agent/error': args => args[0],
|
||||
'agent/inbox/dequeue': args => args[0],
|
||||
'agent/inbox/discard': args => args[0],
|
||||
'agent/inbox/enqueue': args => args[0],
|
||||
'agent/post-step': args => args[0],
|
||||
'agent/pre-step': args => args[0],
|
||||
'agent/prompt-submit': args => args[0],
|
||||
'agent/queued': args => args[0],
|
||||
'agent/request': args => args[0],
|
||||
'agent/request-error': args => args[0],
|
||||
'agent/session-prefix': args => args[0],
|
||||
|
||||
@@ -42,7 +42,9 @@ describe('scoped-dispatch invariants', () => {
|
||||
'agent/created': [agent],
|
||||
'agent/disposed': [agent],
|
||||
'agent/status': [agent, 'idle'],
|
||||
'agent/queued': [agent, [], { source: { kind: 'user' }, contexts: [], steering: false }],
|
||||
'agent/inbox/enqueue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }],
|
||||
'agent/inbox/dequeue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }],
|
||||
'agent/inbox/discard': [agent, []],
|
||||
'agent/cancel-requested': [agent, { kind: 'user' }],
|
||||
'agent/session-start': [agent, 'startup'],
|
||||
'agent/pre-step': [agent, 1, 1, signal],
|
||||
|
||||
@@ -64,7 +64,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`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
|
||||
A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt (`user` source), a synthetic injection (`plugin`/`goal` source), or an admitted goal round — `source` is the only channel that tells them apart. It may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
@@ -97,7 +97,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. 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 projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. 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
|
||||
|
||||
|
||||
@@ -532,10 +532,10 @@ export class Session {
|
||||
// trace/replay data.
|
||||
|
||||
switch (event.type) {
|
||||
// Injected context, ordinary prompts, and mid-turn steering project
|
||||
// Ordinary prompts, injected context, and mid-turn steering project
|
||||
// identically in user role: the event's model-facing content stays
|
||||
// verbatim. A prompt envelope is model-hidden display metadata; its
|
||||
// prefix bytes are already present in content. context's `source`/`meta`
|
||||
// prefix bytes are already present in content. The message's `source`/`meta`
|
||||
// and steering's `turn` are also log-only. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
|
||||
// caller-owned — a producer bakes it into `content`, as workspace-context
|
||||
@@ -544,7 +544,6 @@ export class Session {
|
||||
// verbatim pass-through. See the deferred design note in
|
||||
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
|
||||
case 'user/message':
|
||||
case 'context/message':
|
||||
case 'steering/message': {
|
||||
return { role: 'user', content: event.data.content }
|
||||
}
|
||||
|
||||
@@ -15,14 +15,13 @@ const SURFACE_EVENT_TYPES = new Set<string>([
|
||||
'user/message',
|
||||
'assistant/message',
|
||||
'tool/result',
|
||||
'context/message',
|
||||
'steering/message',
|
||||
])
|
||||
|
||||
/**
|
||||
* Whether an event type can join the model-visible surface.
|
||||
* @param type - event type to test.
|
||||
* @returns true for one of the five message-producing event types.
|
||||
* @returns true for one of the four message-producing event types.
|
||||
*/
|
||||
export function isSurfaceEligibleType(type: string): boolean {
|
||||
return SURFACE_EVENT_TYPES.has(type)
|
||||
|
||||
@@ -84,11 +84,12 @@ export interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
/**
|
||||
* An out-of-band context injection (`agent.inject()`) made while the agent
|
||||
* was idle. The loop wraps the injected `context/message` in a one-shot turn
|
||||
* (`turn/start` → `context/message` → `turn/end`) so every event in the log
|
||||
* stays turn-enclosed — the durability/replay boundary is the turn, and a
|
||||
* bare event between turns would otherwise be indistinguishable from a crash
|
||||
* tail on reload.
|
||||
* was idle. The loop wraps the injected `user/message` (a non-`user` source,
|
||||
* plugin by default) in a one-shot turn (`turn/start` → `user/message` →
|
||||
* `turn/end`) so every event in the log stays turn-enclosed — the
|
||||
* durability/replay boundary is the turn, and a bare event between turns would
|
||||
* otherwise be indistinguishable from a crash tail on reload. The trigger's
|
||||
* `source` mirrors that message's producer.
|
||||
*/
|
||||
injection: { kind: 'injection'; source: MessageSource }
|
||||
}
|
||||
@@ -201,7 +202,13 @@ export interface PromptMessageEnvelope {
|
||||
prefixContexts: PromptPrefixContext[]
|
||||
}
|
||||
|
||||
/** Shared payload for ordinary and steering prompt messages. */
|
||||
/**
|
||||
* Shared payload for user, injected-context, and steering prompt messages. A
|
||||
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn
|
||||
* steering all project into the model transcript as verbatim user-role content;
|
||||
* they are told apart by `source` (a non-`user` kind marks injected context),
|
||||
* not by event type. `meta` carries durable model-hidden producer state.
|
||||
*/
|
||||
export interface PromptMessageData {
|
||||
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
|
||||
content: ContentBlock[]
|
||||
@@ -209,6 +216,15 @@ export interface PromptMessageData {
|
||||
source: MessageSource
|
||||
/** Present only when prompt-prefix contexts were baked into `content`. */
|
||||
envelope?: PromptMessageEnvelope
|
||||
/**
|
||||
* Opaque durable JSON state retained on the event but hidden from the model
|
||||
* projection. It is the intended channel for a future framing directive (a
|
||||
* producer declares the frame, a dedicated renderer applies it — see the
|
||||
* deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,29 +252,21 @@ export interface SessionEventMap {
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (the queued message claimed for this turn). */
|
||||
/**
|
||||
* A user-role message on the model-visible surface: a direct human prompt
|
||||
* (the queued message claimed for this turn), a synthetic `agent.inject()`
|
||||
* context (file-change notices, subdir AGENTS.md, skill content, cron
|
||||
* notifications, …), or an admitted goal continuation round. All three
|
||||
* project their `content` verbatim; `source` (with a non-`user` kind marking
|
||||
* injected context) is the only channel that tells them apart. An idle
|
||||
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
|
||||
*/
|
||||
'user/message': PromptMessageData
|
||||
/**
|
||||
* Durable record of a prompt veto and its reason. It is log-only: the blocked
|
||||
* prompt never enters the model-visible surface, and its turn runs zero steps.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as a synthetic user-role message carrying `content` verbatim — NOT a
|
||||
* user prompt. `meta` is durable JSON state omitted from the model
|
||||
* projection; it is also the intended channel for any future framing
|
||||
* directive (a producer declares the frame, a dedicated renderer applies it —
|
||||
* see the deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
'context/message': {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
|
||||
/**
|
||||
@@ -321,7 +329,6 @@ export type SurfaceEventType =
|
||||
| 'user/message'
|
||||
| 'assistant/message'
|
||||
| 'tool/result'
|
||||
| 'context/message'
|
||||
| 'steering/message'
|
||||
|
||||
/**
|
||||
@@ -339,7 +346,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/context
|
||||
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
|
||||
* messages.
|
||||
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
|
||||
* (inclusive) through `end` (inclusive) with this node. Both must exist as
|
||||
@@ -374,7 +381,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`, `context/message`, `steering/message`).
|
||||
* `assistant/message`, `tool/result`, `steering/message`).
|
||||
* Non-surface events (boundary markers, chunks, usage, errors) never carry
|
||||
* surface metadata — the compiler enforces this at `Session.append()`
|
||||
* call sites.
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('derived-message cache', () => {
|
||||
expect(beforeReplace).toHaveLength(2)
|
||||
|
||||
const nodes = session.surface.nodes
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('Session', () => {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'before' }],
|
||||
source: { kind: 'plugin', plugin: 'before' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -82,7 +82,7 @@ describe('Session', () => {
|
||||
turn: 3,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'after' }],
|
||||
source: { kind: 'plugin', plugin: 'after' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -117,9 +117,9 @@ describe('Session', () => {
|
||||
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
|
||||
})
|
||||
|
||||
it('renders context and steering messages as plain user content', () => {
|
||||
it('renders injected-context and steering messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
source: { kind: 'plugin', plugin: 'watcher' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -172,7 +172,7 @@ describe('Session', () => {
|
||||
version: 1,
|
||||
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }],
|
||||
}
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
meta,
|
||||
@@ -183,7 +183,7 @@ describe('Session', () => {
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
}])
|
||||
const event = session.events[0]
|
||||
expect(event?.type === 'context/message' && event.data.meta).toEqual(meta)
|
||||
expect(event?.type === 'user/message' && event.data.meta).toEqual(meta)
|
||||
})
|
||||
|
||||
it('replays identically from a seeded event log', () => {
|
||||
|
||||
@@ -444,9 +444,9 @@ describe('deriveMessages with surface', () => {
|
||||
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
|
||||
})
|
||||
|
||||
it('context/message and steering/message appear on surface', () => {
|
||||
it('injected-context and steering/message appear on surface', () => {
|
||||
const s = new Session(SessionId('ctx'))
|
||||
s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const messages = s.deriveMessages()
|
||||
expect(messages).toHaveLength(2)
|
||||
@@ -524,7 +524,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('context/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('steering/message')).toBe(true)
|
||||
expect(isSurfaceEligibleType('turn/start')).toBe(false)
|
||||
expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
|
||||
@@ -568,7 +567,7 @@ describe('SurfaceManager.replaceGeneration', () => {
|
||||
expect(s.surface.replaceGeneration).toBe(0)
|
||||
|
||||
const nodes = s.surface.nodes
|
||||
s.append('context/message', {
|
||||
s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
|
||||
expect(s.surface.replaceGeneration).toBe(1)
|
||||
|
||||
@@ -365,7 +365,7 @@ describe('runOneShot and executeCli', () => {
|
||||
const { ctx, agent } = await harness([textResponse('streamed')])
|
||||
const other = ctx.sessions.create(SessionId('unrelated'))
|
||||
let injected = false
|
||||
ctx.on('agent/queued', (subject) => {
|
||||
ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject !== agent || injected) return
|
||||
injected = true
|
||||
agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
@@ -379,7 +379,7 @@ describe('runOneShot and executeCli', () => {
|
||||
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } })
|
||||
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } })
|
||||
expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true)
|
||||
expect(events.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
|
||||
})
|
||||
|
||||
it('emits partial data and a diagnostic for non-completed turns', async () => {
|
||||
@@ -477,7 +477,7 @@ describe('runOneShot and executeCli', () => {
|
||||
|
||||
const queued = await harness([textResponse('unused')])
|
||||
const queuedAbort = new AbortController()
|
||||
queued.ctx.on('agent/queued', (agent) => {
|
||||
queued.ctx.on('agent/inbox/enqueue', (agent) => {
|
||||
if (agent === queued.agent) queuedAbort.abort('cancel queued')
|
||||
})
|
||||
await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
@@ -26,11 +26,11 @@ function nextTurn(session: Session): number {
|
||||
}
|
||||
|
||||
/** Append one idle injection using the public Agent contract's balanced shape. */
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
|
||||
const source: MessageSource = options?.source ?? { kind: 'user' }
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void {
|
||||
const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
@@ -49,6 +49,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject(content, options) { appendInjection(session, content, options) },
|
||||
cancel() { status = 'idle' },
|
||||
@@ -125,7 +126,7 @@ describe('/goal human command', () => {
|
||||
expect(created.text).toContain('Rounds: 0/256')
|
||||
expect(created.text).toContain('Activation: armed')
|
||||
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
|
||||
expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
|
||||
expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
|
||||
|
||||
const count = test.session.events.length
|
||||
await expect(run(test, ' replacement')).resolves.toEqual({
|
||||
|
||||
@@ -306,10 +306,10 @@ export function apply(ctx: Context): void {
|
||||
requestDrive(state)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/queued', (agent, content, info) => {
|
||||
ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameQueued(content, info.source, attempt)) return
|
||||
if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
})
|
||||
|
||||
@@ -207,7 +207,9 @@ describe('same-session goal driving', () => {
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
const rounds: number[] = []
|
||||
for (const event of test.agent.session.events) {
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal') {
|
||||
// Round zero is a durable goal state change; positive rounds are the
|
||||
// admitted continuation prompts this test counts.
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) {
|
||||
rounds.push(event.data.source.round)
|
||||
}
|
||||
}
|
||||
@@ -287,7 +289,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
|
||||
const test = await harness([])
|
||||
const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal') {
|
||||
cancel()
|
||||
agent.cancel({ kind: 'user' })
|
||||
@@ -299,8 +301,10 @@ describe('same-session goal driving', () => {
|
||||
|
||||
expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
// No admitted continuation round (positive round); goal state changes
|
||||
// (round zero) are expected in the log.
|
||||
expect(test.agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')).toBe(false)
|
||||
&& event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('pauses an admitted round when cancellation aborts an active step', async () => {
|
||||
@@ -334,7 +338,7 @@ describe('same-session goal driving', () => {
|
||||
const warnings: string[] = []
|
||||
test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn
|
||||
let inserted = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
const lastStart = agent.session.events.findLast(event => event.type === 'turn/start')
|
||||
@@ -357,7 +361,7 @@ describe('same-session goal driving', () => {
|
||||
it('makes a reserved round stale when a listener queues human work behind it', async () => {
|
||||
const test = await harness([textResponse('human batch'), textResponse('later goal')])
|
||||
let inserted = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
agent.send([{ type: 'text', text: 'human joined the pending batch' }])
|
||||
@@ -375,7 +379,7 @@ describe('same-session goal driving', () => {
|
||||
it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => {
|
||||
const test = await harness([textResponse('new revision')])
|
||||
let edited = false
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || edited) return
|
||||
edited = true
|
||||
const current = test.ctx.goals.get(agent)
|
||||
@@ -391,7 +395,7 @@ describe('same-session goal driving', () => {
|
||||
expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined)
|
||||
.toBe('stale goal-round reservation')
|
||||
const admitted = test.agent.session.events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')
|
||||
&& event.data.source.kind === 'goal' && event.data.source.round > 0)
|
||||
expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal'
|
||||
? admitted.data.source.revision
|
||||
: undefined).toBe(2)
|
||||
@@ -477,8 +481,14 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('blocks the goal when a custom agent rejects the otherwise valid send', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
|
||||
throw new Error('queue rejected')
|
||||
// inject shares send, so reject only the round send (a goal-sourced
|
||||
// next-turn item), not the goal state-change injection that precedes it.
|
||||
const realSend = test.agent.send.bind(test.agent)
|
||||
vi.spyOn(test.agent, 'send').mockImplementation((content, options) => {
|
||||
if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') {
|
||||
throw new Error('queue rejected')
|
||||
}
|
||||
realSend(content, options)
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
|
||||
|
||||
@@ -494,9 +504,13 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('preserves a custom agent side effect when send disarms before throwing', async () => {
|
||||
const test = await harness([])
|
||||
vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
throw new Error('queue rejected after disarm')
|
||||
const realSend = test.agent.send.bind(test.agent)
|
||||
vi.spyOn(test.agent, 'send').mockImplementation((content, options) => {
|
||||
if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') {
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
throw new Error('queue rejected after disarm')
|
||||
}
|
||||
realSend(content, options)
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' })
|
||||
|
||||
@@ -554,7 +568,7 @@ describe('same-session goal driving', () => {
|
||||
it('fails a pre-admission read closed even when the first disarm attempt throws', async () => {
|
||||
const test = await harness([textResponse('retry after containment')])
|
||||
let armed = true
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return
|
||||
armed = false
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
@@ -635,7 +649,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
|
||||
const test = await harness([])
|
||||
const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal') return
|
||||
cancel()
|
||||
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
|
||||
@@ -689,7 +703,7 @@ describe('same-session goal driving', () => {
|
||||
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
|
||||
const test = await harness([])
|
||||
let unloading: Promise<void> | undefined
|
||||
test.ctx.on('agent/queued', (agent, _content, info) => {
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) {
|
||||
unloading = Promise.resolve(test.driver.dispose())
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function view(roundsStarted: number): GoalView {
|
||||
|
||||
function appendChange(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
@@ -126,7 +126,7 @@ describe('goal-session prompt invariants', () => {
|
||||
it('attributes an invalid durable prefix during late loading', async () => {
|
||||
const { ctx, session } = await mount(true)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'counterfeit goal state' }],
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
GoalSnapshotChangeMeta,
|
||||
} from './types.ts'
|
||||
|
||||
type ContextMessageEvent = Extract<SessionEvent, { type: 'context/message' }>
|
||||
type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
|
||||
|
||||
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
|
||||
'create',
|
||||
@@ -310,17 +310,18 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and verify one model-visible goal context event without folding it.
|
||||
* @param event - context event whose metadata and rendered content must agree.
|
||||
* @returns validated change or `undefined` for an unrelated context event.
|
||||
* Decode and verify one model-visible goal state change without folding it. A
|
||||
* goal state change is a round-zero goal-sourced `user/message` carrying
|
||||
* `goal/change` metadata; any other user message returns `undefined`. Goal
|
||||
* metadata on a non-goal source, or a mismatched attribution or rendered body,
|
||||
* fails replay loudly.
|
||||
* @param event - user message whose metadata and rendered content must agree.
|
||||
* @returns validated change, or `undefined` when the message is not a goal state change.
|
||||
*/
|
||||
export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined {
|
||||
export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined {
|
||||
const change = decodeGoalChange(event.data.meta)
|
||||
if (change === undefined) return undefined
|
||||
const source = goalSource(event.data.source)
|
||||
if (change === undefined) {
|
||||
if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
|
||||
return undefined
|
||||
}
|
||||
const ref = goalChangeRef(change)
|
||||
if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) {
|
||||
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
|
||||
@@ -338,23 +339,27 @@ export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | un
|
||||
* @returns decoded change for pending-overlay reconciliation.
|
||||
*/
|
||||
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
|
||||
if (event.type === 'context/message') {
|
||||
const change = decodeGoalEvent(event)
|
||||
if (change === undefined) return undefined
|
||||
applyGoalChange(state, change)
|
||||
return change
|
||||
}
|
||||
if (event.type === 'user/message') {
|
||||
const source = goalSource(event.data.source)
|
||||
if (source !== undefined) {
|
||||
const current = state.goal
|
||||
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|
||||
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
|
||||
|| source.round > current.maxGoalRounds) {
|
||||
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
|
||||
}
|
||||
state.roundsStarted = source.round
|
||||
// A goal state change carries `goal/change` metadata (round zero).
|
||||
const change = decodeGoalEvent(event)
|
||||
if (change !== undefined) {
|
||||
applyGoalChange(state, change)
|
||||
return change
|
||||
}
|
||||
const source = goalSource(event.data.source)
|
||||
if (source === undefined) return undefined
|
||||
// A goal-sourced message without change metadata must be a positive-round
|
||||
// admitted continuation prompt; round zero owes durable change metadata.
|
||||
if (source.round === 0) {
|
||||
throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
|
||||
}
|
||||
const current = state.goal
|
||||
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|
||||
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
|
||||
|| source.round > current.maxGoalRounds) {
|
||||
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
|
||||
}
|
||||
state.roundsStarted = source.round
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -370,7 +370,9 @@ export class GoalService extends Service {
|
||||
/** Incrementally observe durable events without losing deferred mutations. */
|
||||
private sync(session: Session, cache: GoalCache): void {
|
||||
for (const event of session.events.slice(cache.observedSeq)) {
|
||||
if (event.type === 'context/message') {
|
||||
// A goal state change is a round-zero goal-sourced user message; a
|
||||
// positive round is a continuation prompt handled by applyGoalEvent.
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) {
|
||||
const change = decodeGoalEvent(event)
|
||||
if (change !== undefined) {
|
||||
const pending = cache.pending[0]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
|
||||
|
||||
/** Version of the goal change metadata embedded in `context/message`. */
|
||||
/** Version of the goal change metadata embedded in a round-zero `user/message`. */
|
||||
export const GOAL_CHANGE_VERSION = 1
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface GoalClearChangeMeta {
|
||||
readonly clearedAt: number
|
||||
}
|
||||
|
||||
/** Durable metadata union carried by a goal-owned `context/message`. */
|
||||
/** Durable metadata union carried by a goal-owned round-zero `user/message`. */
|
||||
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
||||
|
||||
/** Message attribution for durable goal state and continuation rounds. */
|
||||
|
||||
@@ -50,11 +50,11 @@ describe('goal domain through a real cordis.yml and headless process', () => {
|
||||
expect(result['result']).toContain('CLI tool round trip complete')
|
||||
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
|
||||
|
||||
const contexts = events.filter(event => event.type === 'context/message'
|
||||
const contexts = events.filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')
|
||||
expect(contexts).toHaveLength(1)
|
||||
const context = contexts[0]
|
||||
if (context?.type !== 'context/message') throw new Error('expected goal context event')
|
||||
if (context?.type !== 'user/message') throw new Error('expected goal context event')
|
||||
const change = decodeGoalChange(context.data.meta)
|
||||
if (change === undefined) throw new Error('expected durable goal change')
|
||||
expect(change).toMatchObject({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import GoalService, {
|
||||
@@ -15,7 +15,7 @@ import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-
|
||||
|
||||
interface DeferredInjection {
|
||||
content: ContentBlock[]
|
||||
options: InjectOptions | undefined
|
||||
options: AliasSendOptions | undefined
|
||||
}
|
||||
|
||||
interface StubAgent {
|
||||
@@ -33,8 +33,8 @@ function nextTurn(session: Session): number {
|
||||
}
|
||||
|
||||
/** Mirror the public Agent.inject idle/open-turn contract for domain tests. */
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
|
||||
const source: MessageSource = options?.source ?? { kind: 'user' }
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void {
|
||||
const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
const context = {
|
||||
content,
|
||||
source,
|
||||
@@ -43,12 +43,12 @@ function appendInjection(session: Session, content: ContentBlock[], options?: In
|
||||
const last = session.events.at(-1)
|
||||
const open = last !== undefined && last.type !== 'turn/end'
|
||||
if (open) {
|
||||
session.append('context/message', context, { surfaceOp: 'append' })
|
||||
session.append('user/message', context, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', context, { surfaceOp: 'append' })
|
||||
session.append('user/message', context, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ function stubAgentForSession(session: Session): StubAgent {
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject(content, options) {
|
||||
if (shouldDefer) deferred.push({ content, options })
|
||||
@@ -131,10 +132,10 @@ describe('GoalService creation and replay', () => {
|
||||
})
|
||||
expect(goal.id).toMatch(/^goal-/)
|
||||
expect(seen).toEqual(['create'])
|
||||
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
|
||||
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
|
||||
const context = session.events[1]
|
||||
expect(context?.type).toBe('context/message')
|
||||
if (context?.type !== 'context/message') throw new Error('expected goal context')
|
||||
expect(context?.type).toBe('user/message')
|
||||
if (context?.type !== 'user/message') throw new Error('expected goal context')
|
||||
expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
|
||||
const change = decodeGoalChange(context.data.meta)
|
||||
if (change === undefined) throw new Error('expected decoded goal change')
|
||||
@@ -266,7 +267,9 @@ describe('GoalService creation and replay', () => {
|
||||
|
||||
it('requires the exact live registry instance for reads and mutations', async () => {
|
||||
const { ctx, agent } = await harness()
|
||||
const impostor = { ...agent, session: new Session(agent.id) }
|
||||
// A same-id agent backed by a different session object — the live-instance
|
||||
// check must reject it even though the ids match.
|
||||
const impostor = stubAgentForSession(new Session(agent.id)).agent
|
||||
expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
|
||||
expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_AGENT_NOT_LIVE',
|
||||
@@ -407,8 +410,8 @@ describe('GoalService mutations', () => {
|
||||
vi.setSystemTime(80)
|
||||
ctx.goals.clear(agent, goal)
|
||||
const clear = session.events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.map(event => decodeGoalChange(event.data.meta))
|
||||
.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal')
|
||||
.map(event => event.type === 'user/message' ? decodeGoalChange(event.data.meta) : undefined)
|
||||
.at(-1)
|
||||
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
|
||||
expect(() => foldGoal(session.events)).not.toThrow()
|
||||
@@ -454,7 +457,7 @@ describe('GoalService mutations', () => {
|
||||
ctx.agents.register(stub.agent)
|
||||
let observed: ReturnType<GoalService['get']>
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent)
|
||||
if (session === stub.session && event.type === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent)
|
||||
})
|
||||
|
||||
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
|
||||
@@ -517,7 +520,7 @@ describe('GoalService mutations', () => {
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change), source, meta: change as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
@@ -594,7 +597,7 @@ describe('goal replay validation', () => {
|
||||
}
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: overrides.content ?? renderGoalChange(change),
|
||||
source,
|
||||
meta: change as never,
|
||||
@@ -791,7 +794,7 @@ describe('goal replay validation', () => {
|
||||
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'missing' }], source,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
@@ -853,7 +856,7 @@ describe('goal replay validation', () => {
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(clear), source, meta: clear as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('goal stream invariants', () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
@@ -71,7 +71,7 @@ describe('goal stream invariants', () => {
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
expect(() => {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'counterfeit' }],
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
@@ -82,7 +82,7 @@ describe('goal stream invariants', () => {
|
||||
}))
|
||||
expect(session.seq).toBe(1)
|
||||
expect(() => {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
@@ -95,7 +95,7 @@ describe('goal stream invariants', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-late-load'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
meta: change as never,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -32,10 +32,11 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
get status() { return status },
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject(content: ContentBlock[], options?: InjectOptions) {
|
||||
const source = options?.source ?? { kind: 'user' }
|
||||
session.append('context/message', {
|
||||
inject(content: ContentBlock[], options?: AliasSendOptions) {
|
||||
const source = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
@@ -225,7 +226,9 @@ describe('goal tool execution authority', () => {
|
||||
it('rejects stale agent objects and agents outside running status through the executor', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
const stale = { ...root.agent }
|
||||
// A distinct agent object over root's exact session: same id, not the live
|
||||
// registered instance, so the executor must reject it.
|
||||
const stale = stubAgent('goal-tool-stale', root.agent.session).agent
|
||||
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
|
||||
expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
|
||||
@@ -35,10 +35,10 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
|
||||
}
|
||||
|
||||
/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */
|
||||
/** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */
|
||||
function reminders(agent: Agent): { text: string; source: unknown }[] {
|
||||
return [...agent.session.events]
|
||||
.filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message')
|
||||
.filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user')
|
||||
.map(e => ({
|
||||
text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'),
|
||||
source: e.data.source,
|
||||
|
||||
@@ -125,8 +125,8 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
|
||||
// The injected context reached the model and is recorded with the plugin source.
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief')
|
||||
const ctxMsg = events(agent).find(e => e.type === 'context/message')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' })
|
||||
const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind !== 'user')
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,10 +216,10 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
||||
|
||||
const log = events(agent)
|
||||
const resultIdx = log.findIndex(e => e.type === 'tool/result')
|
||||
const ctxIdx = log.findIndex(e => e.type === 'context/message')
|
||||
const ctxIdx = log.findIndex(e => e.type === 'user/message' && e.data.source.kind !== 'user')
|
||||
expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result
|
||||
const ctxMsg = log[ctxIdx]
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true)
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => {
|
||||
@@ -262,7 +262,7 @@ describe('hooks-claude bridge — SessionStart', () => {
|
||||
// session-start fires async (detached .then → agent.inject); wait for the
|
||||
// injected context/message to actually land before sending, rather than a
|
||||
// fixed sleep that flakes under load.
|
||||
await waitFor(() => events(agent).some(e => e.type === 'context/message'
|
||||
await waitFor(() => events(agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -138,9 +138,9 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// The prompt proceeded unchanged; no context/message injected.
|
||||
// The prompt proceeded unchanged; no injected context.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => {
|
||||
@@ -441,7 +441,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
|
||||
// additionalContext also injected (the block + context arm).
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
|
||||
})
|
||||
|
||||
it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => {
|
||||
@@ -475,7 +475,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(events(handle.agent).some(e => e.type === 'context/message'
|
||||
expect(events(handle.agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
@@ -496,7 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
// the downstream block won: the model was never called, no user/message was
|
||||
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
|
||||
})
|
||||
@@ -528,12 +528,12 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
// the original prompt was replaced by the downstream rewrite
|
||||
const userMsg = events(agent).find(e => e.type === 'user/message')
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
|
||||
const contexts = events(agent).filter(event => event.type === 'context/message')
|
||||
expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
|
||||
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'hooks-claude' },
|
||||
{ kind: 'plugin', plugin: 'policy' },
|
||||
])
|
||||
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
||||
expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
||||
})
|
||||
|
||||
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
|
||||
@@ -551,7 +551,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
|
||||
@@ -573,12 +573,12 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contexts = events(agent).filter(event => event.type === 'context/message')
|
||||
expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
|
||||
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'hooks-claude' },
|
||||
{ kind: 'plugin', plugin: 'policy' },
|
||||
])
|
||||
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
||||
expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
||||
})
|
||||
|
||||
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
|
||||
@@ -599,7 +599,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
|
||||
// the bridge's context still landed (folded onto the block)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -136,12 +136,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
expect(req).toContain('from-bridge')
|
||||
expect(req).toContain('from-downstream')
|
||||
expect(req).toContain('rewritten-prompt')
|
||||
const contexts = events(agent).filter(event => event.type === 'context/message')
|
||||
expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
|
||||
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'hooks-codex' },
|
||||
{ kind: 'plugin', plugin: 'policy' },
|
||||
])
|
||||
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
||||
expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -157,7 +157,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
|
||||
@@ -177,12 +177,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
|
||||
const contexts = events(agent).filter(event => event.type === 'context/message')
|
||||
expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
|
||||
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'hooks-codex' },
|
||||
{ kind: 'plugin', plugin: 'policy' },
|
||||
])
|
||||
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
||||
expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
|
||||
})
|
||||
|
||||
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
|
||||
@@ -197,7 +197,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
|
||||
})
|
||||
|
||||
it('SessionStart additionalContext is injected for the first request', async () => {
|
||||
@@ -206,7 +206,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
await waitFor(() => events(agent).some(e => e.type === 'context/message'
|
||||
await waitFor(() => events(agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
|
||||
@@ -233,7 +233,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -345,7 +345,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
|
||||
})
|
||||
|
||||
it('a throwing SessionStart inject is contained (logged)', async () => {
|
||||
@@ -428,7 +428,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const r = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
|
||||
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
|
||||
})
|
||||
|
||||
it('commandOf reads a non-string command arg as an empty command', async () => {
|
||||
@@ -521,7 +521,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
await waitFor(() => existsSync(marker)) // the exit-2 hook has finished
|
||||
expect(events(agent).some(e => e.type === 'context/message'
|
||||
expect(events(agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -545,7 +545,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
await waitFor(() => events(agent).some(e => e.type === 'context/message'
|
||||
await waitFor(() => events(agent).some(e => e.type === 'user/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
|
||||
@@ -332,7 +332,7 @@ export class PlanModeService extends Service {
|
||||
const text = target
|
||||
? 'The user switched this session to plan mode.'
|
||||
: 'The user switched this session back to the default mode.'
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'plan-mode' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('plan mode through the agent loop', () => {
|
||||
const result = findEvent(log, 'tool/result')
|
||||
expect(result.data.isError).toBe(false)
|
||||
expect(foldPlanMode(log)).toBe(true)
|
||||
expect(log.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
|
||||
})
|
||||
|
||||
it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => {
|
||||
@@ -115,9 +115,9 @@ describe('plan mode through the agent loop', () => {
|
||||
|
||||
const log = agent.session.events
|
||||
expect(foldPlanMode(log)).toBe(true)
|
||||
const notices = log.filter(event => event.type === 'context/message')
|
||||
const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
expect(notices).toHaveLength(1)
|
||||
expect(findEvent(log, 'context/message').data.content).toEqual([
|
||||
expect(notices[0]?.type === 'user/message' && notices[0].data.content).toEqual([
|
||||
{ type: 'text', text: 'The user switched this session to plan mode.' },
|
||||
])
|
||||
// The changed request is logged as a complete snapshot.
|
||||
@@ -163,7 +163,8 @@ describe('plan mode through the agent loop', () => {
|
||||
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
|
||||
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
|
||||
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
|
||||
expect(findEvent(log, 'context/message').data.content).toEqual([
|
||||
const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
expect(notice?.type === 'user/message' && notice.data.content).toEqual([
|
||||
{ type: 'text', text: 'The user switched this session to plan mode.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -95,7 +95,7 @@ function header(session: Session): void {
|
||||
|
||||
function noticeTexts(session: Session): string[] {
|
||||
return session.events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
.map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ function agent(ctx: Context): Agent {
|
||||
const id = SessionId('agent')
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
status: 'idle',
|
||||
ctx: scopeFiber.ctx,
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
cancel() {},
|
||||
|
||||
@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
|
||||
const id = SessionId('pty-loader-agent')
|
||||
const value: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
|
||||
@@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const agent: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
|
||||
@@ -107,7 +107,7 @@ function appendTraceEvents(session: Session): void {
|
||||
{ surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] },
|
||||
)
|
||||
session.append(
|
||||
'context/message',
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
@@ -309,14 +309,14 @@ describe('session event tracing', () => {
|
||||
const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } })
|
||||
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
live.append(
|
||||
'context/message',
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
TracePersistence.listFailure = new Error('list unavailable')
|
||||
TracePersistence.loadFailure = new Error('load unavailable')
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 }))
|
||||
.resolves.toMatchObject({ target: { type: 'context/message' } })
|
||||
.resolves.toMatchObject({ target: { type: 'user/message' } })
|
||||
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
|
||||
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('startInProcessRun', () => {
|
||||
turn,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
@@ -187,10 +187,10 @@ describe('dsh-subagent-spawn', () => {
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
|
||||
it('a cancel from agent/inbox/enqueue maps a no-turn child log to aborted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
ctx.on('agent/queued', () => { controller.abort('queued-window') })
|
||||
ctx.on('agent/inbox/enqueue', () => { controller.abort('queued-window') })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
|
||||
const result = await run.result
|
||||
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
|
||||
|
||||
@@ -34,7 +34,7 @@ The current executable companions protect these relationships:
|
||||
|
||||
| Companion | Checks |
|
||||
|---|---|
|
||||
| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. |
|
||||
| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, inbox FIFO conservation, scoped subjects, and model-request reconstruction. |
|
||||
| `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. |
|
||||
| `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. |
|
||||
| `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. |
|
||||
|
||||
@@ -24,6 +24,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
status: 'idle' as const,
|
||||
ctx: scopeFiber.ctx,
|
||||
send() {},
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
cancel() {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -241,7 +241,7 @@ describe('HarnessSdkServer', () => {
|
||||
turn: 2,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
@@ -1487,12 +1487,12 @@ export function createTuiChat(
|
||||
let toolsExpanded = false
|
||||
let streaming: StreamingAssistantComponent | undefined
|
||||
let runningStatus: RunningStatus | undefined
|
||||
// Steering messages queued during the running turn (`agent/queued`) that the
|
||||
// loop has not yet drained, shown as a badge on the status line. Each entry is
|
||||
// the queued message's serialized source: a drain (`steering/message`) removes
|
||||
// one MATCHING entry, so loop-authored steering — continuation reasons enter
|
||||
// the inbox without an `agent/queued` event — cannot consume a pending user
|
||||
// message's slot. Cleared on leaving `running`, which also absorbs a
|
||||
// Steering messages queued during the running turn (`agent/inbox/enqueue`)
|
||||
// that the loop has not yet drained, shown as a badge on the status line. Each
|
||||
// entry is the queued message's serialized source: a drain (`steering/message`)
|
||||
// removes one MATCHING entry, so loop-authored steering — continuation reasons
|
||||
// enter the inbox without an `agent/inbox/enqueue` event — cannot consume a
|
||||
// pending user message's slot. Cleared on leaving `running`, which also absorbs a
|
||||
// cancellation that discards the queue without logging drains; the status
|
||||
// line exists only while running, so idle carries no badge to keep current.
|
||||
const pendingSteering: string[] = []
|
||||
@@ -1795,6 +1795,29 @@ export function createTuiChat(
|
||||
const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
// Injected context (plugin/goal source) renders as a dim context card,
|
||||
// not a human bubble; only a direct human prompt is a user message. The
|
||||
// boolean avoids narrowing `source`, so the label keeps its full union.
|
||||
const source = event.data.source
|
||||
if (source.kind !== 'user') {
|
||||
const references = sessionReferenceCard(event.data.meta)
|
||||
if (references !== undefined) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
|
||||
break
|
||||
}
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
if (text) {
|
||||
// The tui type view lacks plugin-augmented source kinds (e.g. goal),
|
||||
// so read the display label without narrowing on `kind`.
|
||||
const labelled = source as { kind: string; plugin?: string }
|
||||
const label = labelled.plugin ?? labelled.kind
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0))
|
||||
chat.addChild(new Text(palette.muted(text), 1, 0))
|
||||
}
|
||||
break
|
||||
}
|
||||
const text = displayText(contentText(displayPromptContent(event.data)).trim())
|
||||
if (text) {
|
||||
chat.addChild(new Spacer(1))
|
||||
@@ -1819,22 +1842,6 @@ export function createTuiChat(
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const references = sessionReferenceCard(event.data.meta)
|
||||
if (references !== undefined) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
|
||||
break
|
||||
}
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
if (text) {
|
||||
const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0))
|
||||
chat.addChild(new Text(palette.muted(text), 1, 0))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'prompt/blocked':
|
||||
appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
|
||||
break
|
||||
@@ -1919,7 +1926,6 @@ export function createTuiChat(
|
||||
const isSurface = event.type === 'user/message'
|
||||
|| event.type === 'assistant/message'
|
||||
|| event.type === 'tool/result'
|
||||
|| event.type === 'context/message'
|
||||
|| event.type === 'steering/message'
|
||||
if (isSurface && !active.has(event.seq)) continue
|
||||
if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
|
||||
@@ -2554,7 +2560,7 @@ export function createTuiChat(
|
||||
// A queued steering message reached the model as it drained; drop its
|
||||
// entry from the badge. Matching by source keeps loop-authored steering
|
||||
// (e.g. continuation reasons), which logs here without a matching
|
||||
// `agent/queued` increment, from consuming a pending user slot.
|
||||
// `agent/inbox/enqueue` increment, from consuming a pending user slot.
|
||||
const drained = pendingSteering.indexOf(JSON.stringify(event.data.source))
|
||||
if (drained >= 0) {
|
||||
pendingSteering.splice(drained, 1)
|
||||
@@ -2568,7 +2574,7 @@ export function createTuiChat(
|
||||
renderEvent(event, { addHistory: false, renderChunks: true })
|
||||
requestRender()
|
||||
})
|
||||
const disposeQueued = ctx.on('agent/queued', (subject, _content, info) => {
|
||||
const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => {
|
||||
if (subject !== agent || !info.steering) return
|
||||
pendingSteering.push(JSON.stringify(info.source))
|
||||
refreshStatus()
|
||||
|
||||
@@ -153,6 +153,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
sent.push(content)
|
||||
sentOptions.push(options)
|
||||
},
|
||||
followup(content, options) {
|
||||
sent.push(content)
|
||||
sentOptions.push(options)
|
||||
},
|
||||
steer(content, options) {
|
||||
steered.push(content)
|
||||
steeredOptions.push(options)
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('TUI session-reference snapshot', () => {
|
||||
type: 'text',
|
||||
text: '\n\n## My request:\n',
|
||||
})
|
||||
expect(target.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(target.session.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
|
||||
|
||||
const snapshot = await terminal.snapshot({ includeScrollback: true })
|
||||
if (REFRESHING) {
|
||||
|
||||
@@ -451,7 +451,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
session.append('todo/write', {
|
||||
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
|
||||
})
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
|
||||
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -567,7 +567,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => {
|
||||
harness.session.append('context/message', {
|
||||
harness.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
|
||||
@@ -374,8 +374,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' })
|
||||
result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { 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 })
|
||||
@@ -552,16 +552,16 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).not.toContain('queued')
|
||||
|
||||
const queueSteering = (text: string): void => {
|
||||
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, contexts: [], steering: true })
|
||||
result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
|
||||
}
|
||||
const drainSteering = (text: string): void => {
|
||||
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
// A steering queue for a different agent never touches this status line.
|
||||
const other = { ...result.agent, id: SessionId('other') } as Agent
|
||||
const other = { ...result.agent, id: SessionId('other') } as unknown as Agent
|
||||
result.terminal.output = ''
|
||||
result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, contexts: [], steering: true })
|
||||
result.ctx.emit('agent/inbox/enqueue', other, { content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('queued')
|
||||
|
||||
@@ -574,7 +574,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
|
||||
// A non-steering queue (an idle-style send) leaves the badge untouched.
|
||||
result.terminal.output = ''
|
||||
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, contexts: [], steering: false })
|
||||
result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true })
|
||||
drainSteering('first')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('1 queued')
|
||||
@@ -594,7 +594,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('1 queued')
|
||||
|
||||
// A loop-authored steering event (plugin source, no matching agent/queued)
|
||||
// A loop-authored steering event (plugin source, no matching agent/inbox/enqueue)
|
||||
// cannot consume a pending user slot, even when it drains first.
|
||||
result.terminal.output = ''
|
||||
result.session.append('steering/message', {
|
||||
@@ -627,7 +627,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const idle = await setup()
|
||||
// A steering queue arriving while idle has no status line to badge, so the
|
||||
// refresh is a no-op beyond requesting a render.
|
||||
idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, contexts: [], steering: true })
|
||||
idle.ctx.emit('agent/inbox/enqueue', idle.agent, { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
|
||||
idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' })
|
||||
await tick()
|
||||
expect(idle.terminal.output).not.toContain('Executing tools')
|
||||
@@ -1221,7 +1221,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
|
||||
expect(result.terminal.output).not.toContain('hidden non-reference prefix')
|
||||
|
||||
result.session.append('context/message', {
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'secret full snapshot payload' }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
@@ -1240,13 +1240,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
|
||||
]
|
||||
for (const [meta, text] of invalidCards) {
|
||||
result.session.append('context/message', {
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta,
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
result.session.append('context/message', {
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'same-label snapshot' }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] },
|
||||
@@ -1658,7 +1658,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
|
||||
const events = await setup()
|
||||
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
|
||||
const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
|
||||
const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } as unknown as Agent
|
||||
unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
|
||||
agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
|
||||
@@ -2021,7 +2021,7 @@ describe('tool cards and surface replay', () => {
|
||||
turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const start = result.session.surface.nodes[0] as number
|
||||
result.session.append('context/message', {
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'summary replacement' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
@@ -2207,7 +2207,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
|
||||
@@ -2231,7 +2231,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||||
@@ -2265,14 +2265,14 @@ describe('terminal mounting', () => {
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('late-session'))
|
||||
const agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
@@ -2302,7 +2302,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
@@ -2344,7 +2344,7 @@ describe('terminal mounting', () => {
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
|
||||
Reference in New Issue
Block a user