From 6ce8654ff6be16e2a453aec3cd62cd75e24a9c37 Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 8 Oct 2026 16:30:21 +0800 Subject: [PATCH] fix(terminal): retain background job ids under small result caps --- .../experimental/tool-terminal/src/index.ts | 11 ++++- .../tool-terminal/tests/tools.spec.ts | 42 +++++++++++++------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/experimental/tool-terminal/src/index.ts b/packages/experimental/tool-terminal/src/index.ts index fcc93ec686..b070b5ef40 100644 --- a/packages/experimental/tool-terminal/src/index.ts +++ b/packages/experimental/tool-terminal/src/index.ts @@ -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' diff --git a/packages/experimental/tool-terminal/tests/tools.spec.ts b/packages/experimental/tool-terminal/tests/tools.spec.ts index 8f6f3a5897..2039871876 100644 --- a/packages/experimental/tool-terminal/tests/tools.spec.ts +++ b/packages/experimental/tool-terminal/tests/tools.spec.ts @@ -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>): 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') }) })