From 993f7aaacd391c6750c98da1421fdee4965535ad Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:26:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(terminal):=20terminal=5Fwait=5Ffor=20?= =?UTF-8?q?=E5=A2=9E=20skipped=20=E7=BB=93=E6=9E=9C=E4=B8=8E=20agent-pty.s?= =?UTF-8?q?kip-wait=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 9 +++++++++ src/tools.ts | 16 ++++++++++++++-- tests/tools.spec.ts | 28 +++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4638174..37f2c15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -543,6 +543,15 @@ function buildApi( agentPtyRegistry?.close(uuid) return { ok: true } }, + // The sidebar wait banner's skip button: abort every active + // terminal_wait_for on one agent terminal. Idempotent — 0 when nothing + // is waiting (a stale banner racing a wait that already resolved). + // Degraded mode (node-pty unavailable) has no registry and no waits: an + // honest ok. + 'agent-pty.skip-wait': (payload) => { + const uuid = requireString(payload, 'uuid') + return { ok: true, skipped: agentPtyRegistry?.skipWait(uuid) ?? 0 } + }, // Terminal dependency status (issue #140): after a WS close 1011 with // reason `pty-deps-missing` the client fetches the full repair details // here — the close reason itself is capped at 123 bytes, too small for diff --git a/src/tools.ts b/src/tools.ts index 39632ab..7c96b45 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -296,7 +296,8 @@ export function registerTools( + 'The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. ' + 'Returns `found` with the line/column and the matched text, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. ' + 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). ' - + 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.', + + 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately. ' + + 'The user can skip the wait from the sidebar ( a banner on the terminal\'s tab shows the needle and a skip button ) — the tool then returns `skipped`.', parameters: { uuid: { type: 'string', @@ -349,10 +350,18 @@ export function registerTools( exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' }, }, }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'skipped' }, + needle: { type: 'string', required: true }, + }, + }, ], }, render: (_args, value) => { - const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; match?: string; exitCode?: number | null; exitSignal?: string | null } + const v = value as { kind: 'found' | 'timeout' | 'exited' | 'skipped'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; match?: string; exitCode?: number | null; exitSignal?: string | null } if (v.kind === 'found') { const matched = v.match !== undefined && v.match !== '' ? `, matched "${v.match}"` : '' return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column}${matched} (after ${v.elapsedMs}ms).` }] @@ -360,6 +369,9 @@ export function registerTools( if (v.kind === 'timeout') { return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }] } + if (v.kind === 'skipped') { + return [{ type: 'text', text: `Skipped by user while waiting for "${v.needle}" — the wait ended early. Call terminal_read to inspect the transcript and decide how to proceed.` }] + } const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : '' return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }] }, diff --git a/tests/tools.spec.ts b/tests/tools.spec.ts index 80791a7..197f5b4 100644 --- a/tests/tools.spec.ts +++ b/tests/tools.spec.ts @@ -22,6 +22,9 @@ interface CapturedTool { class FakeRegistry { readonly terminals = new Map() + /** When set, waitFor resolves with the skipped shape instead of the found default. */ + nextWaitResult: { kind: 'skipped' } | undefined = undefined + create(sessionId: string, title: string, command: string): string { const uuid = `uuid-${this.terminals.size + 1}` this.terminals.set(uuid, { sessionId, title, command, exited: false }) @@ -58,10 +61,14 @@ class FakeRegistry { return true } - waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; match: string; elapsedMs: number }> { + waitFor(_uuid: string, needle: string): Promise< + { kind: 'found'; needle: string; line: number; column: number; match: string; elapsedMs: number } + | { kind: 'skipped'; needle: string } + > { // Mirrors the registry's empty-needle rejection (the tool layer no // longer duplicates this check). if (needle === '') return Promise.reject(new Error('needle must be a non-empty string')) + if (this.nextWaitResult !== undefined) return Promise.resolve({ kind: 'skipped', needle }) return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, match: needle, elapsedMs: 1 }) } } @@ -193,6 +200,25 @@ describe('agent terminal tools', () => { expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) }) + it('terminal_wait_for returns skipped (schema-valid) when the registry reports a user skip', async () => { + const { captured, registry } = mount() + const tool = toolOf(captured, 'terminal_wait_for') + registry.create('s1', 'skipper', '') + registry.nextWaitResult = { kind: 'skipped' } + const value = await tool.execute({ uuid: 'uuid-1', needle: 'BUILD_OK', timeout_ms: 1000 }, exec('s1')) + expect(value).toEqual({ kind: 'skipped', needle: 'BUILD_OK' }) + expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) + }) + + it('terminal_wait_for render describes the user skip and the needle', () => { + const { captured } = mount() + const tool = toolOf(captured, 'terminal_wait_for') + const blocks = tool.output.render({}, { kind: 'skipped', needle: 'BUILD_OK' }) as Array<{ type: string; text: string }> + expect(blocks[0]!.type).toBe('text') + expect(blocks[0]!.text).toContain('Skipped by user') + expect(blocks[0]!.text).toContain('BUILD_OK') + }) + it('terminal_wait_for rejects an empty needle through the registry (no duplicate check)', async () => { const { captured, registry } = mount() const tool = toolOf(captured, 'terminal_wait_for')