Merge latest invariant registration gate

This commit is contained in:
Tianyi Cui
2026-07-20 20:29:29 +08:00
22 changed files with 329 additions and 81 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-stdio
The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal.
The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal. Failed turns render their durable `turn/end` reason — `[turn failed <code>]`, `[turn aborted]`, `[turn rejected]`, `[turn interrupted …]`, or the output-token-limit notice — so a provider or network failure is never silent; unknown merge-extended reason kinds fall through as ordinary turn ends.
This package owns the terminal channel only. It injects `agents` and `userInteraction`, then drives an agent created or resumed by app or developer code. The agent spine, agent lifecycle, console logger, and model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.

View File

@@ -15,6 +15,7 @@ import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-agent-loop'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
@@ -62,15 +63,6 @@ function isTTYPair(input: Readable, output: Writable): boolean {
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
}
/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
interface PendingQuestion {
request: AskUserQuestionRequest
questionIndex: number
@@ -138,6 +130,21 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
} else if (event.type === 'turn/end') {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
// Failure reasons must reach the terminal: turn/end is the durable record
// of an in-turn failure, and without this line a failed turn renders as
// silence. Merge-extensible unknown kinds fall through as ordinary ends.
const { reason } = event.data
if (reason.kind === 'error') {
output.write(`\n[turn failed${reason.code === undefined ? '' : ` ${reason.code}`}] ${reason.message}`)
} else if (reason.kind === 'aborted') {
output.write(`\n[turn aborted]${reason.reason === undefined ? '' : ` ${reason.reason}`}`)
} else if (reason.kind === 'rejected') {
output.write(`\n[turn rejected] ${reason.reason}`)
} else if (reason.kind === 'max-tokens') {
output.write('\n[turn hit the output-token limit]')
} else if (reason.kind === 'interrupted') {
output.write('\n[turn interrupted by a previous process exit]')
}
output.write('\n> ')
} else if (event.type === 'tool/call') {
const { name: toolName, arguments: args } = event.data
@@ -239,7 +246,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
queuedInput.length = 0
submittedWork = sawRunning
if (dropped > 0) {
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`)
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${errorChain(error)}`)
}
maybeExit()
})
@@ -399,7 +406,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const text = line.trim()
if (!text) return
if (failedStartup !== undefined) {
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`)
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${errorChain(failedStartup.error)}`)
return
}
const agent = target

View File

@@ -243,6 +243,41 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('\n> ')
})
it('renders failure turn/end reasons so a failed turn is not silent', async () => {
const { ctx, out } = await setup()
const session = makeSession('main')
ctx.emit('session/event', session, {
type: 'turn/end', seq: 1, time: 0,
data: { turn: 1, reason: { kind: 'error', step: 1, message: 'fetch failed: connect ECONNREFUSED', code: 'NETWORK' } },
} as SessionEvent)
expect(out.text()).toContain('[turn failed NETWORK] fetch failed: connect ECONNREFUSED')
ctx.emit('session/event', session, {
type: 'turn/end', seq: 2, time: 0,
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'uncoded failure' } },
} as SessionEvent)
expect(out.text()).toContain('[turn failed] uncoded failure')
ctx.emit('session/event', session, {
type: 'turn/end', seq: 3, time: 0, data: { turn: 3, reason: { kind: 'aborted', reason: 'user cancelled' } },
} as SessionEvent)
expect(out.text()).toContain('[turn aborted] user cancelled')
ctx.emit('session/event', session, {
type: 'turn/end', seq: 4, time: 0, data: { turn: 4, reason: { kind: 'aborted' } },
} as SessionEvent)
expect(out.text()).toContain('[turn aborted]\n> ')
ctx.emit('session/event', session, {
type: 'turn/end', seq: 5, time: 0, data: { turn: 5, reason: { kind: 'rejected', reason: 'policy veto' } },
} as SessionEvent)
expect(out.text()).toContain('[turn rejected] policy veto')
ctx.emit('session/event', session, {
type: 'turn/end', seq: 6, time: 0, data: { turn: 6, reason: { kind: 'max-tokens' } },
} as SessionEvent)
expect(out.text()).toContain('[turn hit the output-token limit]')
ctx.emit('session/event', session, {
type: 'turn/end', seq: 7, time: 0, data: { turn: 7, reason: { kind: 'interrupted' } },
} as SessionEvent)
expect(out.text()).toContain('[turn interrupted by a previous process exit]')
})
it('uses the session id as the label for a non-target session', async () => {
const { ctx, out } = await setup()
// No target exists, so the event's durable identity is the label.
@@ -867,7 +902,7 @@ describe('createStdioChat input', () => {
await new Promise(r => setImmediate(r))
expect(error).toHaveBeenCalledWith(
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable thrown value>',
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable value>',
)
})
@@ -955,7 +990,7 @@ describe('createStdioChat EOF exit', () => {
await flushExit()
expect(error).toHaveBeenCalledWith(
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable thrown value>',
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable value>',
)
expect(exit).toHaveBeenCalledWith(0)
})