feat(terminal): registry 支持等待登记、skipWait 与 skipped 结果

This commit is contained in:
HuanLinOTO
2026-09-09 13:20:08 +08:00
parent 2448068369
commit fb2afabc72
2 changed files with 147 additions and 20 deletions
+82 -20
View File
@@ -134,6 +134,13 @@ export interface AgentTerminalSnapshot {
exitCode?: number | null
/** Exit signal name if the process was killed by a signal; null otherwise. */
exitSignal?: string | null
/**
* The model's active `terminal_wait_for` on this terminal (the sidebar
* renders the wait banner from it). Present only while a wait is
* registered; carries the LATEST wait when several overlap.
*/
waiting?: { needle: string; since: number }
}
/** Map a POSIX signal number to its conventional name (best-effort). */
@@ -223,6 +230,8 @@ export interface AgentTerminalHandle {
exitCode?: number | null
/** Exit signal number once known (POSIX only; undefined on Windows). */
exitSignal?: number | null
/** Active wait_for registrations (skip bookkeeping; empty while idle). */
waits: AgentTerminalActiveWait[]
}
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
@@ -237,6 +246,16 @@ export interface AgentTerminalReadResult {
lineEnd: number
}
/** One active wait_for registration on a handle (banner + skip bookkeeping). */
export interface AgentTerminalActiveWait {
/** The needle being awaited (shown on the sidebar wait banner). */
needle: string
/** Epoch ms when the wait registered (age display / debugging). */
since: number
/** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
skipped: boolean
}
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
export type AgentTerminalWaitResult =
| {
@@ -276,6 +295,12 @@ export type AgentTerminalWaitResult =
/** The exit signal name, if the process was killed by a signal. */
exitSignal?: string | null
}
| {
/** The user skipped the wait from the sidebar banner. */
kind: 'skipped'
/** The needle that was awaited. */
needle: string
}
/** Snapshot projection of a handle (drops the pty reference and transcript). */
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
@@ -289,6 +314,8 @@ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
out.exitCode = handle.exitCode ?? null
out.exitSignal = signalNameOf(handle.exitSignal)
}
const active = handle.waits.at(-1)
if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
return out
}
@@ -349,6 +376,7 @@ export class AgentPtyRegistry {
pty,
transcript: '',
exited: false,
waits: [],
}
pty.onData((data) => {
handle.transcript += data
@@ -503,7 +531,8 @@ export class AgentPtyRegistry {
* 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`.
* A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`.
* @returns one of `found` / `timeout` / `exited` / `skipped`.
*/
async waitFor(
uuid: string,
@@ -531,29 +560,62 @@ export class AgentPtyRegistry {
if (firstHit !== undefined) {
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
// and exited fields mutate as the pty produces output).
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
// Register the active wait so the sidebar can show the wait banner (the
// snapshot's `waiting` field rides the agent-terminals push). Registered
// only after the fast paths: a wait that resolves instantly never
// flashes the banner. The notify() makes every push subscriber (the
// sidebar view) converge on the new waiting state immediately.
const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
handle.waits.push(record)
this.notify()
try {
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
if (record.skipped) {
return { kind: 'skipped', needle }
}
const hit = locateNeedle(handle.transcript, needle, re)
if (hit !== undefined) {
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 }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
}
const hit = locateNeedle(handle.transcript, needle, re)
if (hit !== undefined) {
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 }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
} finally {
const index = handle.waits.indexOf(record)
if (index !== -1) handle.waits.splice(index, 1)
this.notify()
}
}
/**
* Mark every active wait on one terminal as skipped (the sidebar banner's
* skip button). Each waiting poll loop observes its record's flag within
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
* is waiting (a stale banner racing a wait that already resolved).
* @returns the number of waits that transitioned to skipped.
*/
skipWait(uuid: string): number {
const handle = this.expect(uuid)
let count = 0
for (const record of handle.waits) {
if (!record.skipped) {
record.skipped = true
count += 1
}
}
return count
}
/**
* Send a POSIX signal to a terminal's foreground process.
*
+65
View File
@@ -393,6 +393,71 @@ describe('AgentPtyRegistry', () => {
await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow()
})
it('waitFor returns skipped when the user skips from the sidebar', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'skip-test', 'echo skip-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'skip-ready')
// waitFor registers its record synchronously (before the first poll
// await), so the skip can fire immediately after the call.
const waitPromise = registry.waitFor(uuid, 'NEVER_APPEARS_XYZ', 30_000)
expect(await registry.skipWait(uuid)).toBe(1)
const result = await waitPromise
expect(result.kind).toBe('skipped')
if (result.kind === 'skipped') expect(result.needle).toBe('NEVER_APPEARS_XYZ')
// Idempotent: nothing left to skip once the wait resolved.
expect(registry.skipWait(uuid)).toBe(0)
} finally {
registry.disposeAll()
}
})
it('snapshot exposes waiting while a wait is active and clears after it ends', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'wait-snap', 'echo snap-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'snap-ready')
const waitPromise = registry.waitFor(uuid, 'LATER_MARK_9', 30_000)
// Registration happens synchronously before waitFor's first await.
expect(registry.list('s1')[0]?.waiting?.needle).toBe('LATER_MARK_9')
expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number')
expect(registry.skipWait(uuid)).toBe(1)
const result = await waitPromise
expect(result.kind).toBe('skipped')
expect(registry.list('s1')[0]?.waiting).toBeUndefined()
} finally {
registry.disposeAll()
}
})
it('fires change listeners when a wait starts and ends', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'watched-wait', 'echo notify-ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'notify-ready')
let changes = 0
const unsubscribe = registry.subscribe(() => { changes += 1 })
const waitPromise = registry.waitFor(uuid, 'NEVER_NOTIFY_1', 30_000)
const afterStart = changes
expect(afterStart).toBeGreaterThanOrEqual(1)
expect(await registry.skipWait(uuid)).toBe(1)
expect(await waitPromise).toEqual({ kind: 'skipped', needle: 'NEVER_NOTIFY_1' })
expect(changes).toBeGreaterThan(afterStart)
unsubscribe()
} finally {
registry.disposeAll()
}
})
it('skipWait rejects an unknown uuid with not-found', () => {
const registry = new AgentPtyRegistry(testShell())
try {
expect(() => registry.skipWait('missing-uuid')).toThrow(/not found/)
} finally {
registry.disposeAll()
}
})
it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => {
const registry = new AgentPtyRegistry(testShell())
try {