feat(terminal): wait_for needle 支持 regex,found 返回实际匹配文本
needle 以 JavaScript 正则优先(编译失败回退字面匹配,旧字面 needle 完全兼容);found 结果新增 match 字段回报实际匹配文本——单个 needle 可覆盖多结局场景(如 (BUILD_OK|BUILD_FAIL) 同步构建成功/失败,match 区分命中分支);工具 description / needle 参数 / output schema / render 同步更新并写明多重匹配场景;新增 3 个 pty 级测试(alternation 命中分支、非法正则回退、元字符匹配);全量测试失败集合与干净 main 基线完全一致(27 个失败均为预存在 win32 非回归)
This commit is contained in:
+59
-12
@@ -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 }
|
||||
|
||||
+11
-5
@@ -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.` }]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user