Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/pty/pty-local/tests/index.spec.ts # packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
@@ -2,7 +2,16 @@
|
||||
|
||||
Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`, and `terminal_list`. Every operation requires the exact initiating `Agent`, so a model cannot address another agent's terminal even if it learns the id.
|
||||
|
||||
`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight occurs before any terminal write, completion is collected with `task_output`, and `task_kill` requests `Ctrl-C`. Foreground sends use terminal ACP cards; lifecycle, history, signal, and list calls use generic cards.
|
||||
`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight and the PTY service's exclusive per-session send reservation occur before the task id is returned, completion is collected with `task_output`, and `task_kill` delivers `SIGINT` to the foreground process group. Foreground sends use terminal ACP call/result cards. Background sends use a generic execute card; open, read, signal, close, and list use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. None declares source locations.
|
||||
|
||||
## Config
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---:|---|
|
||||
| `enableRunInBackground` | `true` | expose and accept `run_in_background`; false omits the schema field and rejects a forced undeclared argument |
|
||||
| `maxResultBytes` | `262144` | UTF-8 cap (minimum `64`) for each complete terminal result or PTY task output after wait, session, pagination, truncation, and task-status metadata |
|
||||
|
||||
Both values are validated at load. The minimum result cap keeps every registry-issued session or task id visible in its creation acknowledgement. When a result exceeds `maxResultBytes`, rendering reserves space for control metadata and a truncation marker when they fit; cuts preserve UTF-8 boundaries. Each terminal definition's final-content callback applies the same cap after normalized pre-, around-, and post-execute policy failures, denials, short-circuits, replacements, or blocks; a structured multi-block policy result retains its shape.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -44,11 +53,11 @@ Prefix-stable while tool visibility and definitions are unchanged.
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output. Programmatic callers receive typed session snapshots, bounded send/read DTOs, signal and close outcomes, or `{ kind: "background", taskId }`; Native rendering preserves the text above.
|
||||
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Every terminal-owned or policy-produced single-text result is capped by `maxResultBytes` after normalized tool or pipeline errors, denials, short-circuits, replacements, blocks, and generic task status text. Structured multi-block policy results retain their shape. Results remain in session history until compaction; incremental task reads do not repeat consumed output. Programmatic callers receive typed session snapshots, bounded provider read/send DTOs, signal and close outcomes, or `{ kind: "background", taskId }`; Native rendering applies the presentation cap above.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent and bounded by the backend; each returned result remains in history until compaction.
|
||||
Terminal-owned and policy-produced single-text results are data-dependent and bounded by `maxResultBytes`; a policy that deliberately substitutes structured multi-block content owns that content's bound. Each returned result remains in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -26,11 +26,15 @@
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-pty": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -44,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
@@ -24,6 +26,25 @@ export const name = 'tool-pty'
|
||||
/** Required capability, registry, and prompt services. */
|
||||
export const inject = ['pty', 'tools', 'systemPrompt']
|
||||
|
||||
/** Default cap for one complete model-facing terminal result. */
|
||||
export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
|
||||
/** Smallest cap that preserves every counter-backed PTY and task id in its creation acknowledgement. */
|
||||
export const MIN_MAX_RESULT_BYTES = 64
|
||||
|
||||
/** Model-facing terminal tool configuration. */
|
||||
export interface Config {
|
||||
/** Expose `run_in_background` and accept background sends (default true). */
|
||||
enableRunInBackground?: boolean
|
||||
/** Maximum UTF-8 bytes in one complete terminal or task-output result. */
|
||||
maxResultBytes?: number
|
||||
}
|
||||
|
||||
/** Schemastery configuration for the terminal tool consumer. */
|
||||
export const Config: z<Config> = z.object({
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
maxResultBytes: z.number().step(1).min(MIN_MAX_RESULT_BYTES).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES),
|
||||
})
|
||||
|
||||
interface SpawnArgs {
|
||||
type: string
|
||||
name?: string
|
||||
@@ -105,9 +126,13 @@ function sessionId(args: SessionArgs): PtySessionIdType {
|
||||
return PtySessionId(args.sessionId)
|
||||
}
|
||||
|
||||
function rawResultText(result: ToolResult): string | undefined {
|
||||
if (result.content.length !== 1) return undefined
|
||||
const block = result.content[0]
|
||||
function textResult(text: string, maxBytes: number): ContentBlock[] {
|
||||
return [{ type: 'text', text: boundTerminalText(text, maxBytes) }]
|
||||
}
|
||||
|
||||
function rawContentText(content: readonly ContentBlock[]): string | undefined {
|
||||
if (content.length !== 1) return undefined
|
||||
const block = content[0]
|
||||
return block?.type === 'text' ? block.text : undefined
|
||||
}
|
||||
|
||||
@@ -118,7 +143,16 @@ function sendDetail(result: PtySendResult): string {
|
||||
}
|
||||
|
||||
/** Register all terminal tools and the minimal usage guidance. */
|
||||
export function apply(ctx: Context): void {
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const enableRunInBackground = config.enableRunInBackground ?? true
|
||||
const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES
|
||||
if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < MIN_MAX_RESULT_BYTES) {
|
||||
throw new Error(`tool-pty: maxResultBytes must be a safe integer of at least ${MIN_MAX_RESULT_BYTES}`)
|
||||
}
|
||||
const finalizeContent: NonNullable<ToolDefinition['finalizeContent']> = (_exec, result) => {
|
||||
const raw = rawContentText(result.content)
|
||||
return raw === undefined ? undefined : textResult(raw, maxResultBytes)
|
||||
}
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pty',
|
||||
order: 106,
|
||||
@@ -133,6 +167,7 @@ export function apply(ctx: Context): void {
|
||||
name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' },
|
||||
cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' },
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -142,7 +177,7 @@ export function apply(ctx: Context): void {
|
||||
motd: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: renderSpawn(value) }],
|
||||
render: (_args, value) => [{ type: 'text', text: renderSpawn(value, maxResultBytes) }],
|
||||
},
|
||||
async execute(args: SpawnArgs, exec) {
|
||||
if (args.type.length === 0) throw new Error('type must be a non-empty string')
|
||||
@@ -161,13 +196,17 @@ export function apply(ctx: Context): void {
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'terminal_send',
|
||||
description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.',
|
||||
description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit.'
|
||||
+ (enableRunInBackground ? ' Background mode returns a task id for task_output/task_kill.' : ''),
|
||||
parameters: {
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_list.' },
|
||||
text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' },
|
||||
submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' },
|
||||
run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' },
|
||||
...enableRunInBackground
|
||||
? { run_in_background: { type: 'boolean' as const, description: 'Return a task id immediately; collect with task_output or stop with task_kill.' } }
|
||||
: {},
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
oneOf: [
|
||||
@@ -193,7 +232,7 @@ export function apply(ctx: Context): void {
|
||||
type: 'text',
|
||||
text: value.kind === 'background'
|
||||
? `started background task ${value.taskId}`
|
||||
: renderSend(value),
|
||||
: renderSend(value, maxResultBytes),
|
||||
}],
|
||||
presentationMeta: (_args, value) => value.kind === 'foreground'
|
||||
? {
|
||||
@@ -209,6 +248,7 @@ export function apply(ctx: Context): void {
|
||||
const id = sessionId(args)
|
||||
const request = { text: args.text, submit: args.submit ?? true }
|
||||
if (args.run_in_background === true) {
|
||||
if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration')
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
let cancelRequested = false
|
||||
@@ -216,6 +256,7 @@ export function apply(ctx: Context): void {
|
||||
kind: 'pty-send',
|
||||
label: `${id}: ${args.text || '(input)'}`,
|
||||
owner,
|
||||
outputLimitBytes: maxResultBytes,
|
||||
run: () => {
|
||||
const operation = ctx.pty.startSend(owner, id, request)
|
||||
return {
|
||||
@@ -247,7 +288,7 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
presentResult(args, result) {
|
||||
if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
|
||||
const raw = rawResultText(result)
|
||||
const raw = rawContentText(result.content)
|
||||
return raw === undefined ? undefined : { card: 'terminal', output: raw }
|
||||
},
|
||||
}))
|
||||
@@ -260,6 +301,7 @@ export function apply(ctx: Context): void {
|
||||
offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
|
||||
count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -272,7 +314,7 @@ export function apply(ctx: Context): void {
|
||||
truncated: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: renderRead(value) }],
|
||||
render: (_args, value) => [{ type: 'text', text: renderRead(value, maxResultBytes) }],
|
||||
},
|
||||
execute(args: ReadArgs, exec) {
|
||||
const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), {
|
||||
@@ -291,6 +333,7 @@ export function apply(ctx: Context): void {
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
|
||||
signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' },
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -314,6 +357,7 @@ export function apply(ctx: Context): void {
|
||||
parameters: {
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -342,9 +386,10 @@ export function apply(ctx: Context): void {
|
||||
name: 'terminal_list',
|
||||
description: 'List persistent terminal sessions owned by the current agent.',
|
||||
parameters: {},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: { type: 'array', items: SESSION_SNAPSHOT_SCHEMA },
|
||||
render: (_args, value) => [{ type: 'text', text: renderList(value) }],
|
||||
render: (_args, value) => [{ type: 'text', text: renderList(value, maxResultBytes) }],
|
||||
},
|
||||
execute(_args: Record<string, never>, exec) {
|
||||
return Promise.resolve(ctx.pty.list(requireAgent(exec.agent)))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Model and ACP rendering for persistent terminal tool results. */
|
||||
|
||||
import { TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
|
||||
interface RenderedSessionStatusRunning {
|
||||
kind: 'running'
|
||||
}
|
||||
@@ -44,56 +46,126 @@ interface RenderedReadResult {
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const TRUNCATED = '\n[output truncated]'
|
||||
|
||||
function byteLength(text: string): number {
|
||||
return encoder.encode(text).byteLength
|
||||
}
|
||||
|
||||
function retain(text: string, maxBytes: number, kind: 'head' | 'tail'): string {
|
||||
const retainer = new TextRetainer({ kind, maxBytes })
|
||||
retainer.push(text)
|
||||
return retainer.finish().text
|
||||
}
|
||||
|
||||
function fitWithSuffix(content: string, suffix: string, maxBytes: number): string {
|
||||
const fixedBytes = byteLength(suffix)
|
||||
if (fixedBytes >= maxBytes) return retain(suffix, maxBytes, 'tail')
|
||||
return `${retain(content, maxBytes - fixedBytes, 'tail')}${suffix}`
|
||||
}
|
||||
|
||||
function fitWithPrefix(prefix: string, content: string, maxBytes: number): string {
|
||||
const fixed = `${prefix}${TRUNCATED}`
|
||||
const fixedBytes = byteLength(fixed)
|
||||
if (fixedBytes >= maxBytes) return retain(fixed, maxBytes, 'head')
|
||||
return `${prefix}${retain(content, maxBytes - fixedBytes, 'tail')}${TRUNCATED}`
|
||||
}
|
||||
|
||||
function boundBodyWithSuffix(
|
||||
content: string,
|
||||
metadata: string,
|
||||
upstreamTruncated: boolean,
|
||||
maxBytes: number,
|
||||
): string {
|
||||
const suffix = `${metadata}${upstreamTruncated ? TRUNCATED : ''}`
|
||||
const complete = `${content}${suffix}`
|
||||
if (byteLength(complete) <= maxBytes) return complete
|
||||
return fitWithSuffix(content, `${metadata}${TRUNCATED}`, maxBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one complete terminal acknowledgement while preserving UTF-8 cuts.
|
||||
* @param text - complete acknowledgement text.
|
||||
* @param maxBytes - positive final result cap.
|
||||
* @returns bounded text with a truncation marker when it fits.
|
||||
*/
|
||||
export function boundTerminalText(text: string, maxBytes: number): string {
|
||||
if (byteLength(text) <= maxBytes) return text
|
||||
const markerBytes = byteLength(TRUNCATED)
|
||||
if (markerBytes >= maxBytes) return retain(TRUNCATED, maxBytes, 'tail')
|
||||
return `${retain(text, maxBytes - markerBytes, 'head')}${TRUNCATED}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one created session and its bounded MOTD.
|
||||
* @param result - published spawn result.
|
||||
* @param maxBytes - complete UTF-8 result cap.
|
||||
* @returns Model-facing session acknowledgement.
|
||||
*/
|
||||
export function renderSpawn(result: RenderedSpawnResult): string {
|
||||
export function renderSpawn(result: RenderedSpawnResult, maxBytes: number): string {
|
||||
const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})`
|
||||
return `started terminal session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}`
|
||||
const prefix = `started terminal session ${label} [type: ${result.type}]\n`
|
||||
const motd = result.motd || '(no startup output)'
|
||||
const complete = `${prefix}${motd}`
|
||||
return byteLength(complete) <= maxBytes ? complete : fitWithPrefix(prefix, motd, maxBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one settled interactive send.
|
||||
* @param result - settled send outcome.
|
||||
* @param maxBytes - complete UTF-8 result cap.
|
||||
* @returns Terminal output plus wait/session markers.
|
||||
*/
|
||||
export function renderSend(result: RenderedSendResult): string {
|
||||
export function renderSend(result: RenderedSendResult, maxBytes: number): string {
|
||||
const output = result.viewport || '(no new output)'
|
||||
const status = result.sessionStatus.kind === 'running'
|
||||
? 'running'
|
||||
: `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}`
|
||||
return `${output}\n[wait: ${result.waitReason}]\n[session: ${status}]${result.truncated ? '\n[output truncated]' : ''}`
|
||||
return boundBodyWithSuffix(
|
||||
output,
|
||||
`\n[wait: ${result.waitReason}]\n[session: ${status}]`,
|
||||
result.truncated,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one incremental background operation read.
|
||||
* @param read - consuming operation delta.
|
||||
* @returns Delta plus truncation marker when needed.
|
||||
* @returns Delta plus its upstream truncation marker. The generic task control
|
||||
* applies the producer's complete-result cap after adding task status.
|
||||
*/
|
||||
export function renderSendRead(read: RenderedSendRead): string {
|
||||
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
|
||||
const separator = read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'
|
||||
return `${read.delta}${read.truncated ? `${separator}[output truncated]` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one bounded historical page.
|
||||
* @param result - retained scrollback page.
|
||||
* @param maxBytes - complete UTF-8 result cap.
|
||||
* @returns Page text plus pagination and truncation markers.
|
||||
*/
|
||||
export function renderRead(result: RenderedReadResult): string {
|
||||
export function renderRead(result: RenderedReadResult, maxBytes: number): string {
|
||||
const output = result.text || '(no retained output)'
|
||||
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
|
||||
return boundBodyWithSuffix(
|
||||
output,
|
||||
`\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]`,
|
||||
result.truncated,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render owner-visible live sessions.
|
||||
* @param sessions - fresh owner-scoped snapshots.
|
||||
* @param maxBytes - complete UTF-8 result cap.
|
||||
* @returns One line per session or the empty marker.
|
||||
*/
|
||||
export function renderList(sessions: readonly RenderedSessionSnapshot[]): string {
|
||||
export function renderList(sessions: readonly RenderedSessionSnapshot[], maxBytes: number): string {
|
||||
if (sessions.length === 0) return '(no terminal sessions)'
|
||||
return sessions.map((session) => {
|
||||
const text = sessions.map((session) => {
|
||||
const name = session.name === undefined ? '' : ` (${session.name})`
|
||||
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
|
||||
const status = session.status.kind === 'running'
|
||||
@@ -101,4 +173,5 @@ export function renderList(sessions: readonly RenderedSessionSnapshot[]): string
|
||||
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
|
||||
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
|
||||
}).join('\n')
|
||||
return boundBodyWithSuffix(text, '', false, maxBytes)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts'
|
||||
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts'
|
||||
|
||||
describe('tool-pty rendering', () => {
|
||||
it('renders spawn with and without names or MOTD', () => {
|
||||
expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }))
|
||||
expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }, 1024))
|
||||
.toBe('started terminal session pty-1 [type: shell]\n(no startup output)')
|
||||
expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }))
|
||||
expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }, 1024))
|
||||
.toContain('pty-2 (main)')
|
||||
})
|
||||
|
||||
it('renders running, exited, empty, and truncated sends', () => {
|
||||
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }))
|
||||
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }, 1024))
|
||||
.toBe('(no new output)\n[wait: timeout]\n[session: running]\n[output truncated]')
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }))
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }, 1024))
|
||||
.toContain('exited code=null signal=SIGTERM')
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }))
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }, 1024))
|
||||
.toContain('exited code=2 signal=null')
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }))
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }, 1024))
|
||||
.toContain('exited code=null signal=null')
|
||||
expect(renderSendRead({ delta: '', truncated: true })).toBe('[output truncated]')
|
||||
expect(renderSendRead({ delta: 'x', truncated: true })).toBe('x\n[output truncated]')
|
||||
@@ -26,14 +26,48 @@ describe('tool-pty rendering', () => {
|
||||
})
|
||||
|
||||
it('renders history and every list status shape', () => {
|
||||
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }))
|
||||
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }, 1024))
|
||||
.toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]')
|
||||
expect(renderList([])).toBe('(no terminal sessions)')
|
||||
expect(renderList([], 1024)).toBe('(no terminal sessions)')
|
||||
expect(renderList([
|
||||
{ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' } },
|
||||
{ sessionId: PtySessionId('pty-2'), name: 'done', type: 'shell', pid: 9, status: { kind: 'exited', exitCode: 2, signal: null } },
|
||||
{ sessionId: PtySessionId('pty-3'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: 'SIGTERM' } },
|
||||
{ sessionId: PtySessionId('pty-4'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: null } },
|
||||
])).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
|
||||
], 1024)).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
|
||||
})
|
||||
|
||||
it('bounds complete UTF-8 results while retaining terminal metadata when it fits', () => {
|
||||
const send = renderSend({
|
||||
viewport: `prefix-${'界'.repeat(40)}`,
|
||||
waitReason: 'stdin_read',
|
||||
sessionStatus: { kind: 'running' },
|
||||
truncated: false,
|
||||
}, 64)
|
||||
expect(Buffer.byteLength(send)).toBeLessThanOrEqual(64)
|
||||
expect(send).toContain('[wait: stdin_read]')
|
||||
expect(send).toContain('[output truncated]')
|
||||
|
||||
const read = renderRead({
|
||||
text: 'x'.repeat(200), totalLines: 20, lineBegin: 0, lineEnd: 10, truncated: false,
|
||||
}, 48)
|
||||
expect(Buffer.byteLength(read)).toBeLessThanOrEqual(48)
|
||||
expect(read).toContain('[lines: 0-10 of 20]')
|
||||
|
||||
expect(Buffer.byteLength(renderSpawn({
|
||||
sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200),
|
||||
}, 32))).toBeLessThanOrEqual(32)
|
||||
|
||||
const boundedSpawn = renderSpawn({
|
||||
sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200),
|
||||
}, 96)
|
||||
expect(boundedSpawn).toContain('started terminal session pty-1')
|
||||
expect(boundedSpawn).toContain('[output truncated]')
|
||||
|
||||
expect(Buffer.byteLength(renderSend({
|
||||
viewport: 'x'.repeat(200), waitReason: 'stdin_read', sessionStatus: { kind: 'running' }, truncated: false,
|
||||
}, 8))).toBeLessThanOrEqual(8)
|
||||
expect(boundTerminalText('x'.repeat(200), 8)).toHaveLength(8)
|
||||
expect(boundTerminalText('x'.repeat(200), 32).endsWith('[output truncated]')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,20 +32,23 @@ class StubSession implements PtyBackendSession {
|
||||
autoSettle = true
|
||||
rejectOperation = false
|
||||
closeGate: PromiseWithResolvers<undefined> | undefined
|
||||
viewport = 'command output'
|
||||
delta = 'live output'
|
||||
deltaTruncated = false
|
||||
|
||||
startSend(_request: PtySendRequest): PtySendOperation {
|
||||
let settle!: () => void
|
||||
let reject!: (error: unknown) => void
|
||||
let cancelled = false
|
||||
const done = new Promise<void>((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise }).then(() => ({
|
||||
viewport: cancelled ? '^C' : 'command output',
|
||||
viewport: cancelled ? '^C' : this.viewport,
|
||||
waitReason: 'stdin_read' as const,
|
||||
sessionStatus: this.statusValue,
|
||||
truncated: false,
|
||||
}))
|
||||
const operation: PtySendOperation = {
|
||||
done,
|
||||
readOutput: () => ({ delta: 'live output', truncated: false }),
|
||||
readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }),
|
||||
cancel: () => {
|
||||
if (cancelled) return false
|
||||
cancelled = true
|
||||
@@ -88,7 +91,13 @@ function stubBackend() {
|
||||
return { backend, sessions }
|
||||
}
|
||||
|
||||
async function setup(tasks: boolean) {
|
||||
async function setup(tasks: boolean, config: ToolPty.Config = {}) {
|
||||
const base = await setupBase(tasks)
|
||||
await base.ctx.plugin(ToolPty, config)
|
||||
return base
|
||||
}
|
||||
|
||||
async function setupBase(tasks: boolean) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -100,7 +109,6 @@ async function setup(tasks: boolean) {
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
}
|
||||
await ctx.plugin(ToolPty)
|
||||
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
|
||||
}
|
||||
|
||||
@@ -291,6 +299,101 @@ describe('tool-pty foreground surface', () => {
|
||||
expect(ctx.tools.get('terminal_close')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Close terminal pty-1' })
|
||||
expect(ctx.tools.get('terminal_list')?.presentCall?.({})).toMatchObject({ card: 'generic', title: 'List terminal sessions' })
|
||||
})
|
||||
|
||||
it('configuration-gates background sends and validates the final result bound', async () => {
|
||||
const disabled = await setup(true, { enableRunInBackground: false })
|
||||
const definition = disabled.ctx.tools.get('terminal_send')
|
||||
expect(definition?.parameters).not.toHaveProperty('properties.run_in_background')
|
||||
expect(definition?.description).not.toContain('Background mode')
|
||||
await call(disabled.ctx, 'terminal_open', { type: 'stub' }, disabled.agent)
|
||||
expect((await call(disabled.ctx, 'terminal_send', {
|
||||
sessionId: 'pty-1', text: 'work', run_in_background: true,
|
||||
}, disabled.agent)).isError).toBe(true)
|
||||
|
||||
const defaults = await setupBase(false)
|
||||
ToolPty.apply(defaults.ctx)
|
||||
expect(defaults.ctx.tools.get('terminal_send')?.parameters).toHaveProperty('properties.run_in_background')
|
||||
|
||||
const invalid = await setupBase(false)
|
||||
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes')
|
||||
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 63 }) }).toThrow('at least 64')
|
||||
})
|
||||
|
||||
it('bounds normalized errors and preserves allocated ids at the minimum result cap', async () => {
|
||||
const { ctx, agent } = await setup(true, { maxResultBytes: 64 })
|
||||
const failed = await call(ctx, 'terminal_open', { type: 'x'.repeat(1_000) }, agent)
|
||||
expect(failed.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(failed))).toBeLessThanOrEqual(64)
|
||||
expect(text(failed)).toContain('[output truncated]')
|
||||
|
||||
const opened = await call(ctx, 'terminal_open', { type: 'stub', name: 'n'.repeat(1_000) }, agent)
|
||||
expect(text(opened)).toContain('pty-1')
|
||||
expect(Buffer.byteLength(text(opened))).toBeLessThanOrEqual(64)
|
||||
const background = await call(ctx, 'terminal_send', {
|
||||
sessionId: 'pty-1', text: 'work', run_in_background: true,
|
||||
}, agent)
|
||||
expect(text(background)).toContain('pty-send-1')
|
||||
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
|
||||
})
|
||||
|
||||
it('bounds terminal results after policy decisions and pipeline failures', async () => {
|
||||
const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
if (exec.name === 'terminal_list') return { kind: 'deny', reason: 'd'.repeat(1_000) }
|
||||
if (exec.name === 'terminal_signal') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
if (exec.name === 'terminal_close') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
||||
if (exec.name === 'terminal_open') {
|
||||
return { kind: 'accept', content: [{ type: 'text', text: 'a'.repeat(1_000) }] }
|
||||
}
|
||||
if (exec.name === 'terminal_read') {
|
||||
return { kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] }
|
||||
}
|
||||
if (exec.name === 'terminal_send') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
|
||||
const denied = await call(ctx, 'terminal_list', {}, agent)
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
|
||||
expect(text(denied)).toContain('[output truncated]')
|
||||
|
||||
const replaced = await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
expect(replaced.isError).toBe(false)
|
||||
expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
|
||||
expect(text(replaced)).toContain('[output truncated]')
|
||||
|
||||
const blocked = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent)
|
||||
expect(blocked.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
|
||||
expect(text(blocked)).toContain('[output truncated]')
|
||||
|
||||
const failures = [
|
||||
await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent),
|
||||
await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent),
|
||||
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'work' }, agent),
|
||||
]
|
||||
for (const failure of failures) {
|
||||
expect(failure.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64)
|
||||
expect(text(failure)).toContain('[output truncated]')
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a structured around-dispatch failure unchanged', async () => {
|
||||
const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
|
||||
ctx.on('tools/execute', async (exec, next) => exec.name === 'terminal_list'
|
||||
? { content: [], isError: true, error: { message: 'structured failure' } }
|
||||
: next())
|
||||
const result = await call(ctx, 'terminal_list', {}, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pty task integration', () => {
|
||||
@@ -305,6 +408,23 @@ describe('tool-pty task integration', () => {
|
||||
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
|
||||
})
|
||||
|
||||
it('bounds foreground and background results after terminal and task metadata', async () => {
|
||||
const { ctx, agent, stub } = await setup(true, { maxResultBytes: 64 })
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
stub.sessions[0]!.viewport = '界'.repeat(100)
|
||||
const foreground = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'foreground' }, agent)
|
||||
expect(Buffer.byteLength(text(foreground))).toBeLessThanOrEqual(64)
|
||||
|
||||
stub.sessions[0]!.delta = '界'.repeat(100)
|
||||
stub.sessions[0]!.deltaTruncated = true
|
||||
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent)
|
||||
const background = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
|
||||
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
|
||||
expect(text(background)).toContain('[status: completed')
|
||||
expect(text(background).match(/\[output truncated\]/g)).toHaveLength(1)
|
||||
expect(text(background)).toContain('[output truncated]\n[status: completed')
|
||||
})
|
||||
|
||||
it('rejects pre-aborted background calls, maps task cancellation, and contains operation failure', async () => {
|
||||
const { ctx, agent, stub } = await setup(true)
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/retention"
|
||||
},
|
||||
{
|
||||
"path": "../pty"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user