feat(terminal): terminal_wait_for 增 skipped 结果与 agent-pty.skip-wait 路由
This commit is contained in:
@@ -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
|
||||
|
||||
+14
-2
@@ -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}.` }]
|
||||
},
|
||||
|
||||
+27
-1
@@ -22,6 +22,9 @@ interface CapturedTool {
|
||||
class FakeRegistry {
|
||||
readonly terminals = new Map<string, { sessionId: string; title: string; command: string; exited: boolean }>()
|
||||
|
||||
/** 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')
|
||||
|
||||
Reference in New Issue
Block a user