refactor(agent-loop): simplify message machine
This commit is contained in:
@@ -396,7 +396,7 @@ describe('CommandService', () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('mid'))
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
await ctx.commands.execute(agent, '/mid', new AbortController().signal)
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'command/run', 'command/done',
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolve } from 'node:path'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
|
||||
interface SessionRecord {
|
||||
handle: AgentHandle
|
||||
lastTurnEnd: TurnEndReason | undefined
|
||||
activePrompt: boolean
|
||||
}
|
||||
|
||||
@@ -72,12 +71,6 @@ export class HarnessSdkServer {
|
||||
) {
|
||||
const serverOptions = this.options
|
||||
this.disposers.push(ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'turn/end') {
|
||||
const rec = this.sessions.get(String(session.id))
|
||||
if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) {
|
||||
rec.lastTurnEnd = event.data.reason
|
||||
}
|
||||
}
|
||||
const payload: SessionEventNotification = { sessionId: String(session.id), event }
|
||||
this.transport.notify('session.event', payload)
|
||||
}))
|
||||
@@ -146,13 +139,15 @@ export class HarnessSdkServer {
|
||||
}
|
||||
rec.activePrompt = true
|
||||
try {
|
||||
rec.lastTurnEnd = undefined
|
||||
rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }))
|
||||
const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })
|
||||
rec.handle.agent.followup(message)
|
||||
await rec.handle.agent.whenIdle()
|
||||
const lastEnd = rec.handle.agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
const reason = lastEnd?.data.reason
|
||||
const payload: SessionFinishedNotification = {
|
||||
sessionId: params.sessionId,
|
||||
status: this.finishedStatus(rec.lastTurnEnd),
|
||||
reason: rec.lastTurnEnd,
|
||||
status: this.finishedStatus(reason),
|
||||
reason,
|
||||
}
|
||||
this.transport.notify('session.finished', payload)
|
||||
return { accepted: true }
|
||||
@@ -244,7 +239,7 @@ export class HarnessSdkServer {
|
||||
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
|
||||
},
|
||||
})
|
||||
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
|
||||
const rec: SessionRecord = { handle, activePrompt: false }
|
||||
this.sessions.set(sessionId, rec)
|
||||
return rec
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ describe('HarnessSdkServer', () => {
|
||||
await server.shutdown()
|
||||
})
|
||||
|
||||
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
|
||||
it('reports the final whole-agent outcome after later activity settles', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -272,13 +272,11 @@ describe('HarnessSdkServer', () => {
|
||||
followup(input: UserMessage) {
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: input.source },
|
||||
})
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
@@ -306,8 +304,8 @@ describe('HarnessSdkServer', () => {
|
||||
method: 'session.finished',
|
||||
params: {
|
||||
sessionId: 'message-outcome',
|
||||
status: 'error',
|
||||
reason: { kind: 'max-tokens' },
|
||||
status: 'ok',
|
||||
reason: { kind: 'completed' },
|
||||
},
|
||||
})
|
||||
await server.shutdown()
|
||||
|
||||
@@ -460,7 +460,6 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
|
||||
case 'completed': return `turn ${event.data.turn}: completed`
|
||||
case 'aborted': return `turn ${event.data.turn}: cancelled`
|
||||
case 'error': return `turn ${event.data.turn}: error`
|
||||
case 'disposed': return `turn ${event.data.turn}: disposed`
|
||||
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
|
||||
case 'interrupted': return `turn ${event.data.turn}: interrupted`
|
||||
default: return `turn ${event.data.turn}: unknown result`
|
||||
|
||||
@@ -5,20 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-tui
|
||||
*/
|
||||
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
Container,
|
||||
Key,
|
||||
Spacer,
|
||||
Text,
|
||||
TUI,
|
||||
ProcessTerminal,
|
||||
matchesKey,
|
||||
visibleWidth,
|
||||
type EditorTheme,
|
||||
type SlashCommand,
|
||||
type TerminalColorScheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import { CombinedAutocompleteProvider, Container, Key, Spacer, Text, TUI, ProcessTerminal, matchesKey, visibleWidth, type EditorTheme, type SlashCommand, type TerminalColorScheme } from '@earendil-works/pi-tui'
|
||||
import { Service, type Context, type Fiber } from 'cordis'
|
||||
import {
|
||||
assembleContextFor,
|
||||
@@ -343,6 +330,7 @@ export function createTuiChat(
|
||||
const pendingSteering = new Set<MessageId>()
|
||||
let disposed = false
|
||||
let shuttingDown: Promise<void> | undefined
|
||||
let exitAfterIdle = false
|
||||
// Optional: skills mount conditionally, so read the global service store
|
||||
// rather than declaring an injection that would make the TUI require them.
|
||||
const skills = ctx.get('skills')
|
||||
@@ -786,9 +774,15 @@ export function createTuiChat(
|
||||
case 'completed':
|
||||
break
|
||||
case 'error': {
|
||||
const key = `${event.data.turn}:${reason.step}`
|
||||
const message = 'failure' in reason ? reason.failure.message : reason.message
|
||||
if (!liveErrors.delete(key)) appendNotice(message, 'error')
|
||||
const prefix = `${event.data.turn}:`
|
||||
let alreadyReported = false
|
||||
for (const key of liveErrors) {
|
||||
if (!key.startsWith(prefix)) continue
|
||||
liveErrors.delete(key)
|
||||
alreadyReported = true
|
||||
}
|
||||
const message = reason.error instanceof Error ? reason.error.message : String(reason.error)
|
||||
if (!alreadyReported) appendNotice(message, 'error')
|
||||
break
|
||||
}
|
||||
case 'aborted':
|
||||
@@ -797,9 +791,6 @@ export function createTuiChat(
|
||||
case 'max-tokens':
|
||||
appendNotice('The model reached its output-token limit.', 'warning')
|
||||
break
|
||||
case 'disposed':
|
||||
appendNotice('Turn stopped: the agent was disposed.', 'warning')
|
||||
break
|
||||
case 'interrupted':
|
||||
appendNotice('The previous process ended during this turn.', 'warning')
|
||||
break
|
||||
@@ -892,9 +883,9 @@ export function createTuiChat(
|
||||
|
||||
const requestExit = (): void => {
|
||||
if (agent.status === 'running') {
|
||||
exitAfterIdle = true
|
||||
agent.cancel({ kind: 'user' })
|
||||
appendNotice('Cancelling the active turn before exit…', 'warning')
|
||||
void agent.whenIdle().then(() => shutdown(true))
|
||||
return
|
||||
}
|
||||
void shutdown(true)
|
||||
@@ -1190,67 +1181,14 @@ export function createTuiChat(
|
||||
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
||||
return
|
||||
}
|
||||
if (agent.acceptsNextStep) {
|
||||
// Steering is never subject to prompt admission; an attached snapshot
|
||||
// drains beside it at the same step boundary through the outbox.
|
||||
if (attachedContext !== undefined) {
|
||||
agent.inject(attachedContext)
|
||||
}
|
||||
const message = createUserMessage({ content, source: { kind: 'user' } })
|
||||
agent.steer(message)
|
||||
pendingSteering.add(message.id)
|
||||
refreshStatus()
|
||||
return
|
||||
}
|
||||
if (attachedContext === undefined) {
|
||||
agent.followup(createUserMessage({ content, source: { kind: 'user' } }))
|
||||
return
|
||||
}
|
||||
// Idle: the snapshot rides the prompt's admission transaction so a
|
||||
// blocking hook discards both together.
|
||||
let cleanedUp = false
|
||||
const message: UserMessage = createUserMessage({ content, source: { kind: 'user' } })
|
||||
const acceptedId = message.id
|
||||
const discarded = new Set<MessageId>()
|
||||
const cleanup = (): void => {
|
||||
// Every completion path detaches both listeners. Keep this
|
||||
// idempotent so later cleanup paths cannot double-release them.
|
||||
/* v8 ignore next -- unreachable idempotence guard, see above */
|
||||
if (cleanedUp) return
|
||||
cleanedUp = true
|
||||
detachSubmit()
|
||||
detachDiscard()
|
||||
}
|
||||
// Prepended so this wrapper is outermost: it observes the exact accepted
|
||||
// message identity whether a downstream hook allows or blocks, then detaches.
|
||||
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => {
|
||||
if (subject !== agent || submitted.id !== message.id) return next()
|
||||
cleanup()
|
||||
const decision = await next()
|
||||
if (decision.kind !== 'allow') return decision
|
||||
return { ...decision, additionalContexts: [...decision.additionalContexts ?? [], attachedContext] }
|
||||
}, { prepend: true })
|
||||
// Installed before followup(): an enqueue listener can synchronously
|
||||
// cancel and discard before followup() returns its id.
|
||||
const detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => {
|
||||
if (subject !== agent) return
|
||||
for (const message of messages) discarded.add(message.id)
|
||||
if (discarded.has(acceptedId)) cleanup()
|
||||
})
|
||||
// followup() accepts any typed input and contains listener failures;
|
||||
// this guards a future synchronous throw so the wrapper cannot leak.
|
||||
/* v8 ignore start -- future-proofing guard, see above */
|
||||
try {
|
||||
agent.followup(message)
|
||||
if (discarded.has(acceptedId)) cleanup()
|
||||
} catch (error: unknown) {
|
||||
cleanup()
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
if (attachedContext !== undefined) agent.inject(attachedContext)
|
||||
const message = createUserMessage({ content, source: { kind: 'user' } })
|
||||
agent.steer(message)
|
||||
pendingSteering.add(message.id)
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
/** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
|
||||
/** Deliver user input to the nearest step, or report a disposed agent. */
|
||||
const deliver = (payload: string): void => {
|
||||
dispatchMessage([{ type: 'text', text: payload }])
|
||||
}
|
||||
@@ -1448,14 +1386,11 @@ export function createTuiChat(
|
||||
const settlePendingSteering = (id: MessageId): void => {
|
||||
if (pendingSteering.delete(id)) refreshStatus()
|
||||
}
|
||||
const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => {
|
||||
const disposeDequeued = ctx.on('agent/inbox/admitted', (subject, message) => {
|
||||
if (subject === agent) settlePendingSteering(message.id)
|
||||
})
|
||||
const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, messages) => {
|
||||
if (subject !== agent) return
|
||||
let changed = false
|
||||
for (const message of messages) changed = pendingSteering.delete(message.id) || changed
|
||||
if (changed) refreshStatus()
|
||||
const disposeDiscarded = ctx.on('agent/inbox/canceled', (subject, message) => {
|
||||
if (subject === agent && pendingSteering.delete(message.id)) refreshStatus()
|
||||
})
|
||||
const disposeStatus = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent) return
|
||||
@@ -1464,6 +1399,10 @@ export function createTuiChat(
|
||||
// the queue without logging drains, cannot strand a stale count).
|
||||
if (status !== 'running') pendingSteering.clear()
|
||||
setStatus(status)
|
||||
if (status === 'idle' && exitAfterIdle) {
|
||||
exitAfterIdle = false
|
||||
void shutdown(true)
|
||||
}
|
||||
})
|
||||
const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
|
||||
if (subject !== agent) return
|
||||
|
||||
@@ -6,7 +6,6 @@ import AgentRegistry, {
|
||||
type AgentCancelCause,
|
||||
type AgentOptions,
|
||||
type AgentStatus,
|
||||
type SendOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
ContentBlock,
|
||||
@@ -27,7 +26,6 @@ interface FakeAgent extends Agent {
|
||||
status: AgentStatus
|
||||
sent: ContentBlock[][]
|
||||
sentMessages: UserMessage[]
|
||||
sentOptions: (SendOptions | undefined)[]
|
||||
steered: ContentBlock[][]
|
||||
steeredIds: MessageId[]
|
||||
steeredOptions: UserMessage[]
|
||||
@@ -178,7 +176,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
if (options.omitInitialLifecycle !== true) {
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
}
|
||||
@@ -187,7 +184,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
const sentMessages: UserMessage[] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const steeredIds: MessageId[] = []
|
||||
const sentOptions: (SendOptions | undefined)[] = []
|
||||
const steeredOptions: UserMessage[] = []
|
||||
const injected: ContentBlock[][] = []
|
||||
const injectedOptions: UserMessage[] = []
|
||||
@@ -203,23 +199,15 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
ctx,
|
||||
sent,
|
||||
sentMessages,
|
||||
sentOptions,
|
||||
steered,
|
||||
steeredIds,
|
||||
steeredOptions,
|
||||
injected,
|
||||
injectedOptions,
|
||||
cancelled,
|
||||
send(input, options) {
|
||||
sent.push(input.content)
|
||||
sentMessages.push(input)
|
||||
sentOptions.push(options)
|
||||
return input.id
|
||||
},
|
||||
followup(input) {
|
||||
sent.push(input.content)
|
||||
sentMessages.push(input)
|
||||
sentOptions.push(undefined)
|
||||
return input.id
|
||||
},
|
||||
steer(input) {
|
||||
|
||||
@@ -322,7 +322,6 @@ describe('TUI terminal-state snapshots', () => {
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
appendUser(session, 'Next question.')
|
||||
},
|
||||
@@ -774,14 +773,14 @@ describe('TUI terminal-state snapshots', () => {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' },
|
||||
})
|
||||
harness.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
harness.session.append('turn/start', { turn: 2 })
|
||||
harness.session.append('turn/end', {
|
||||
turn: 2,
|
||||
reason: { kind: 'interrupted' },
|
||||
})
|
||||
harness.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
harness.session.append('turn/start', { turn: 3 })
|
||||
harness.session.append('turn/end', { turn: 3, reason: { kind: 'disposed' } })
|
||||
harness.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
harness.session.append('turn/start', { turn: 4 })
|
||||
// A merge-extensible turn-end kind unknown to the TUI still surfaces its
|
||||
// name so the agent never stops without a visible reason.
|
||||
harness.session.append('turn/end', { turn: 4, reason: { kind: 'plugin-policy' } as never })
|
||||
@@ -822,7 +821,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
const log = (meta: typeof earlier, title: string, day: string): { meta: typeof earlier; events: SessionEvent[] } => ({
|
||||
meta,
|
||||
events: [
|
||||
{ type: 'turn/start', seq: 0, time: Date.parse(`${day}T00:00:01Z`), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: Date.parse(`${day}T00:00:01Z`), data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: 1, time: Date.parse(`${day}T00:00:02Z`), data: createUserMessage({ content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }), surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: Date.parse(`${day}T00:00:03Z`), data: { turn: 1, step: 1 } },
|
||||
{ type: 'request/header', seq: 3, time: Date.parse(`${day}T00:00:04Z`), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('goodbye message and /resume', () => {
|
||||
time = 100,
|
||||
reason: TurnEndReason = { kind: 'completed' },
|
||||
): SessionEvent[] => [
|
||||
{ type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time, data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: 1, time: time + 1, data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append' },
|
||||
@@ -1333,9 +1333,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
appendAssistant(result.session, [])
|
||||
result.session.append('step/end', { turn: 1, step: 1 })
|
||||
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
result.session.append('turn/start', { turn: 2 })
|
||||
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
result.session.append('turn/start', { turn: 3 })
|
||||
result.session.append('step/start', { turn: 3, step: 1 })
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 3,
|
||||
@@ -1872,7 +1872,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).not.toContain('│')
|
||||
expect(result.terminal.output).not.toContain('Response 1s')
|
||||
|
||||
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
result.session.append('turn/start', { turn: 2 })
|
||||
result.session.append('step/start', { turn: 2, step: 1 })
|
||||
clock += 1_000
|
||||
result.terminal.output = ''
|
||||
@@ -3760,7 +3760,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 }
|
||||
unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
unrelatedSession.append('turn/start', { turn: 1 })
|
||||
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
|
||||
agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
|
||||
agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error'))
|
||||
@@ -3768,22 +3768,22 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
|
||||
events.session.append('step/end', { turn: 1, step: 1 })
|
||||
events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } })
|
||||
events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/start', { turn: 2 })
|
||||
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } })
|
||||
events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/start', { turn: 3 })
|
||||
events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted' } })
|
||||
events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/start', { turn: 4 })
|
||||
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
|
||||
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/start', { turn: 5 })
|
||||
events.session.append('turn/end', { turn: 5, reason: { kind: 'interrupted' } })
|
||||
events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/start', { turn: 6 })
|
||||
events.session.append('turn/end', {
|
||||
turn: 6,
|
||||
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
|
||||
})
|
||||
events.session.append('turn/start', { turn: 8, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/start', { turn: 8 })
|
||||
events.session.append('turn/end', { turn: 8, reason: { kind: 'disposed' } })
|
||||
events.session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/start', { turn: 9 })
|
||||
// Merge-extensible reason kind unknown to the TUI still names the stop.
|
||||
events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never })
|
||||
agentEvents(events.ctx, events.agent).emit('agent/disposed')
|
||||
@@ -4715,7 +4715,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
|
||||
@@ -4740,7 +4740,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||||
@@ -4775,14 +4775,14 @@ describe('terminal mounting', () => {
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
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', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
@@ -4813,7 +4813,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
@@ -4853,11 +4853,11 @@ describe('terminal mounting', () => {
|
||||
await ctx.plugin(TuiPromptService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('failed-start-session'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('ApprovalService.request', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const session = ctx.sessions.create(SessionId('asked-observer-throw'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { session } as unknown as Agent
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
@@ -141,7 +141,7 @@ describe('ApprovalService.request', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const session = ctx.sessions.create(SessionId('decided-observer-throw'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { session } as unknown as Agent
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
@@ -360,7 +360,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
*/
|
||||
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
|
||||
const session = new Session(SessionId(id))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const injected: string[] = []
|
||||
const agent = {
|
||||
id,
|
||||
|
||||
@@ -14,7 +14,7 @@ async function setup(): Promise<Context> {
|
||||
}
|
||||
|
||||
function startTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
}
|
||||
|
||||
describe('approval invariants', () => {
|
||||
@@ -32,7 +32,7 @@ describe('approval invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const id = ApprovalRequestId('ask-resume')
|
||||
session.append('approval/asked', { id, toolName: 'bash' })
|
||||
await ctx.plugin(InvariantService)
|
||||
@@ -54,7 +54,7 @@ describe('approval invariants', () => {
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
})
|
||||
ctx.emit('session/event', session, asked)
|
||||
ctx.emit('session/event', session, decided)
|
||||
|
||||
Reference in New Issue
Block a user