diff --git a/src/agent-pty.ts b/src/agent-pty.ts index 561bfc2..ffdaee6 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -149,22 +149,56 @@ function signalNameOf(signal: number | null | undefined): string | null { return SIGNAL_NAMES[signal] ?? `signal ${signal}` } -/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */ -function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined { +/** + * Compile a wait needle into a RegExp. The needle is treated as a JavaScript + * regular expression; a pattern that fails to compile ( e.g. an unbalanced + * group typed as a literal ) degrades to verbatim substring matching so + * legacy literal needles keep working. + */ +function compileNeedle(needle: string): RegExp | null { + try { + return new RegExp(needle) + } catch { + return null + } +} + +/** + * Locate the first occurrence of `needle` in `transcript`, returning its + * line/column plus the actual matched text — for alternation patterns + * ( e.g. `(BUILD_OK|BUILD_FAIL)` ) the match tells which alternative hit. + * `re` is the precompiled form of `needle` (from {@link compileNeedle}); + * `null` means verbatim substring matching. + */ +function locateNeedle( + transcript: string, + needle: string, + re: RegExp | null, +): { line: number; column: number; match: string } | undefined { if (needle === '') return undefined - const idx = transcript.indexOf(needle) - if (idx === -1) return undefined + // Regex hit → use the match index/text; literal fallback → indexOf with + // the needle itself as the matched text. + let hit: { index: number; text: string } | undefined + if (re !== null) { + re.lastIndex = 0 + const m = re.exec(transcript) + hit = m === null ? undefined : { index: m.index, text: m[0] } + } else { + const idx = transcript.indexOf(needle) + hit = idx === -1 ? undefined : { index: idx, text: needle } + } + if (hit === undefined) return undefined // Walk the transcript up to the match index, counting newlines to derive // the 0-based line; the column is the offset within that line. let line = 0 let lineStart = 0 - for (let i = 0; i < idx; i += 1) { + for (let i = 0; i < hit.index; i += 1) { if (transcript.charCodeAt(i) === 0x0a /* \n */) { line += 1 lineStart = i + 1 } } - return { line, column: idx - lineStart } + return { line, column: hit.index - lineStart, match: hit.text } } /** One live agent terminal. */ @@ -208,12 +242,17 @@ export type AgentTerminalWaitResult = | { /** The needle was found in the transcript. */ kind: 'found' - /** The matched substring. */ + /** The pattern that was awaited. */ needle: string /** 0-based line index (in the retained transcript) where the needle first appeared. */ line: number /** 0-based column index within that line where the match starts. */ column: number + /** + * The text that actually matched — for multi-outcome patterns + * ( e.g. `(BUILD_OK|BUILD_FAIL)` ) this tells which alternative matched. + */ + match: string /** Elapsed wall-clock milliseconds from the wait start to the match. */ elapsedMs: number } @@ -456,7 +495,12 @@ export class AgentPtyRegistry { * make event-driven wakeups unreliable. A 50ms poll is fast enough for * interactive use and simple enough to be obviously correct. * @param uuid - terminal to watch. - * @param needle - substring to search for (case-sensitive, verbatim). + * @param needle - JavaScript regular expression to search for + * (case-sensitive); a pattern that fails to compile falls back to + * verbatim substring matching. May cover several outcomes at once + * ( e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure ) — the + * returned `match` reports the text that actually matched, so callers + * can tell which outcome hit. * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms. * @param signal - caller-owned cancellation; aborts the wait re-throwing. * @returns one of `found` / `timeout` / `exited`. @@ -474,15 +518,18 @@ export class AgentPtyRegistry { const timeout = Math.max(100, Math.floor(timeoutMs)) const start = Date.now() const deadline = start + timeout + // Compile the needle once (regex semantics; invalid patterns degrade to + // verbatim matching) and reuse the compiled form across every poll. + const re = compileNeedle(needle) // Fast path: already exited, or the needle is already in the transcript // (a `terminal_send` may have produced the expected output before this // call even started). if (handle.exited) { return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } } - const firstHit = locateNeedle(handle.transcript, needle) + const firstHit = locateNeedle(handle.transcript, needle, re) if (firstHit !== undefined) { - return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start } + return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start } } // Poll loop: check the transcript every 50ms, exit on match / exit / // abort / timeout. The handle is read live each iteration (its transcript @@ -492,9 +539,9 @@ export class AgentPtyRegistry { if (handle.exited) { return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } } - const hit = locateNeedle(handle.transcript, needle) + const hit = locateNeedle(handle.transcript, needle, re) if (hit !== undefined) { - return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start } + return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start } } if (Date.now() >= deadline) { return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length } diff --git a/src/tools.ts b/src/tools.ts index d16bb38..39632ab 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -287,11 +287,14 @@ export function registerTools( register(defineTool({ name: 'terminal_wait_for', description: - 'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. ' + 'Block until a pattern appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. ' + 'Use this to synchronize on command completion cues ( e.g. a shell prompt, "done", "Listening on", "Build successful" ) ' + 'without busy-polling terminal_read. ' + + 'The needle is a JavaScript regular expression ( a pattern that fails to compile falls back to verbatim substring matching ). ' + + 'One needle may cover MULTIPLE outcomes — e.g. wait on `(BUILD_OK|BUILD_FAIL)` or `Build (succeeded|failed)` returns as soon as EITHER marker appears, ' + + 'and the found result\'s `match` field tells which alternative hit ( build success vs failure ). ' + '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 of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. ' + + '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.', parameters: { @@ -303,7 +306,8 @@ export function registerTools( needle: { type: 'string', required: true, - description: 'Substring to wait for (case-sensitive, verbatim). Must be non-empty.', + description: 'JavaScript regular expression to wait for (case-sensitive); a pattern that fails to compile falls back to verbatim substring matching. ' + + 'May cover several outcomes in one wait ( e.g. `(BUILD_OK|BUILD_FAIL)` for build success/failure ) — check `match` in the found result to see which one hit. Must be non-empty.', }, timeout_ms: { type: 'number', @@ -321,6 +325,7 @@ export function registerTools( needle: { type: 'string', required: true }, line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' }, column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' }, + match: { type: 'string', required: true, description: 'The text that actually matched — for multi-outcome patterns ( e.g. `(BUILD_OK|BUILD_FAIL)` ) this tells which alternative matched.' }, elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' }, }, }, @@ -347,9 +352,10 @@ export function registerTools( ], }, render: (_args, value) => { - const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null } + 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 } if (v.kind === 'found') { - return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }] + 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).` }] } 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.` }] diff --git a/tests/agent-pty.spec.ts b/tests/agent-pty.spec.ts index 7b69a18..b5b67ec 100644 --- a/tests/agent-pty.spec.ts +++ b/tests/agent-pty.spec.ts @@ -281,6 +281,57 @@ describe('AgentPtyRegistry', () => { } }) + it('waitFor supports regex alternation and reports which alternative matched', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + // Multi-outcome pattern (build success/failure): the terminal prints + // only the FAILURE marker; the alternation must match immediately and + // the `match` field must tell WHICH alternative hit. + const uuid = registry.create('s1', 'multi-outcome', 'echo BUILD_FAIL', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, 'BUILD_FAIL') + const result = await registry.waitFor(uuid, 'BUILD_(OK|FAIL)', 2000) + expect(result.kind).toBe('found') + if (result.kind === 'found') { + expect(result.needle).toBe('BUILD_(OK|FAIL)') + expect(result.match).toBe('BUILD_FAIL') + } + } finally { + registry.disposeAll() + } + }) + + it('waitFor falls back to verbatim matching when the pattern is not a valid regex', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + // `BUILD_(OK` does not compile as a regex (unbalanced group); the wait + // must degrade to literal substring matching and still find it. + const uuid = registry.create('s1', 'bad-regex', 'echo "BUILD_(OK literal"', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, 'BUILD_(OK') + const result = await registry.waitFor(uuid, 'BUILD_(OK', 2000) + expect(result.kind).toBe('found') + if (result.kind === 'found') { + expect(result.match).toBe('BUILD_(OK') + } + } finally { + registry.disposeAll() + } + }) + + it('waitFor regex matches text a plain substring could not express', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'regex-version', 'echo version v1.2.3 ready', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, 'v1.2.3') + const result = await registry.waitFor(uuid, String.raw`v\d+\.\d+\.\d+`, 2000) + expect(result.kind).toBe('found') + if (result.kind === 'found') { + expect(result.match).toBe('v1.2.3') + } + } finally { + registry.disposeAll() + } + }) + it('waitFor returns found after the needle appears (async output)', async () => { const registry = new AgentPtyRegistry(testShell()) try { diff --git a/tests/tools.spec.ts b/tests/tools.spec.ts index 865235e..80791a7 100644 --- a/tests/tools.spec.ts +++ b/tests/tools.spec.ts @@ -58,11 +58,11 @@ class FakeRegistry { return true } - waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; elapsedMs: number }> { + waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; match: string; elapsedMs: number }> { // 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')) - return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, elapsedMs: 1 }) + return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, match: needle, elapsedMs: 1 }) } } @@ -189,7 +189,7 @@ describe('agent terminal tools', () => { const tool = toolOf(captured, 'terminal_wait_for') const uuid = registry.create('s1', 'waiter', '') const value = await tool.execute({ uuid, needle: 'done' }, exec('s1')) - expect(value).toEqual({ kind: 'found', needle: 'done', line: 0, column: 0, elapsedMs: 1 }) + expect(value).toEqual({ kind: 'found', needle: 'done', line: 0, column: 0, match: 'done', elapsedMs: 1 }) expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) })