fix(terminal): retain background job ids under small result caps

This commit is contained in:
fz committed 2026-10-08 16:30:21 +08:00
1 parent 54c13688a8
commit 6ce8654ff6
2 files changed
+38 -15

No files matched your search

@@ -31,7 +31,7 @@ export const inject = ['terminals', 'tools', 'systemPrompt', 'workingDirectory']
/** 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 job id in its creation acknowledgement. */
/** Smallest cap that preserves registry-issued PTY and job ids in creation acknowledgements. */
export const MIN_MAX_RESULT_BYTES = 64
/** Model-facing terminal tool configuration. */
@@ -145,6 +145,13 @@ function sendDetail(result: TerminalSendResult): string {
: `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
}
function renderBackgroundJobId(jobId: string, maxBytes: number): string {
const acknowledgement = `started background job ${jobId}`
return Buffer.byteLength(acknowledgement) <= maxBytes
? acknowledgement
: `started job ${jobId}`
}
/** Register all terminal tools and the minimal usage guidance. */
export function apply(ctx: Context, config: Config = {}): void {
const enableRunInBackground = config.enableRunInBackground ?? true
@@ -236,7 +243,7 @@ export function apply(ctx: Context, config: Config = {}): void {
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background job ${value.jobId}`
? renderBackgroundJobId(value.jobId, maxResultBytes)
: renderSend(value, maxResultBytes),
}],
presentationMeta: (_args, value) => value.kind === 'foreground'
@@ -140,6 +140,16 @@ function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
function backgroundJobId(result: Awaited<ReturnType<typeof call>>): string {
if (!('value' in result)) throw new Error('missing background job result')
const value = result.value
if (typeof value !== 'object' || value === null || !('jobId' in value) || typeof value.jobId !== 'string') {
throw new Error('missing background job id')
}
expect(value.jobId).toMatch(/^pty-send-[0-9a-f-]{36}$/)
return value.jobId
}
describe('tool-terminal foreground API', () => {
it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => {
const { ctx, agent } = await setup(false)
@@ -345,7 +355,7 @@ describe('tool-terminal foreground API', () => {
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(text(background)).toBe(`started job ${backgroundJobId(background)}`)
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
})
@@ -427,9 +437,10 @@ describe('tool-terminal task integration', () => {
const { ctx, agent } = await setup(true)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent)
expect(text(started)).toBe('started background job pty-send-1')
expect(started).toMatchObject({ isError: false, value: { kind: 'background', jobId: 'pty-send-1' } })
const output = await call(ctx, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
const jobId = backgroundJobId(started)
expect(text(started)).toBe(`started background job ${jobId}`)
expect(started).toMatchObject({ isError: false, value: { kind: 'background', jobId } })
const output = await call(ctx, 'job_output', { job_id: jobId, wait: true }, agent)
expect(text(output)).toContain('live output')
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
})
@@ -443,8 +454,8 @@ describe('tool-terminal task integration', () => {
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, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent)
const background = await call(ctx, 'job_output', { job_id: backgroundJobId(started), 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)
@@ -459,16 +470,21 @@ describe('tool-terminal task integration', () => {
expect((await callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'x', run_in_background: true }, agent, controller.signal)).isError).toBe(true)
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent))).toContain('pty-send-1')
expect(text(await call(ctx, 'job_kill', { job_id: 'pty-send-1' }, agent))).toContain('requested cancellation')
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent)
const jobId = backgroundJobId(started)
expect(text(started)).toContain(jobId)
expect(text(await call(ctx, 'job_kill', { job_id: jobId }, agent))).toContain('requested cancellation')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'job_output', { job_id: 'pty-send-1' }, agent))).toContain('[status: killed')
expect(text(await call(ctx, 'job_output', { job_id: jobId }, agent))).toContain('[status: killed')
stub.sessions[0]!.rejectOperation = true
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent))).toContain('pty-send-2')
const failed = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent)
const failedJobId = backgroundJobId(failed)
expect(failedJobId).not.toBe(jobId)
expect(text(failed)).toContain(failedJobId)
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'job_output', { job_id: 'pty-send-2' }, agent))).toContain('[status: failed')
expect(text(await call(ctx, 'job_output', { job_id: failedJobId }, agent))).toContain('[status: failed')
})
it('reports foreground cancellation after the terminal operation settles', async () => {
@@ -500,8 +516,8 @@ describe('tool-terminal task integration', () => {
const { ctx, agent, stub } = await setup(true)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
stub.sessions[0]!.statusValue = { kind: 'exited', exitCode: null, signal: null }
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
const output = await call(ctx, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
const output = await call(ctx, 'job_output', { job_id: backgroundJobId(started), wait: true }, agent)
expect(text(output)).toContain('session exited: unknown')
})
})