feat(terminal): registry 支持等待登记、skipWait 与 skipped 结果
This commit is contained in:
+82
-20
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user