Merge remote-tracking branch 'origin/master' into codex/tool-json-schema-dsl
# Conflicts: # docs/config-catalog.md # docs/cookbook/adding-a-tool.i18n.yaml # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # packages/core/tools/tests/tools.spec.ts
This commit is contained in:
@@ -662,7 +662,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
|
||||
// Prompt-submit is inside the new turn but before prompt assembly. Promptless
|
||||
// injection turns leave the switch pending because they execute no request.
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => {
|
||||
const rec = ownedRecord(agent)
|
||||
if (rec !== undefined) flushPendingSwitches(rec)
|
||||
return next()
|
||||
@@ -926,7 +926,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
cancel(params: CancelNotification): Promise<void> {
|
||||
const rec = sessions.get(SessionId(params.sessionId))
|
||||
if (rec === undefined) return Promise.resolve()
|
||||
// session/cancel maps to the queue-aware agent.cancel(reason): it aborts
|
||||
// session/cancel maps to the queue-aware agent.cancel({ kind: 'user' }): it aborts
|
||||
// a RUNNING step, clears the queued + steering FIFOs, and drops a
|
||||
// turn that is about to start (the pre-step window) — so a queued-but-
|
||||
// not-yet-started prompt never runs, while a prompt accepted afterward
|
||||
@@ -941,7 +941,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
if (rec.commandAbort !== undefined) {
|
||||
rec.commandAbort.abort(new Error('session/cancel'))
|
||||
} else {
|
||||
rec.agent.cancel('session/cancel')
|
||||
rec.agent.cancel({ kind: 'user' })
|
||||
settlePrompt(rec, 'cancelled')
|
||||
}
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('turnEndToStopReason', () => {
|
||||
it('maps every known TurnEndReason kind to a legal StopReason', () => {
|
||||
expect(turnEndToStopReason({ kind: 'completed' })).toBe('end_turn')
|
||||
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
|
||||
expect(turnEndToStopReason({ kind: 'aborted', reason: 'x' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'aborted' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn')
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _next) => ({
|
||||
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _signal, _next) => ({
|
||||
...callConfig,
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
|
||||
@@ -325,6 +325,10 @@ describe('acp bridge — turn outcomes', () => {
|
||||
await harness.client.cancel({ sessionId })
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
await agent.whenIdle()
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
})
|
||||
|
||||
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ export function apply(ctx: Context): void {
|
||||
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
|
||||
})),
|
||||
...exec.agent !== undefined ? { agent: exec.agent } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
signal: exec.signal,
|
||||
})
|
||||
return [{ type: 'text', text: JSON.stringify(result) }]
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
interface OptionSchemaShape {
|
||||
properties: {
|
||||
questions: {
|
||||
@@ -75,6 +77,7 @@ describe('ask_user_question tool', () => {
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ask-1'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
@@ -110,6 +113,7 @@ describe('ask_user_question tool', () => {
|
||||
})
|
||||
|
||||
await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ask-recommended'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
@@ -144,6 +148,7 @@ describe('ask_user_question tool', () => {
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ask-multi'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
@@ -198,6 +203,7 @@ describe('ask_user_question tool', () => {
|
||||
const agent = { id: 'main' } as unknown as Agent
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ask-3'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
|
||||
@@ -212,6 +218,7 @@ describe('ask_user_question tool', () => {
|
||||
const ctx = await setup()
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ask-no-provider'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
@@ -227,6 +234,7 @@ describe('ask_user_question tool', () => {
|
||||
const ctx = await setup()
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('ask-empty'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [] },
|
||||
|
||||
@@ -1337,7 +1337,7 @@ export function createTuiChat(
|
||||
: event.data.reason.message
|
||||
if (!liveErrors.delete(key)) appendNotice(message, 'error')
|
||||
} else if (event.data.reason.kind === 'aborted') {
|
||||
appendNotice(event.data.reason.reason ?? 'Turn cancelled.', 'warning')
|
||||
appendNotice('Turn cancelled.', 'warning')
|
||||
} else if (event.data.reason.kind === 'max-tokens') {
|
||||
appendNotice('The model reached its output-token limit.', 'warning')
|
||||
} else if (event.data.reason.kind === 'rejected') {
|
||||
@@ -1484,7 +1484,7 @@ export function createTuiChat(
|
||||
|
||||
const requestExit = (): void => {
|
||||
if (agent.status === 'running') {
|
||||
agent.cancel('terminal exit requested')
|
||||
agent.cancel({ kind: 'user' })
|
||||
appendNotice('Cancelling the active turn before exit…', 'warning')
|
||||
void agent.whenIdle().then(() => shutdown(true))
|
||||
return
|
||||
@@ -1567,7 +1567,7 @@ export function createTuiChat(
|
||||
description: 'Cancel the active turn',
|
||||
handler: () => {
|
||||
if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
|
||||
agent.cancel('cancelled from terminal')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return { kind: 'success', text: 'Cancellation requested.' }
|
||||
},
|
||||
})
|
||||
@@ -1647,12 +1647,12 @@ export function createTuiChat(
|
||||
return { consume: true }
|
||||
}
|
||||
if (matchesKey(data, Key.escape) && agent.status === 'running') {
|
||||
agent.cancel('cancelled from terminal')
|
||||
agent.cancel({ kind: 'user' })
|
||||
return { consume: true }
|
||||
}
|
||||
if (matchesKey(data, Key.ctrl('c'))) {
|
||||
if (agent.status === 'running') {
|
||||
agent.cancel('cancelled from terminal')
|
||||
agent.cancel({ kind: 'user' })
|
||||
} else if (editor.getText() !== '') {
|
||||
editor.setText('')
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, {
|
||||
type Agent,
|
||||
type AgentCancelCause,
|
||||
type AgentOptions,
|
||||
type AgentStatus,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -13,7 +18,7 @@ interface FakeAgent extends Agent {
|
||||
status: AgentStatus
|
||||
sent: ContentBlock[][]
|
||||
steered: ContentBlock[][]
|
||||
cancelled: string[]
|
||||
cancelled: AgentCancelCause[]
|
||||
}
|
||||
|
||||
export interface TuiHarnessOptions {
|
||||
@@ -110,7 +115,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
options.beforeMount?.(session)
|
||||
const sent: ContentBlock[][] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const cancelled: string[] = []
|
||||
const cancelled: AgentCancelCause[] = []
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
@@ -127,8 +132,8 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
steered.push(content)
|
||||
},
|
||||
inject() {},
|
||||
cancel(reason) {
|
||||
cancelled.push(reason ?? '')
|
||||
cancel(cause = { kind: 'user' }) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
whenIdle() {
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -34,8 +34,8 @@ buffer
|
||||
11| " Retrying model request (1/2) in 1000ms: temporary transport failure "
|
||||
style 1-67 fg=yellow
|
||||
12| <blank>
|
||||
13| " cancelled during retry delay "
|
||||
style 1-28 fg=yellow
|
||||
13| " Turn cancelled. "
|
||||
style 1-15 fg=yellow
|
||||
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
15| " "
|
||||
|
||||
@@ -279,7 +279,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
})
|
||||
harness.session.append('turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'aborted', reason: 'cancelled during retry delay' },
|
||||
reason: { kind: 'aborted' },
|
||||
})
|
||||
})
|
||||
await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true })
|
||||
|
||||
@@ -495,7 +495,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('/cancel')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.cancelled).toContain('cancelled from terminal')
|
||||
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
|
||||
|
||||
result.agent.status = 'idle'
|
||||
for (const command of ['/help', '/reasoning', '/tools', '/redraw']) {
|
||||
@@ -600,7 +600,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 }
|
||||
const request = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/request', 1, 0, seed, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
|
||||
)
|
||||
expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
|
||||
await dispose(result)
|
||||
@@ -652,7 +652,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(assembly.variables).toEqual({})
|
||||
const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' }
|
||||
await expect(agentEvents(empty.ctx, empty.agent).waterfall(
|
||||
'agent/request', 1, 0, seed, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
|
||||
)).resolves.toBe(seed)
|
||||
await dispose(empty)
|
||||
|
||||
@@ -828,7 +828,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('/exit')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.cancelled).toContain('terminal exit requested')
|
||||
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
|
||||
expect(result.exit).toHaveBeenCalledWith(0)
|
||||
|
||||
const events = await setup()
|
||||
@@ -845,7 +845,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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/end', { turn: 3, reason: { kind: 'aborted', reason: 'stopped' } })
|
||||
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/end', { turn: 4, reason: { kind: 'max-tokens' } })
|
||||
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -861,8 +861,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await tick()
|
||||
expect(events.terminal.output).toContain('live failure')
|
||||
expect(events.terminal.output).toContain('durable failure')
|
||||
expect(events.terminal.output).toContain('Turn cancelled')
|
||||
expect(events.terminal.output).toContain('structured provider failure')
|
||||
expect(events.terminal.output).toContain('stopped')
|
||||
expect(events.terminal.output).toContain('output-token limit')
|
||||
expect(events.terminal.output).toContain('Turn rejected')
|
||||
expect(events.terminal.output).toContain('previous process ended')
|
||||
|
||||
Reference in New Issue
Block a user