From 3538757cbea0decda47b20d34f7c28bdf17fa6ca Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:35:58 +0800 Subject: [PATCH] feat(terminal): code PTY session and persistent shell failures TerminalError gains SESSION_CLOSING, SESSION_EXITED, STARTUP_EXITED, STARTUP_TIMEOUT, NO_SANDBOX, and INIT_REJECTED, following its existing unprefixed code style. The terminal service and terminal-bash use them for a send to a closing or exited session, a shell that exits or misses readiness during startup, and a confined mode without a sandbox provider; an empty confined argv is SubprocessError SUBPROCESS_INVALID_ARGV, and a send on an aborted signal throws an error named AbortError. The persistent bash and pwsh tools report a missing owner as ToolContextUnavailableError, a blank command as ToolArgsError, and a shell that rejects its setup as TerminalError INIT_REJECTED. Refs #7869 --- .../tool-bash-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-bash-persistent/README.md | 2 +- .../shell/tool-bash-persistent/README.zh.md | 2 +- .../shell/tool-bash-persistent/src/index.ts | 9 +++++---- .../tool-bash-persistent/tests/tools.spec.ts | 14 +++++++++++--- .../tool-pwsh-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-pwsh-persistent/README.md | 2 +- .../shell/tool-pwsh-persistent/README.zh.md | 2 +- .../shell/tool-pwsh-persistent/src/index.ts | 6 +++--- .../tool-pwsh-persistent/tests/tools.spec.ts | 9 +++++++-- packages/terminal/terminal-bash/src/index.ts | 18 ++++++++++++------ packages/terminal/terminal-bash/src/session.ts | 17 +++++++++++------ .../terminal/terminal-bash/tests/index.spec.ts | 4 ++++ .../terminal-bash/tests/session.spec.ts | 11 ++++++++--- packages/terminal/terminal/README.i18n.yaml | 4 ++-- packages/terminal/terminal/README.md | 2 +- packages/terminal/terminal/README.zh.md | 2 +- packages/terminal/terminal/src/index.ts | 17 +++++++++++++++-- .../terminal/terminal/tests/service.spec.ts | 2 ++ 19 files changed, 90 insertions(+), 41 deletions(-) diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index cc6225da8c..a7d7a29c7c 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/README.i18n.yaml @@ -27,8 +27,8 @@ en: b398f124ea7b77b0 zh: 50855e601e6c1b88 /deepseek-ai-dsh-tool-bash-persistent/use-this-package/what-can-go-wrong: - en: 2713550ebdb02f69 - zh: 52ed398dc541d1fc + en: 52e0e1489c6f8071 + zh: 27251d61351ee72d /deepseek-ai-dsh-tool-bash-persistent/understand-the-implementation: en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md index 4bbea6bf1a..9feed7a656 100644 --- a/packages/shell/tool-bash-persistent/README.md +++ b/packages/shell/tool-bash-persistent/README.md @@ -58,7 +58,7 @@ Commands share one shell per agent, so state persists until an `exit`, a timeout ### What can go wrong -A call without an owning agent session fails with `bash requires an owning agent session`, and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. +A call without an owning agent session fails with `bash requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. ----- diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index 6b4877e177..c348502a09 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" ### 可能出什么问题 -没有拥有者 agent 会话的调用会以 `bash requires an owning agent session` 失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 +没有拥有者 agent 会话的调用会以 `bash requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 ----- diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 94de7ccf09..ac8d11ef77 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -9,9 +9,10 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' +import { TerminalError } from '@deepseek-ai/dsh-terminal' import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' // TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' @@ -278,7 +279,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell }) const result = await setup.done if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { - throw new Error('persistent bash shell did not accept initialization') + throw new TerminalError('persistent bash shell did not accept initialization', 'INIT_REJECTED') } return spawned.sessionId } catch (error: unknown) { @@ -428,9 +429,9 @@ function registerPersistentBash(ctx: Context, config: ResolvedConfig): void { render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args, exec) { - if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + if (args.command.trim().length === 0) throw new ToolArgsError(['command must be a non-empty string']) const owner = exec.agent - if (owner === undefined) throw new Error('bash requires an owning agent session') + if (owner === undefined) throw new ToolContextUnavailableError('bash requires an owning agent session') return serialized(owner, async () => { if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement. return executeCommand(ctx, shells, owner, args.command, config, exec.signal) diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 7a926e670e..6793099643 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -694,7 +694,10 @@ describe('tool-bash-persistent', () => { 'fails initialization and closes the unusable shell for %s', async (mode) => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) - expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(await call(ctx, owner, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'TerminalError', code: 'INIT_REJECTED' } }, + }) expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed') }, ) @@ -752,8 +755,13 @@ describe('tool-bash-persistent', () => { it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() - expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) - expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + expect(await call(ctx, undefined, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) + const blank = await call(ctx, owner, ' ') + expect(text(blank)).toContain('command must be a non-empty string') + expect(blank).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) const controller = new AbortController() controller.abort(new Error('caller stopped')) diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml index 60b8ae7682..ab2bb8cd7f 100644 --- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml @@ -27,8 +27,8 @@ en: 9f4c22b06f940caf zh: 61f755cec7ff5b76 /deepseek-ai-dsh-tool-pwsh-persistent/use-this-package/what-can-go-wrong: - en: 70bf2e1e8d8ccbf6 - zh: 6c78d8c85113adf7 + en: 3abc9cc73411c042 + zh: 31c9e3ddf7160414 /deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation: en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md index 0bd03285be..5437ef8a9a 100644 --- a/packages/shell/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -60,7 +60,7 @@ Commands share one shell per agent, so cwd, `$env:` variables, functions, and ba ### What can go wrong -A call without an owning agent session fails with `pwsh requires an owning agent session`, and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. +A call without an owning agent session fails with `pwsh requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. ----- diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md index 7f05af221f..f249622ce2 100644 --- a/packages/shell/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -60,7 +60,7 @@ kind: "package-reference" ### 可能出什么问题 -没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session` 失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 +没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 ----- diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 132ca51d2c..69c2ef9990 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -13,7 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' // TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.' @@ -439,9 +439,9 @@ function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args, exec) { - if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + if (args.command.trim().length === 0) throw new ToolArgsError(['command must be a non-empty string']) const owner = exec.agent - if (owner === undefined) throw new Error('pwsh requires an owning agent session') + if (owner === undefined) throw new ToolContextUnavailableError('pwsh requires an owning agent session') return serialized(owner, async () => { if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement. return executeCommand(ctx, shells, owner, args.command, config, exec.signal) diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index f842613039..c22cad7974 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -829,8 +829,13 @@ describe('tool-pwsh-persistent', () => { it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() - expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) - expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + expect(await call(ctx, undefined, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) + const blank = await call(ctx, owner, ' ') + expect(text(blank)).toContain('command must be a non-empty string') + expect(blank).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) const controller = new AbortController() controller.abort(new Error('caller stopped')) diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 5359948eb6..bdb60aa177 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -8,8 +8,9 @@ import type {} from '@deepseek-ai/dsh-working-directory' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal' +import { TerminalBackendCleanupError, TerminalError } from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } from '@deepseek-ai/dsh-terminal' +import { SubprocessError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' @@ -94,12 +95,17 @@ function childEnvironment(spec: TerminalBackendSpawnSpec, dialect: ShellDialect) export const PWSH_PROMPT_SETUP = "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }" +/** The startup deadline failure shared by both dialects. */ +function startupTimeout(): TerminalError { + return new TerminalError('PTY shell did not reach readiness before startup timeout', 'STARTUP_TIMEOUT') +} + async function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy, signal?: AbortSignal): Promise { const argv = [config.shellPath, ...config.shellArgs] if (policy.mode === 'danger-full-access') return argv const sandbox = ctx.get('sandbox') if (sandbox === undefined) { - throw new Error(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`) + throw new TerminalError(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`, 'NO_SANDBOX') } // Re-state the discriminant because object spread does not preserve its narrowed type. return (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv @@ -134,8 +140,8 @@ async function startupSession( ...signal !== undefined ? { signal } : {}, }) const result = await startupOperation.done - if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') - if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + if (result.waitReason === 'session_exit') throw new TerminalError('PTY shell exited during startup', 'STARTUP_EXITED') + if (result.waitReason === 'timeout') throw startupTimeout() viewport = result.viewport if (result.waitReason === 'stdin_read') break } @@ -154,7 +160,7 @@ async function startupSession( const deadline = Promise.withResolvers() deadlineTimer = setTimeout(() => { startupOperation?.cancel() - deadline.reject(new Error('PTY shell did not reach readiness before startup timeout')) + deadline.reject(startupTimeout()) }, timeoutMs) races.push(deadline.promise) } @@ -201,7 +207,7 @@ export class BashTerminalBackend implements TerminalBackend { const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session }) const argv = await spawnArgv(this.ctx, this.config, policy, spec.signal) spec.signal?.throwIfAborted() - if (argv[0] === undefined) throw new Error('terminal-bash: sandbox returned empty argv') + if (argv[0] === undefined) throw new SubprocessError('terminal-bash: sandbox returned empty argv', 'SUBPROCESS_INVALID_ARGV') const terminal = await this.spawnTerminal({ argv, cwd: spec.cwd ?? await this.ctx.workingDirectory.ensure(spec.owner, spec.signal), diff --git a/packages/terminal/terminal-bash/src/session.ts b/packages/terminal/terminal-bash/src/session.ts index 993b15c4ee..a78c6e4f7c 100644 --- a/packages/terminal/terminal-bash/src/session.ts +++ b/packages/terminal/terminal-bash/src/session.ts @@ -314,8 +314,8 @@ export class LocalPtySession implements TerminalBackendSession { try { const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} }) const result = await operation.done - if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') - if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + if (result.waitReason === 'session_exit') throw new TerminalError('PTY shell exited during startup', 'STARTUP_EXITED') + if (result.waitReason === 'timeout') throw new TerminalError('PTY shell did not reach readiness before startup timeout', 'STARTUP_TIMEOUT') this.motd = result.viewport } catch (error: unknown) { signal?.throwIfAborted() @@ -326,8 +326,8 @@ export class LocalPtySession implements TerminalBackendSession { } startSend(request: TerminalSendRequest): TerminalSendOperation { - if (this.closing) throw new Error('PTY session is closing') - if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited') + if (this.closing) throw new TerminalError('PTY session is closing', 'SESSION_CLOSING') + if (this.statusValue.kind === 'exited') throw new TerminalError('PTY session has exited', 'SESSION_EXITED') if (this.active !== undefined) { const draining = this.activeWrite !== undefined ? ' or draining provider write' @@ -336,7 +336,12 @@ export class LocalPtySession implements TerminalBackendSession { : '' throw new TerminalError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE') } - if (request.signal?.aborted === true) throw new Error('PTY send aborted before write') + if (request.signal?.aborted === true) { + // Named as a cancellation so the tool registry classifies it as the caller's abort. + const aborted = new Error('PTY send aborted before write') + aborted.name = 'AbortError' + throw aborted + } const operation = new LocalSendOperation( this.config.maxReadBytes, @@ -452,7 +457,7 @@ export class LocalPtySession implements TerminalBackendSession { } async signal(signal: TerminalSignal): Promise { - if (this.closing) throw new Error('PTY session is closing') + if (this.closing) throw new TerminalError('PTY session is closing', 'SESSION_CLOSING') const targetPgid = await this.terminal.signalForeground(signal) return { delivered: true, targetPgid } } diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 71793a5f6e..d992f8ee7f 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -133,6 +133,7 @@ describe('BashTerminalBackend startup rollback', () => { controller.abort(abortReason) await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason) await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv') + await expect(backend.spawn(spec(agent(ctx)))).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_INVALID_ARGV' }) }) it('closes failed startup and aggregates cleanup failure', async () => { @@ -347,6 +348,7 @@ describe('BashTerminalBackend startup rollback', () => { await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toThrow( 'sandbox mode "workspace-write" requires a ctx.sandbox provider in the execution world', ) + await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'NO_SANDBOX' })) }) it('forwards terminal allocation cancellation directly', async () => { @@ -523,8 +525,10 @@ describe('BashTerminalBackend startup rollback', () => { }) as unknown as LocalPtySession const exited = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit')) await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup') + await expect(exited.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_EXITED' })) const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') + await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_TIMEOUT' })) }) it('bounds all pwsh startup retries with one deadline', async () => { diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index bff1317c26..4dab23a32e 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -752,6 +752,7 @@ describe('LocalPtySession readiness and output', () => { terminal.emitExit(7, 9) expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } }) expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited') + expect(() => session.startSend({ text: '', submit: false })).toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_EXITED' })) }) it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => { @@ -776,6 +777,7 @@ describe('LocalPtySession readiness and output', () => { const aborted = new AbortController() aborted.abort() expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write') + expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow(expect.objectContaining({ name: 'AbortError' })) terminal.throwWrite = true const failed = session.startSend({ text: 'x', submit: false }) @@ -1166,6 +1168,7 @@ describe('LocalPtySession readiness and output', () => { const initializing = startup.initialize(new AbortController().signal) startupTerminal.emitExit(1) await expect(initializing).rejects.toThrow('exited during startup') + await expect(initializing).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_EXITED' })) expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null }) const terminal = new FakeTerminal() @@ -1238,7 +1241,9 @@ describe('LocalPtySession readiness and output', () => { const timeoutTerminal = new FakeTerminal() const timeout = new LocalPtySession(timeoutTerminal, config()) - const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout') + const timedOut = expect(timeout.initialize()).rejects.toEqual(expect.objectContaining({ + name: 'TerminalError', code: 'STARTUP_TIMEOUT', message: expect.stringContaining('startup timeout') as string, + })) await vi.advanceTimersByTimeAsync(100) await timedOut }) @@ -1640,8 +1645,8 @@ describe('LocalPtySession bounds, signals, and teardown', () => { const closing = session.close('test') expect(session.close('other')).toBe(closing) await expect(closing).rejects.toThrow('PTY cleanup failed (test)') - expect(() => session.startSend({ text: '', submit: false })).toThrow('closing') - await expect(session.signal('SIGTERM')).rejects.toThrow('closing') + expect(() => session.startSend({ text: '', submit: false })).toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) + await expect(session.signal('SIGTERM')).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) }) it('reports cleanup failure without waiting for top-level exit and permits retry', async () => { diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index 50ce7993cf..6b91667f72 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/README.i18n.yaml @@ -48,8 +48,8 @@ en: 7af36e58919e41d5 zh: 4fe7b4db1099bcbf /deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation: - en: 5815723cd0c6089c - zh: 0c612ffa4b8c466c + en: 0b8dc3a382aa7bf2 + zh: e6cc6906e01827d2 /deepseek-ai-dsh-terminal/further-exploration: en: 8459a3b70a9f8bad zh: 21372aa8939d2182 diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index 26e8506bd8..40cb6866f8 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -88,7 +88,7 @@ Each published session is a record of its id, owner, optional name, backend type ### Send reservation -The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. +The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. Failures are `TerminalError` instances (a `HarnessError`); besides the registry codes, a send to a closing session fails with `SESSION_CLOSING`, and backends report `SESSION_EXITED`, `STARTUP_EXITED`, `STARTUP_TIMEOUT`, `NO_SANDBOX`, and `INIT_REJECTED` with the same class. diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index a185554a9e..bb21c9e502 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -88,7 +88,7 @@ kind: "package-reference" ### 发送预留 -服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。 +服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。失败均为 `TerminalError`(一种 `HarnessError`);除注册表 code 外,向正在关闭的会话发送会以 `SESSION_CLOSING` 失败,后端以同一类报告 `SESSION_EXITED`、`STARTUP_EXITED`、`STARTUP_TIMEOUT`、`NO_SANDBOX` 与 `INIT_REJECTED`。 diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index f97ab51cae..df09ff94aa 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -52,16 +52,29 @@ declare module '@deepseek-ai/cordis' { } } -/** Machine-routable PTY service failures. */ +/** + * Machine-routable PTY service and backend failures. Backends use + * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a + * confined mode has no sandbox provider, `SESSION_CLOSING` and + * `SESSION_EXITED` for an operation on a closing or exited session, and + * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses + * readiness during startup. + */ export type TerminalErrorCode = | 'DUPLICATE_BACKEND' | 'DUPLICATE_NAME' | 'FOREIGN_SESSION' + | 'INIT_REJECTED' | 'NO_BACKEND' + | 'NO_SANDBOX' | 'NO_SESSION' | 'OWNER_NOT_LIVE' | 'SEND_ACTIVE' | 'SERVICE_DISPOSING' + | 'SESSION_CLOSING' + | 'SESSION_EXITED' + | 'STARTUP_EXITED' + | 'STARTUP_TIMEOUT' /** Harness error carrying a stable {@link TerminalErrorCode}. */ export class TerminalError extends HarnessError { @@ -246,7 +259,7 @@ export class TerminalSessionService extends Service { */ startSend(owner: Agent, id: TerminalSessionId, request: TerminalSendRequest): TerminalSendOperation { const record = this.expectOwned(owner, id) - if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`) + if (record.closing !== undefined) throw new TerminalError(`PTY session ${id} is closing`, 'SESSION_CLOSING') if (record.active !== undefined) throw new TerminalError(`PTY session ${id} already has an active send`, 'SEND_ACTIVE') const operation = record.session.startSend(request) record.active = operation diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 3d8304275b..0237f2970a 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -525,6 +525,8 @@ describe('TerminalSessionService ownership and lifecycle', () => { b.sessions[0]!.closeGate = Promise.withResolvers() const first = ctx.terminals.kill(owner, created.sessionId) expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing') + expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false })) + .toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) const second = ctx.terminals.kill(owner, created.sessionId) b.sessions[0]!.closeGate?.resolve(undefined) expect(await first).toBe(true)