mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
Merge origin/main into brennanb2025/nc-fork-turn
This commit is contained in:
@@ -83,6 +83,88 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental until CI soak; investigate fidelity or count failures without relaxing the row budget."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.osc-status-scan-budget",
|
||||
"title": "OSC 9999 status bursts reuse forward terminator searches",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "shared-unit-and-runtime-unit",
|
||||
"surfaces": ["terminal output ingestion", "terminal agent-status side effects"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "Shared parser tests cover provider-independent bytes; main and renderer contract tests cover status and terminal-output delivery. Live Linux, Windows, WSL, SSH and remote-runtime processes are not launched. Execution, liveness, paths, wire formats and mobile UI are unchanged.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/blob/main/src/shared/agent-status-osc.ts"
|
||||
],
|
||||
"invariant": "Terminal status parsing preserves ordinary UTF-16 output, every valid payload in order, the last valid payload's clean-output offset, earliest BEL/ST termination, and incomplete-frame caps while searching each complete burst only forward.",
|
||||
"oracle": "Two 5,000-frame bursts using exclusively BEL or ST produce every expected payload and ordinary output byte with at most twice the input length in native search ranges. Mixed terminators, every split through prefixes/JSON/ST, independent parser interleaving, malformed payloads, exact pending-cap boundaries and oversized complete frames retain their previous behavior. A one-character echo performs no terminator search. Parsed output chunks are not retained in legacy regular-expression state.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-status-osc.test.ts src/shared/agent-status-osc-scan-budget.test.ts src/shared/agent-status-types.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/agent-status-osc.test.ts",
|
||||
"src/shared/agent-status-osc-scan-budget.test.ts",
|
||||
"src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/shared/agent-status-osc-scan-budget.test.ts",
|
||||
"assertions": [
|
||||
"reads each burst only forward with terminator %j",
|
||||
"keeps a one-character input echo on the ordinary-output path",
|
||||
"does not retain the output chunk in legacy regular-expression state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/agent-status-osc.test.ts",
|
||||
"assertions": [
|
||||
"uses the earliest mixed terminator and counts only parsed payload offsets",
|
||||
"keeps a distant ST usable after many intervening BEL frames",
|
||||
"preserves every split of prefixes, JSON, and both terminators across independent streams",
|
||||
"applies the pending cap only to incomplete frames"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-status-osc.test.ts src/shared/agent-status-osc-scan-budget.test.ts src/shared/agent-status-types.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 23.96,
|
||||
"summary": "153 tests passed across eight files. Independent baseline differential review also matched 3,704 streams and 45,141 chunk results."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "Shared parser and main/renderer terminal contract tests; no launched app."
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Initial deterministic local validation; CI soak has not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The unchanged parser failed both search budgets: 618,560,785 searched characters for the 246,390-character BEL burst and 631,068,285 for the 251,390-character ST burst. Reusing forward match positions reduces those totals to 492,770 and 502,770 characters respectively, within twice the input length, with identical complete results."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Warmed Node 24 macOS CPU medians: a 250 KB / 5,000-status burst fell from 100.240 ms to 1.903 ms; a 1 MB / 20,000-status burst fell from 1,588.492 ms to 7.369 ms. Wall-clock medians were 134.878 to 2.484 ms and 2,536.927 to 12.902 ms under concurrent machine load. These are adverse bursts, not typical callback sizes. The ordinary-output path is unchanged; one-character echo CPU was 2.173 versus 2.342 ms per 100,000 calls, and single-status BEL CPU was 10.835 versus 10.897 ms per 30,000 calls. Both native terminator searches advance monotonically within the current chunk; no regex state retains the input. No scheduling, polling, provider calls, pending limits, output filtering or payload parsing changed."
|
||||
},
|
||||
"knownGaps": [
|
||||
"Live Electron input latency and Linux/Windows/WSL/SSH execution have not been measured for this parser-only change.",
|
||||
"Fragmented unterminated payload accumulation and downstream processing of large status arrays remain outside this complete-burst search budget."
|
||||
],
|
||||
"promotionCriteria": [
|
||||
"Complete CI soak while preserving byte fidelity and deterministic search budgets."
|
||||
],
|
||||
"demotionRule": "Keep experimental until CI soak; investigate output, offset, carry or search-budget failures without relaxing the oracle."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.padded-fullscreen-redraw",
|
||||
"title": "Fullscreen redraw padding does not stall terminal delivery",
|
||||
|
||||
@@ -117,7 +117,7 @@ function blockContent(message: NativeChatMessage): string {
|
||||
if (block.type === 'tool-result') {
|
||||
return block.output
|
||||
}
|
||||
return block.path ?? block.url ?? block.alt ?? ''
|
||||
return block.type === 'image-ref' ? (block.path ?? block.url ?? block.alt ?? '') : block.groupId
|
||||
}
|
||||
|
||||
function messageWeight(message: NativeChatMessage, content: string): number {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Structured worktree status validation
|
||||
|
||||
Validated on September 7, 2026 in a background Electron dev instance of
|
||||
`pr19217-review-r2`, based on `ce1024096b` with the source-adapter refactor.
|
||||
CDP app identity confirmed the checkout; screenshots show the full hidden renderer.
|
||||
The command output is the real `orca worktree ps --json` response reduced to status,
|
||||
agent state, provider, and pane key for readability.
|
||||
|
||||
## Functional correctness
|
||||
|
||||
A real Codex structured session appeared as `working` in `worktree.ps` while the
|
||||
sidebar showed working. Closing its chat tab removed that exact session's row and
|
||||
returned the worktree to `active`. A different completed chat remained present,
|
||||
confirming that closure removed only the selected session.
|
||||
|
||||
- [Working: CLI and sidebar](working.png)
|
||||
- [Closed: CLI and sidebar](closed.png)
|
||||
|
||||
The disappearing session is `codex_40677067_f492_4d7d_86dd_ec566ede04c3`.
|
||||
The host's held-session roster controls eligibility; its retained broadcast cache
|
||||
is history, not a roster. Failed eviction intentionally keeps an entry for retry.
|
||||
|
||||
## Architecture
|
||||
|
||||
PTY reconciliation and process admission belong to the PTY source adapter.
|
||||
Structured input comes from the current host's held-session projections. One
|
||||
admitted collection feeds row shaping and worktree aggregation, with no structured
|
||||
boolean bypass. PTY hooks and retained reports still arrive independently, so their
|
||||
precedence and conservative remote evidence rules remain necessary. No second
|
||||
persistent status store or provider polling was introduced.
|
||||
|
||||
## Validation and limits
|
||||
|
||||
Independent final review found no proven issues. Runtime, host lifecycle, status
|
||||
feed and source-admission suites passed: 1,344 tests, one skipped. Node typecheck,
|
||||
targeted lint and diff checks passed. Ablating the runtime call to enumerate
|
||||
retained history caused the executable call-site test to fail with two rows where
|
||||
one was expected; restoring the live accessor passed both call-site tests.
|
||||
|
||||
Live screenshots prove Codex working and closure on macOS. Claude provider turns,
|
||||
approval/input states, live Windows/Linux/WSL/SSH/relay/mobile scenarios and
|
||||
release-scale latency/heap measurements remain unverified. Existing tests cover
|
||||
remote/WSL evidence, monitoring precedence and lifecycle cases. The existing
|
||||
30-minute freshness rule and CLI activity timestamps are preserved; complete
|
||||
CLI/sidebar timing parity is not claimed. The wire keeps its existing row shape
|
||||
and status vocabulary; mobile receives the new rows without a new opcode.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,104 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
|
||||
|
||||
const source = vi.hoisted(() => ({ chunks: [] as Buffer[] }))
|
||||
vi.mock('../native-chat/wsl-transcript-fs-access', () => ({
|
||||
openTranscriptReadStream: async function* () {
|
||||
yield* source.chunks
|
||||
}
|
||||
}))
|
||||
|
||||
it('copies only the carried line when the next chunk contains many complete lines', async () => {
|
||||
source.chunks = Array.from({ length: 100 }, () => Buffer.from(`${'a\n'.repeat(1000)}x`))
|
||||
const original = Buffer.concat
|
||||
let copied = 0
|
||||
const concat = vi.spyOn(Buffer, 'concat').mockImplementation((chunks, total) => {
|
||||
copied += total ?? chunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
||||
return original(chunks, total)
|
||||
})
|
||||
let lines = 0
|
||||
let result: Awaited<ReturnType<typeof consumeCompleteJsonlLines>>
|
||||
try {
|
||||
result = await consumeCompleteJsonlLines({
|
||||
path: '/log',
|
||||
start: 0,
|
||||
onLine: () => {
|
||||
lines += 1
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
concat.mockRestore()
|
||||
}
|
||||
expect(lines).toBe(100000)
|
||||
expect(result!).toEqual({ consumedThrough: 200099, trailingPartialLine: 'x', bytesRead: 200100 })
|
||||
expect(copied).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('preserves UTF-8/CRLF carry, byte callbacks and stop offsets', async () => {
|
||||
source.chunks = [Buffer.from('ab\r'), Buffer.from('\ncd\npartial')]
|
||||
const lines: string[] = []
|
||||
expect(
|
||||
await consumeCompleteJsonlLines({
|
||||
path: '/log',
|
||||
start: 5,
|
||||
onLine: () => {},
|
||||
onLineBytes: (line) => lines.push(line.toString())
|
||||
})
|
||||
).toEqual({ consumedThrough: 12, trailingPartialLine: 'partial', bytesRead: 14 })
|
||||
expect(lines).toEqual(['ab', 'cd'])
|
||||
let stopped = false
|
||||
expect(
|
||||
await consumeCompleteJsonlLines({
|
||||
path: '/log',
|
||||
start: 5,
|
||||
onLine: () => {
|
||||
stopped = true
|
||||
},
|
||||
shouldStop: () => stopped
|
||||
})
|
||||
).toEqual({ consumedThrough: 9, trailingPartialLine: null, bytesRead: 14 })
|
||||
const unicode = Buffer.from('🦀\n')
|
||||
source.chunks = [unicode.subarray(0, 2), unicode.subarray(2)]
|
||||
const onLine = vi.fn()
|
||||
await consumeCompleteJsonlLines({ path: '/log', start: 0, onLine })
|
||||
expect(onLine).toHaveBeenCalledWith('🦀')
|
||||
})
|
||||
|
||||
// Why: a chunk boundary is not aligned to anything — it can land mid-record,
|
||||
// mid-UTF-8-sequence, between CR and LF, or on an empty line. A dropped or
|
||||
// merged line here silently corrupts an agent transcript, and a wrong
|
||||
// `consumedThrough` makes the next incremental scan resume mid-line.
|
||||
it('yields identical lines and resume offsets for every single-byte chunk split', async () => {
|
||||
const bigRecord = `{"d":${'"'.padEnd(2000, 'z')}"}`
|
||||
const expectedLines = [
|
||||
'{"a":1}', // plain LF record
|
||||
'{"b":"🦀 é 𝄞"}', // CRLF record whose content is 2/3/4-byte UTF-8
|
||||
'', // empty line
|
||||
'', // empty CRLF line
|
||||
'{"c":"x\ry"}', // lone CR inside a record
|
||||
bigRecord // single record larger than any carried prefix
|
||||
]
|
||||
const trailing = '{"partial":' // final line with no trailing newline
|
||||
const buffer = Buffer.from(
|
||||
`{"a":1}\n{"b":"🦀 é 𝄞"}\r\n\n\r\n{"c":"x\ry"}\n${bigRecord}\n${trailing}`,
|
||||
'utf-8'
|
||||
)
|
||||
const expectedConsumed = buffer.length - Buffer.byteLength(trailing)
|
||||
|
||||
for (let cut = 0; cut <= buffer.length; cut++) {
|
||||
source.chunks = [buffer.subarray(0, cut), buffer.subarray(cut)].filter((c) => c.length > 0)
|
||||
const lines: string[] = []
|
||||
const result = await consumeCompleteJsonlLines({
|
||||
path: '/log',
|
||||
start: 41,
|
||||
onLine: (line) => lines.push(line)
|
||||
})
|
||||
expect({ cut, lines, ...result }).toEqual({
|
||||
cut,
|
||||
lines: expectedLines,
|
||||
consumedThrough: 41 + expectedConsumed,
|
||||
trailingPartialLine: trailing,
|
||||
bytesRead: buffer.length
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -36,23 +36,24 @@ export async function consumeCompleteJsonlLines(args: {
|
||||
remainderLength += chunk.length
|
||||
continue
|
||||
}
|
||||
const data =
|
||||
remainderLength > 0
|
||||
? Buffer.concat([...remainderParts, chunk], remainderLength + chunk.length)
|
||||
: chunk
|
||||
remainderParts = []
|
||||
remainderLength = 0
|
||||
const data = chunk
|
||||
const carriedLength = remainderLength
|
||||
let lineStart = 0
|
||||
let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart)
|
||||
while (newlineIndex !== -1) {
|
||||
let lineEnd = newlineIndex
|
||||
if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) {
|
||||
lineEnd--
|
||||
let line = data.subarray(lineStart, newlineIndex)
|
||||
// Only the first line of a chunk can carry a prefix; resetting inside the
|
||||
// branch keeps the common per-line path allocation-free.
|
||||
if (remainderLength > 0) {
|
||||
line = Buffer.concat([...remainderParts, line], remainderLength + line.length)
|
||||
remainderParts = []
|
||||
remainderLength = 0
|
||||
}
|
||||
const lineEnd = line.at(-1) === CARRIAGE_RETURN_BYTE ? line.length - 1 : line.length
|
||||
if (args.onLineBytes) {
|
||||
args.onLineBytes(data.subarray(lineStart, lineEnd))
|
||||
args.onLineBytes(line.subarray(0, lineEnd))
|
||||
} else {
|
||||
args.onLine(data.toString('utf-8', lineStart, lineEnd))
|
||||
args.onLine(line.toString('utf-8', 0, lineEnd))
|
||||
}
|
||||
lineStart = newlineIndex + 1
|
||||
if (args.shouldStop?.()) {
|
||||
@@ -61,7 +62,7 @@ export async function consumeCompleteJsonlLines(args: {
|
||||
}
|
||||
newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart)
|
||||
}
|
||||
consumedThrough += lineStart
|
||||
consumedThrough += carriedLength + lineStart
|
||||
if (stopped) {
|
||||
remainderParts = []
|
||||
remainderLength = 0
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createServer, type Socket } from 'node:net'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteBrowserSocksServer } from './remote-browser-socks-server'
|
||||
|
||||
vi.mock('node:net', () => ({
|
||||
createServer: vi.fn(() => ({ listening: false }))
|
||||
}))
|
||||
|
||||
function setup(requestTail: Buffer = Buffer.alloc(0)) {
|
||||
const upstream = new PassThrough()
|
||||
const write = vi.spyOn(upstream, 'write')
|
||||
const opened = Promise.withResolvers<PassThrough>()
|
||||
const open = vi.fn(() => opened.promise)
|
||||
const server = new RemoteBrowserSocksServer({ open })
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
remoteAddress: '127.0.0.1',
|
||||
destroyed: false,
|
||||
write: vi.fn(() => true),
|
||||
pause: vi.fn(),
|
||||
end: vi.fn((_reply, callback) => callback()),
|
||||
pipe: vi.fn(),
|
||||
destroy: vi.fn(() => {
|
||||
socket.destroyed = true
|
||||
socket.emit('close')
|
||||
})
|
||||
})
|
||||
const accept = vi.mocked(createServer).mock.calls.at(-1)![0] as (socket: Socket) => void
|
||||
accept(socket as unknown as Socket)
|
||||
socket.emit('data', Buffer.from([5, 1, 0]))
|
||||
socket.emit('data', Buffer.concat([Buffer.from([5, 1, 0, 1, 127, 0, 0, 1, 1, 187]), requestTail]))
|
||||
return { server, socket, upstream, write, opened, open }
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('pending browser SOCKS route buffering', () => {
|
||||
it('copies fragmented pending bytes linearly and forwards every byte at the existing cap', async () => {
|
||||
const { server, socket, upstream, write, opened } = setup()
|
||||
const payload = Buffer.alloc(256 * 1024)
|
||||
for (let index = 0; index < payload.length; index += 1) {
|
||||
payload[index] = index % 251
|
||||
}
|
||||
let copiedBytes = 0
|
||||
const originalCopy = Buffer.prototype.copy
|
||||
const copy = vi.spyOn(Buffer.prototype, 'copy').mockImplementation(function (target, ...args) {
|
||||
const copied = originalCopy.call(this, target, ...args)
|
||||
copiedBytes += copied
|
||||
return copied
|
||||
})
|
||||
const concat = vi.spyOn(Buffer, 'concat')
|
||||
try {
|
||||
for (let index = 0; index < payload.length; index += 256) {
|
||||
socket.emit('data', payload.subarray(index, index + 256))
|
||||
}
|
||||
expect(concat.mock.calls.length).toBe(0)
|
||||
expect(copiedBytes).toBeLessThan(payload.length * 3)
|
||||
opened.resolve(upstream)
|
||||
await vi.waitFor(() => expect(write).toHaveBeenCalledTimes(1))
|
||||
expect(write.mock.calls[0][0]).toEqual(payload)
|
||||
} finally {
|
||||
copy.mockRestore()
|
||||
concat.mockRestore()
|
||||
await server.close()
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps request-tail bytes ahead of later fragments in the pending payload', async () => {
|
||||
const tail = Buffer.from('GET / HTTP/1.1\r\n')
|
||||
const { server, socket, upstream, write, opened } = setup(tail)
|
||||
const rest = Buffer.from('Host: example.com\r\n\r\n')
|
||||
try {
|
||||
for (const byte of rest) {
|
||||
socket.emit('data', Buffer.from([byte]))
|
||||
}
|
||||
opened.resolve(upstream)
|
||||
await vi.waitFor(() => expect(write).toHaveBeenCalledTimes(1))
|
||||
expect(write.mock.calls[0][0]).toEqual(Buffer.concat([tail, rest]))
|
||||
} finally {
|
||||
await server.close()
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects one byte beyond the cap and destroys a late upstream without forwarding', async () => {
|
||||
const { server, socket, upstream, write, opened, open } = setup()
|
||||
try {
|
||||
await vi.waitFor(() => expect(open).toHaveBeenCalledTimes(1))
|
||||
socket.emit('data', Buffer.alloc(256 * 1024))
|
||||
expect(socket.destroyed).toBe(false)
|
||||
socket.emit('data', Buffer.from([1]))
|
||||
expect(socket.destroyed).toBe(true)
|
||||
expect(socket.end.mock.calls[0][0][1]).toBe(1)
|
||||
opened.resolve(upstream)
|
||||
await vi.waitFor(() => expect(upstream.destroyed).toBe(true))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('discards pending input on client close while the route is opening', async () => {
|
||||
const { server, socket, upstream, write, opened, open } = setup()
|
||||
try {
|
||||
await vi.waitFor(() => expect(open).toHaveBeenCalledTimes(1))
|
||||
socket.emit('data', Buffer.from('pending request'))
|
||||
socket.destroy()
|
||||
opened.resolve(upstream)
|
||||
await vi.waitFor(() => expect(upstream.destroyed).toBe(true))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createServer, type Server, type Socket } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { GrowingByteBuffer } from '../../shared/growing-byte-buffer'
|
||||
import { pipeUpstreamToClient } from './remote-browser-socks-upstream'
|
||||
|
||||
const SOCKS_VERSION = 5
|
||||
const SOCKS_NO_AUTH = 0
|
||||
@@ -106,10 +108,13 @@ export class RemoteBrowserSocksServer {
|
||||
this.clients.add(socket)
|
||||
let phase: 'greeting' | 'request' | 'opening' | 'connected' | 'closed' = 'greeting'
|
||||
let buffered = Buffer.alloc(0)
|
||||
const pendingUpstream = new GrowingByteBuffer()
|
||||
const timeout = setTimeout(() => socket.destroy(), HANDSHAKE_TIMEOUT_MS)
|
||||
const cleanup = (): void => {
|
||||
phase = 'closed'
|
||||
clearTimeout(timeout)
|
||||
buffered = Buffer.alloc(0)
|
||||
pendingUpstream.clear()
|
||||
this.clients.delete(socket)
|
||||
}
|
||||
const finishFailure = (reply: Uint8Array): void => {
|
||||
@@ -119,6 +124,7 @@ export class RemoteBrowserSocksServer {
|
||||
phase = 'closed'
|
||||
clearTimeout(timeout)
|
||||
buffered = Buffer.alloc(0)
|
||||
pendingUpstream.clear()
|
||||
socket.pause()
|
||||
socket.end(reply, () => socket.destroy())
|
||||
}
|
||||
@@ -127,13 +133,15 @@ export class RemoteBrowserSocksServer {
|
||||
if (phase === 'closed' || phase === 'connected') {
|
||||
return
|
||||
}
|
||||
buffered = Buffer.concat([buffered, chunk])
|
||||
if (phase === 'opening') {
|
||||
if (buffered.byteLength > MAX_PENDING_UPSTREAM_BYTES) {
|
||||
if (pendingUpstream.byteLength + chunk.byteLength > MAX_PENDING_UPSTREAM_BYTES) {
|
||||
fail(1)
|
||||
} else {
|
||||
pendingUpstream.append(chunk)
|
||||
}
|
||||
return
|
||||
}
|
||||
buffered = Buffer.concat([buffered, chunk])
|
||||
if (phase === 'greeting' && buffered.byteLength > MAX_HANDSHAKE_BYTES) {
|
||||
fail(1)
|
||||
return
|
||||
@@ -181,6 +189,8 @@ export class RemoteBrowserSocksServer {
|
||||
return
|
||||
}
|
||||
phase = 'opening'
|
||||
pendingUpstream.append(buffered)
|
||||
buffered = Buffer.alloc(0)
|
||||
void Promise.resolve()
|
||||
.then(() => this.open(normalizeListenerWildcard(parsed.target)))
|
||||
.then(
|
||||
@@ -193,9 +203,8 @@ export class RemoteBrowserSocksServer {
|
||||
clearTimeout(timeout)
|
||||
socket.off('data', onData)
|
||||
socket.write(SUCCESS_RESPONSE)
|
||||
if (buffered.byteLength > 0) {
|
||||
upstream.write(buffered)
|
||||
buffered = Buffer.alloc(0)
|
||||
if (pendingUpstream.byteLength > 0) {
|
||||
upstream.write(pendingUpstream.takeBuffer())
|
||||
}
|
||||
socket.pipe(upstream)
|
||||
pipeUpstreamToClient(upstream, socket)
|
||||
@@ -212,22 +221,6 @@ export class RemoteBrowserSocksServer {
|
||||
}
|
||||
}
|
||||
|
||||
function pipeUpstreamToClient(upstream: Duplex, socket: Socket): void {
|
||||
upstream.on('data', (chunk: Buffer) => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const accepted = socket.write(bytes, (error) => {
|
||||
if (!error && 'settleRead' in upstream && typeof upstream.settleRead === 'function') {
|
||||
upstream.settleRead(bytes.byteLength)
|
||||
}
|
||||
})
|
||||
if (!accepted) {
|
||||
upstream.pause()
|
||||
}
|
||||
})
|
||||
socket.on('drain', () => upstream.resume())
|
||||
upstream.once('end', () => socket.end())
|
||||
}
|
||||
|
||||
function parseSocksRequest(buffer: Uint8Array): SocksRequest | null | undefined {
|
||||
if (buffer.byteLength < 4) {
|
||||
return undefined
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
|
||||
export function pipeUpstreamToClient(upstream: Duplex, socket: Socket): void {
|
||||
upstream.on('data', (chunk: Buffer) => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const accepted = socket.write(bytes, (error) => {
|
||||
if (!error && 'settleRead' in upstream && typeof upstream.settleRead === 'function') {
|
||||
upstream.settleRead(bytes.byteLength)
|
||||
}
|
||||
})
|
||||
if (!accepted) {
|
||||
upstream.pause()
|
||||
}
|
||||
})
|
||||
socket.on('drain', () => upstream.resume())
|
||||
upstream.once('end', () => socket.end())
|
||||
}
|
||||
@@ -20,14 +20,22 @@ function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
function taskId(message: Record<string, unknown>): string | null {
|
||||
const value = message.task_id
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
|
||||
? value
|
||||
: null
|
||||
/** The bound every task id shares, wherever it enters. An id the roster stores
|
||||
* becomes a durable entry key, so a provisional one takes the same bound the
|
||||
* announced path applies — an over-long id is rejected, never truncated. */
|
||||
export function isBoundedClaudeTaskId(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
|
||||
}
|
||||
|
||||
function taskDescription(value: unknown): string | undefined {
|
||||
/** The task's canonical, resume-stable id. Shared with the subagent roster so
|
||||
* both readers of this channel agree on what identifies a task. */
|
||||
export function claudeTaskId(message: Record<string, unknown>): string | null {
|
||||
const value = message.task_id
|
||||
return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null
|
||||
}
|
||||
|
||||
/** A task's human label, collapsed and bounded. */
|
||||
export function claudeTaskDescription(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
@@ -107,7 +115,7 @@ export class ClaudeBackgroundTaskTracker {
|
||||
this.replaceAggregateRoster(message.tasks)
|
||||
return true
|
||||
}
|
||||
const id = taskId(message)
|
||||
const id = claudeTaskId(message)
|
||||
if (!id) {
|
||||
return false
|
||||
}
|
||||
@@ -126,13 +134,13 @@ export class ClaudeBackgroundTaskTracker {
|
||||
}
|
||||
const existing = this.tasks.get(id)
|
||||
if (
|
||||
(patch.is_backgrounded === true || taskDescription(patch.description)) &&
|
||||
(patch.is_backgrounded === true || claudeTaskDescription(patch.description)) &&
|
||||
(!this.aggregateRosterObserved || existing)
|
||||
) {
|
||||
this.upsert(id, {
|
||||
backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true,
|
||||
kind: existing?.kind ?? 'unknown',
|
||||
description: taskDescription(patch.description) ?? existing?.description
|
||||
description: claudeTaskDescription(patch.description) ?? existing?.description
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -152,7 +160,7 @@ export class ClaudeBackgroundTaskTracker {
|
||||
this.upsert(id, {
|
||||
backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor',
|
||||
kind,
|
||||
description: taskDescription(message.description)
|
||||
description: claudeTaskDescription(message.description)
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -172,14 +180,14 @@ export class ClaudeBackgroundTaskTracker {
|
||||
if (!task || task.ambient === true) {
|
||||
continue
|
||||
}
|
||||
const id = taskId(task)
|
||||
const id = claudeTaskId(task)
|
||||
if (!id) {
|
||||
continue
|
||||
}
|
||||
this.tasks.set(id, {
|
||||
backgrounded: true,
|
||||
kind: classifyClaudeBackgroundTaskKind(task.task_type),
|
||||
description: taskDescription(task.description)
|
||||
description: claudeTaskDescription(task.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
import {
|
||||
claudeDispatchInvokesSlashCommand,
|
||||
claudeDispatchMessageContent
|
||||
} from './claude-structured-dispatch-content'
|
||||
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
)
|
||||
|
||||
function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks }
|
||||
}
|
||||
|
||||
const REMOTE_IMAGE = { type: 'image-ref' as const, url: 'https://example.test/a.png' }
|
||||
|
||||
describe('claudeDispatchMessageContent', () => {
|
||||
it('puts the text block last so a slash command still expands with an attachment', async () => {
|
||||
const content = await claudeDispatchMessageContent(
|
||||
// The composer builds text-then-images; Claude only treats a leading `/` as a
|
||||
// command when the LAST block is text.
|
||||
userMessage([{ type: 'text', text: '/goal ship the parser' }, REMOTE_IMAGE])
|
||||
)
|
||||
|
||||
expect(content).toEqual([
|
||||
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
|
||||
{ type: 'text', text: '/goal ship the parser' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps every image ahead of the text and preserves each side’s order', async () => {
|
||||
const second = { type: 'image-ref' as const, url: 'https://example.test/b.png' }
|
||||
|
||||
const content = await claudeDispatchMessageContent(
|
||||
userMessage([{ type: 'text', text: 'look' }, REMOTE_IMAGE, second])
|
||||
)
|
||||
|
||||
expect(content.map((part) => (part as { type: string }).type)).toEqual([
|
||||
'image',
|
||||
'image',
|
||||
'text'
|
||||
])
|
||||
expect(content[0]).toEqual({
|
||||
type: 'image',
|
||||
source: { type: 'url', url: 'https://example.test/a.png' }
|
||||
})
|
||||
expect(content[1]).toEqual({
|
||||
type: 'image',
|
||||
source: { type: 'url', url: 'https://example.test/b.png' }
|
||||
})
|
||||
})
|
||||
|
||||
it('sends text alone unchanged', async () => {
|
||||
const content = await claudeDispatchMessageContent(userMessage([{ type: 'text', text: 'hi' }]))
|
||||
|
||||
expect(content).toEqual([{ type: 'text', text: 'hi' }])
|
||||
})
|
||||
|
||||
it('sends an image with no text', async () => {
|
||||
const content = await claudeDispatchMessageContent(userMessage([REMOTE_IMAGE]))
|
||||
|
||||
expect(content).toEqual([
|
||||
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a message with no renderable block', async () => {
|
||||
await expect(
|
||||
claudeDispatchMessageContent(userMessage([{ type: 'text', text: '' }]))
|
||||
).rejects.toThrow('Claude dispatch requires text or an image')
|
||||
})
|
||||
|
||||
it('rejects a non-user message', async () => {
|
||||
await expect(
|
||||
claudeDispatchMessageContent({
|
||||
...userMessage([{ type: 'text', text: 'hi' }]),
|
||||
role: 'assistant'
|
||||
})
|
||||
).rejects.toThrow('Claude dispatch accepts only user messages')
|
||||
})
|
||||
|
||||
it('joins several text blocks so a command is not stranded ahead of trailing prose', async () => {
|
||||
// Appending each block would leave `thanks` trailing, and Claude reads only that block.
|
||||
const content = await claudeDispatchMessageContent(
|
||||
userMessage([
|
||||
{ type: 'text', text: '/goal ship' },
|
||||
REMOTE_IMAGE,
|
||||
{ type: 'text', text: 'thanks' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(content).toEqual([
|
||||
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
|
||||
{ type: 'text', text: '/goal ship\nthanks' }
|
||||
])
|
||||
expect(claudeDispatchInvokesSlashCommand(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('puts a locally attached image ahead of the text, the shape the composer sends', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'claude-dispatch-content-'))
|
||||
const path = join(dir, 'shot.png')
|
||||
await writeFile(path, PNG)
|
||||
|
||||
try {
|
||||
const content = await claudeDispatchMessageContent(
|
||||
userMessage([
|
||||
{ type: 'text', text: '/goal ship' },
|
||||
{ type: 'image-ref', path }
|
||||
])
|
||||
)
|
||||
|
||||
expect(content).toEqual([
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: PNG.toString('base64') }
|
||||
},
|
||||
{ type: 'text', text: '/goal ship' }
|
||||
])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('claudeDispatchInvokesSlashCommand', () => {
|
||||
it('reads the trailing prompt Claude recovers, not any text block', () => {
|
||||
expect(
|
||||
claudeDispatchInvokesSlashCommand([
|
||||
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
|
||||
{ type: 'text', text: '/goal ship' }
|
||||
])
|
||||
).toBe(true)
|
||||
// The pre-fix order: Claude recovers no prompt at all, so no command runs.
|
||||
expect(
|
||||
claudeDispatchInvokesSlashCommand([
|
||||
{ type: 'text', text: '/goal ship' },
|
||||
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }
|
||||
])
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the joined prompt, so a command behind leading prose is not one', async () => {
|
||||
// Keeping the blocks separate would leave `/goal ship` trailing and falsely claim a command.
|
||||
const content = await claudeDispatchMessageContent(
|
||||
userMessage([
|
||||
{ type: 'text', text: 'take a look' },
|
||||
{ type: 'text', text: '/goal ship' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(content).toEqual([{ type: 'text', text: 'take a look\n/goal ship' }])
|
||||
expect(claudeDispatchInvokesSlashCommand(content)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches untrimmed, as Claude does, and ignores a promptless turn', () => {
|
||||
expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: ' /goal ship' }])).toBe(false)
|
||||
expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: 'ship it' }])).toBe(false)
|
||||
expect(claudeDispatchInvokesSlashCommand([])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { open } from 'node:fs/promises'
|
||||
import { extname } from 'node:path'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
import type { NativeChatBlock } from '../../shared/native-chat-types'
|
||||
import { claudeRecord } from './claude-structured-item-translation'
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
const MAX_IMAGE_COUNT = 20
|
||||
@@ -88,27 +89,49 @@ async function imageContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude encodes a user turn as attachment blocks followed by the typed text, and recovers the
|
||||
* typed prompt by reading only the trailing text block. Verified against the real CLI over
|
||||
* stream-json: a body ending in an image has no recoverable prompt, so its `/command` reaches
|
||||
* the model as prose instead of being expanded.
|
||||
*/
|
||||
export async function claudeDispatchMessageContent(
|
||||
body: AgentJournalMessageItem
|
||||
): Promise<unknown[]> {
|
||||
if (body.role !== 'user') {
|
||||
throw new Error('Claude dispatch accepts only user messages')
|
||||
}
|
||||
const content: unknown[] = []
|
||||
const images: unknown[] = []
|
||||
const texts: string[] = []
|
||||
const imageBudget: ImageBudget = { count: 0, localBytes: 0 }
|
||||
for (const block of body.blocks as NativeChatBlock[]) {
|
||||
if (block.type === 'text' && block.text.length > 0) {
|
||||
content.push({ type: 'text', text: block.text })
|
||||
texts.push(block.text)
|
||||
} else if (block.type === 'image-ref') {
|
||||
content.push(await imageContent(block, imageBudget))
|
||||
images.push(await imageContent(block, imageBudget))
|
||||
}
|
||||
}
|
||||
// Join rather than append each block: only the trailing text is read as the prompt, so several
|
||||
// text blocks would silently discard every one but the last.
|
||||
const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images
|
||||
if (content.length === 0) {
|
||||
throw new Error('Claude dispatch requires text or an image')
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/** The prompt Claude recovers from a dispatch, or null when the turn carries no prompt. */
|
||||
function claudeDispatchPrompt(content: readonly unknown[]): string | null {
|
||||
const last = claudeRecord(content.at(-1))
|
||||
return last?.type === 'text' && typeof last.text === 'string' ? last.text : null
|
||||
}
|
||||
|
||||
/** Mirrors how Claude decides a turn is a command. Untrimmed on purpose: Claude does not trim
|
||||
* here either, so leading whitespace really does mean no command runs. */
|
||||
export function claudeDispatchInvokesSlashCommand(content: readonly unknown[]): boolean {
|
||||
return claudeDispatchPrompt(content)?.startsWith('/') === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep waiter metadata bounded even when a dispatch contains large base64 images.
|
||||
* The digest is only diagnostic: replay acknowledgement must use provider identity.
|
||||
@@ -117,10 +140,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string {
|
||||
const digest = createHash('sha256')
|
||||
const summary = content
|
||||
.map((part) => {
|
||||
const record =
|
||||
typeof part === 'object' && part !== null && !Array.isArray(part)
|
||||
? (part as Record<string, unknown>)
|
||||
: null
|
||||
const record = claudeRecord(part)
|
||||
const type = typeof record?.type === 'string' ? record.type : 'unknown'
|
||||
if (type === 'text') {
|
||||
return `text:${typeof record?.text === 'string' ? record.text.length : 0}`
|
||||
@@ -136,10 +156,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string {
|
||||
})
|
||||
.join(',')
|
||||
for (const [index, part] of content.entries()) {
|
||||
const record =
|
||||
typeof part === 'object' && part !== null && !Array.isArray(part)
|
||||
? (part as Record<string, unknown>)
|
||||
: null
|
||||
const record = claudeRecord(part)
|
||||
const type = typeof record?.type === 'string' ? record.type : 'unknown'
|
||||
digest.update(`${index}:${type}:`)
|
||||
if (type === 'text' && typeof record?.text === 'string') {
|
||||
|
||||
@@ -423,6 +423,73 @@ describe('Claude structured dispatch image limits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a slash command sent with an attachment from its result receipt', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{
|
||||
clientMessageId: 'client-1',
|
||||
body: userMessage([
|
||||
{ type: 'text', text: '/permissions' },
|
||||
{ type: 'image-ref', url: 'https://example.test/a.png' }
|
||||
])
|
||||
},
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
// The mapper moves the image ahead of the prompt, so Claude runs the command and replies
|
||||
// with a result receipt instead of a user replay.
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'command-result-uuid'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
await expect(dispatched).resolves.toMatchObject({
|
||||
state: 'accepted',
|
||||
providerIdentity: { uuid: 'command-result-uuid' }
|
||||
})
|
||||
// The sent order is the fix: the waiter's verdict alone was already what it is today.
|
||||
expect(session.connection.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
|
||||
{ type: 'text', text: '/permissions' }
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{
|
||||
clientMessageId: 'client-1',
|
||||
body: userMessage([{ type: 'text', text: ' /permissions' }])
|
||||
},
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
|
||||
expect(
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'unrelated-result-uuid'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
|
||||
})
|
||||
|
||||
it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => {
|
||||
const session = sessionFor()
|
||||
const first = dispatchClaudeTurn(
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-se
|
||||
import { readClaudeFrameString } from './claude-structured-init-proof'
|
||||
import {
|
||||
claudeDispatchContentKey,
|
||||
claudeDispatchInvokesSlashCommand,
|
||||
claudeDispatchMessageContent
|
||||
} from './claude-structured-dispatch-content'
|
||||
|
||||
@@ -231,9 +232,9 @@ export async function dispatchClaudeTurn(
|
||||
return { state: 'rejected', reason: (error as Error).message }
|
||||
}
|
||||
const dispatchSequence = ++session.dispatchSequence
|
||||
const acceptsResult = input.body.blocks.some(
|
||||
(block) => block.type === 'text' && block.text.trimStart().startsWith('/')
|
||||
)
|
||||
// Read the sent content, not the journal blocks: only the mapped trailing prompt decides
|
||||
// whether Claude runs a command, so the two cannot disagree about which frame settles this.
|
||||
const acceptsResult = claudeDispatchInvokesSlashCommand(content)
|
||||
const sentUuid = randomUUID()
|
||||
const replay = waitForReplay(
|
||||
session,
|
||||
|
||||
@@ -74,6 +74,18 @@ export function claudeMessageIdentity(
|
||||
return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid }
|
||||
}
|
||||
|
||||
/** User bubbles belong to the submitted message; SDK user frames carry echoes
|
||||
* and tool results, so a user envelope keeps only its tool results. */
|
||||
export function claudeOutputEnvelope(envelope: ClaudeMessageEnvelope): ClaudeMessageEnvelope {
|
||||
if (envelope.role !== 'user') {
|
||||
return envelope
|
||||
}
|
||||
return {
|
||||
...envelope,
|
||||
content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
|
||||
}
|
||||
}
|
||||
|
||||
function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] {
|
||||
const blocks: NativeChatBlock[] = []
|
||||
for (const value of envelope.content) {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
NativeChatSubagentEntry,
|
||||
NativeChatSubagentGroupBlock
|
||||
} from '../../shared/native-chat-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
|
||||
const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1'
|
||||
|
||||
/** The union's other arms carry no client message id, so reading one narrows. */
|
||||
function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null {
|
||||
return identity.provider === 'orca' ? identity.clientMessageId : null
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body) => items.push({ identity, body }),
|
||||
appendTombstone: vi.fn(),
|
||||
publish: vi.fn()
|
||||
}
|
||||
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' })
|
||||
const groupRows = () =>
|
||||
items.filter((item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID)
|
||||
const agentsOf = (body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] => {
|
||||
if (!body || body.kind !== 'message') {
|
||||
return []
|
||||
}
|
||||
const block = body.blocks.find(
|
||||
(candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group'
|
||||
)
|
||||
return block ? block.agents : []
|
||||
}
|
||||
/** The last roster row written for one group, so a test can read a group that
|
||||
* is no longer the live one. */
|
||||
const rosterIn = (groupId: string): NativeChatSubagentEntry[] =>
|
||||
agentsOf(
|
||||
items.findLast((item) => orcaClientMessageId(item.identity) === `claude-subagents:${groupId}`)
|
||||
?.body
|
||||
)
|
||||
const rosterOf = (turnUuid: string): NativeChatSubagentEntry[] =>
|
||||
rosterIn(`claude-session:${turnUuid}`)
|
||||
const roster = (): NativeChatSubagentEntry[] => agentsOf(groupRows().at(-1)?.body)
|
||||
const fallbackRows = (): AgentJournalItemBody[] =>
|
||||
items
|
||||
.filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:'))
|
||||
.map((item) => item.body)
|
||||
return { translator, groupRows, roster, rosterIn, rosterOf, fallbackRows }
|
||||
}
|
||||
|
||||
function userTurn(uuid: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
startsTurn: true as const,
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'go' }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function systemFrame(subtype: string, fields: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'system', subtype, session_id: 'claude-session', ...fields }
|
||||
}
|
||||
}
|
||||
|
||||
function spawnResult(uuid: string, toolUseId: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'done' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resultFrame() {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'claude-session',
|
||||
uuid: 'result-1',
|
||||
result: 'ok'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('claude journal translation — subagents', () => {
|
||||
it('rosters a spawned subagent and settles it on the spawn call result', () => {
|
||||
const { translator, roster, fallbackRows } = harness()
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.handle(
|
||||
systemFrame('task_started', {
|
||||
task_id: 'task-1',
|
||||
tool_use_id: 'toolu_1',
|
||||
task_type: 'local_agent',
|
||||
subagent_type: 'explorer',
|
||||
description: 'Map the lane'
|
||||
})
|
||||
)
|
||||
expect(roster()).toEqual([
|
||||
expect.objectContaining({ id: 'task-1', label: 'Map the lane', state: 'working' })
|
||||
])
|
||||
// The task frames stay status-chrome, so none of them prints an opcode row.
|
||||
expect(fallbackRows()).toEqual([])
|
||||
translator.handle(spawnResult('user-2', 'toolu_1'))
|
||||
expect(roster()).toEqual([expect.objectContaining({ state: 'completed' })])
|
||||
})
|
||||
|
||||
it('marks a child still working at turn end unverifiable', () => {
|
||||
const { translator, roster } = harness()
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.handle(
|
||||
systemFrame('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'local_agent',
|
||||
description: 'Map the lane'
|
||||
})
|
||||
)
|
||||
translator.handle(resultFrame())
|
||||
expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
})
|
||||
|
||||
it('leaves a backgrounded child running past the end of its turn', () => {
|
||||
const { translator, roster } = harness()
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.handle(
|
||||
systemFrame('task_started', {
|
||||
task_id: 'task-1',
|
||||
tool_use_id: 'toolu_1',
|
||||
task_type: 'local_agent',
|
||||
description: 'Watch the build',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
// A backgrounded spawn returns its tool result immediately; the child runs on.
|
||||
translator.handle(spawnResult('user-2', 'toolu_1'))
|
||||
translator.handle(resultFrame())
|
||||
expect(roster()).toEqual([expect.objectContaining({ state: 'working' })])
|
||||
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
|
||||
expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
})
|
||||
|
||||
it('keeps a backgrounded shell task out of the roster entirely', () => {
|
||||
const { translator, groupRows } = harness()
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.handle(
|
||||
systemFrame('task_started', {
|
||||
task_id: 'task-bash',
|
||||
tool_use_id: 'toolu_bash',
|
||||
task_type: 'local_bash',
|
||||
description: 'sleep 20',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
translator.handle(resultFrame())
|
||||
expect(groupRows()).toEqual([])
|
||||
})
|
||||
|
||||
it('shows a subagent whose release announces no task frames, from its child traffic', () => {
|
||||
const { translator, roster } = harness()
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.handle({
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'assistant',
|
||||
uuid: 'child-1',
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: 'toolu_1',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'looking' }] }
|
||||
}
|
||||
})
|
||||
expect(roster()).toEqual([
|
||||
expect.objectContaining({ id: 'toolu_1', label: 'subagent', state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('settles the turn a new turn superseded, and leaves the new one running', () => {
|
||||
const { translator, rosterOf } = harness()
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.handle(
|
||||
systemFrame('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'local_agent',
|
||||
description: 'First turn'
|
||||
})
|
||||
)
|
||||
// A second turn starts with no result frame for the first: the first turn
|
||||
// ends here, and nothing else will ever name its group again.
|
||||
translator.handle(userTurn('user-2'))
|
||||
translator.handle(
|
||||
systemFrame('task_started', {
|
||||
task_id: 'task-2',
|
||||
task_type: 'local_agent',
|
||||
description: 'Second turn'
|
||||
})
|
||||
)
|
||||
expect(rosterOf('user-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
expect(rosterOf('user-2')).toEqual([expect.objectContaining({ state: 'working' })])
|
||||
})
|
||||
|
||||
it('does not let an unrelated turn end settle a child announced outside a turn', () => {
|
||||
const { translator, rosterIn } = harness()
|
||||
// No turn is live yet, so this child has no turn key to belong to.
|
||||
translator.handle(
|
||||
systemFrame('task_started', {
|
||||
task_id: 'task-early',
|
||||
task_type: 'local_agent',
|
||||
description: 'Before the turn'
|
||||
})
|
||||
)
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.handle(resultFrame())
|
||||
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })])
|
||||
// The outcome still lands, which a latched `unverifiable` would have lost.
|
||||
translator.handle(
|
||||
systemFrame('task_updated', { task_id: 'task-early', patch: { status: 'completed' } })
|
||||
)
|
||||
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })])
|
||||
})
|
||||
|
||||
it('settles a child left outside every turn when the session ends', () => {
|
||||
const { translator, rosterIn } = harness()
|
||||
translator.handle(
|
||||
systemFrame('task_started', {
|
||||
task_id: 'task-early',
|
||||
task_type: 'local_agent',
|
||||
description: 'Before the turn'
|
||||
})
|
||||
)
|
||||
translator.handle(userTurn('user-1'))
|
||||
translator.handle(resultFrame())
|
||||
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
|
||||
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
})
|
||||
})
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
claudeMessageBody,
|
||||
claudeMessageIdentity,
|
||||
claudeHasReplayContent,
|
||||
claudeRecord,
|
||||
claudeOutputEnvelope,
|
||||
claudeStreamingMessageBody,
|
||||
claudeText,
|
||||
claudeThinkingIdentity,
|
||||
claudeThinkingText,
|
||||
claudeToolBody,
|
||||
@@ -29,16 +28,15 @@ import {
|
||||
claudeQuestionItems
|
||||
} from './claude-structured-prompt-items'
|
||||
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame'
|
||||
import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity'
|
||||
import {
|
||||
CLAUDE_UNRENDERABLE_CONTENT_TEXT,
|
||||
appendUnmodeledClaudeContent,
|
||||
claudeProviderFrameKind,
|
||||
claudeResultFailure,
|
||||
createClaudeProviderFrameFallback,
|
||||
isModeledClaudeContent,
|
||||
isSettledClaudeResultKind
|
||||
} from './claude-structured-provider-fallback'
|
||||
import { ClaudeSubagentRoster } from './claude-subagent-roster'
|
||||
import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
|
||||
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
|
||||
|
||||
@@ -89,10 +87,16 @@ export function createClaudeJournalTranslator(
|
||||
const promptItems = new Map<string, AgentJournalItemIdentity[]>()
|
||||
const streamedBlocks = createClaudeStreamedBlockRegistry()
|
||||
let currentTurn: { sessionId: string; turnId: string } | null = null
|
||||
const groupKeyOf = (turn: { sessionId: string; turnId: string } | null): string | null =>
|
||||
turn ? `${turn.sessionId}:${turn.turnId}` : null
|
||||
const providerFallback = createClaudeProviderFrameFallback(
|
||||
deps.sink,
|
||||
deps.fallbackIdPrefix ?? 'acquisition'
|
||||
)
|
||||
const subagents = new ClaudeSubagentRoster({
|
||||
sink: deps.sink,
|
||||
currentGroupKey: () => groupKeyOf(currentTurn)
|
||||
})
|
||||
const streamedText = createClaudeStreamedTextCheckpoints({
|
||||
...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }),
|
||||
...(deps.schedule ? { schedule: deps.schedule } : {}),
|
||||
@@ -144,14 +148,10 @@ export function createClaudeJournalTranslator(
|
||||
return false
|
||||
}
|
||||
let changed = false
|
||||
// User bubbles belong to the submitted message; SDK user frames carry echoes and tool results.
|
||||
const outputEnvelope =
|
||||
envelope.role === 'user'
|
||||
? {
|
||||
...envelope,
|
||||
content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
|
||||
}
|
||||
: envelope
|
||||
if (envelope.parentToolUseId) {
|
||||
subagents.observeChildActivity(envelope.parentToolUseId)
|
||||
}
|
||||
const outputEnvelope = claudeOutputEnvelope(envelope)
|
||||
const body = claudeMessageBody(outputEnvelope)
|
||||
// The final frame of a streamed block lands on the block's identity, not its own uuid.
|
||||
const identity =
|
||||
@@ -180,6 +180,8 @@ export function createClaudeJournalTranslator(
|
||||
claudeToolIdentity(envelope.sessionId, result.toolUseId),
|
||||
claudeToolBody({ tool, result })
|
||||
)
|
||||
// A spawn call's result is the parent turn's evidence its child finished.
|
||||
subagents.observeToolResult(result.toolUseId, result.failed)
|
||||
// Tool inputs are only needed until their matching result arrives.
|
||||
tools.delete(result.toolUseId)
|
||||
changed = true
|
||||
@@ -192,21 +194,7 @@ export function createClaudeJournalTranslator(
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
const unhandledContent = outputEnvelope.content.filter((part) => !isModeledClaudeContent(part))
|
||||
for (const part of unhandledContent) {
|
||||
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
|
||||
providerFallback.append(
|
||||
`message:${envelope.role}:content:${partType}`,
|
||||
part,
|
||||
readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
// An empty user frame is a replay with nothing to show, not an unknown kind.
|
||||
if (envelope.content.length === 0 && envelope.role === 'assistant') {
|
||||
providerFallback.append(`message:${envelope.role}:empty`, message)
|
||||
changed = true
|
||||
}
|
||||
changed = appendUnmodeledClaudeContent(providerFallback, outputEnvelope, message) || changed
|
||||
if (
|
||||
envelope.role === 'user' &&
|
||||
startsTurn &&
|
||||
@@ -214,6 +202,9 @@ export function createClaudeJournalTranslator(
|
||||
message.parent_tool_use_id === null
|
||||
) {
|
||||
if (currentTurn) {
|
||||
// A new turn starting is the only end the previous one gets when its
|
||||
// result never arrives; settling it later would sweep THIS turn.
|
||||
subagents.settleTurn(groupKeyOf(currentTurn))
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
}
|
||||
currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid }
|
||||
@@ -254,6 +245,8 @@ export function createClaudeJournalTranslator(
|
||||
handle: (event) => {
|
||||
if (event.type === 'ended') {
|
||||
streamedText.flush()
|
||||
// No event will ever settle a child once the provider is gone.
|
||||
subagents.settleSession()
|
||||
if (currentTurn) {
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
currentTurn = null
|
||||
@@ -274,6 +267,9 @@ export function createClaudeJournalTranslator(
|
||||
promptItems.delete(event.promptKey)
|
||||
deps.sink.publish()
|
||||
} else if (event.type === 'message' && event.message.type === 'result') {
|
||||
// The turn is over however it ended, so a foreground child still
|
||||
// reported as working will never be settled by an event.
|
||||
subagents.settleTurn(groupKeyOf(currentTurn))
|
||||
if (currentTurn) {
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
currentTurn = null
|
||||
@@ -291,6 +287,9 @@ export function createClaudeJournalTranslator(
|
||||
providerFallback.append(kind, event.message, failure?.text)
|
||||
}
|
||||
} else if (event.type === 'message') {
|
||||
// These frames stay `status-chrome`: the roster reads them here, and the
|
||||
// fallback below still drops the raw frame instead of printing an opcode.
|
||||
subagents.observeSystemFrame(event.message)
|
||||
const kind = claudeProviderFrameKind(event.message)
|
||||
if (!handleMessage(event.message, event.startsTurn === true)) {
|
||||
providerFallback.append(kind, event.message)
|
||||
@@ -310,6 +309,7 @@ export function createClaudeJournalTranslator(
|
||||
tools.clear()
|
||||
promptItems.clear()
|
||||
streamedBlocks.clear()
|
||||
subagents.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,15 @@ import {
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema'
|
||||
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
|
||||
import { claudeRecord, claudeText } from './claude-structured-item-translation'
|
||||
import {
|
||||
readableProviderFrameText,
|
||||
unhandledProviderFrameJournalItem
|
||||
} from '../native-chat/agent-session-wire/unhandled-provider-frame'
|
||||
import {
|
||||
claudeRecord,
|
||||
claudeText,
|
||||
type ClaudeMessageEnvelope
|
||||
} from './claude-structured-item-translation'
|
||||
|
||||
export function claudeProviderFrameKind(message: Record<string, unknown>): string {
|
||||
const type = claudeText(message.type) ?? 'unknown'
|
||||
@@ -123,3 +130,30 @@ export function createClaudeProviderFrameFallback(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudeProviderFrameFallback = ReturnType<typeof createClaudeProviderFrameFallback>
|
||||
|
||||
/** Journal each content part this build does not model, plus the empty assistant
|
||||
* frame a replay leaves behind (an empty USER frame is a replay with nothing to
|
||||
* show, not an unknown kind). Returns whether anything was appended. */
|
||||
export function appendUnmodeledClaudeContent(
|
||||
fallback: ClaudeProviderFrameFallback,
|
||||
envelope: ClaudeMessageEnvelope,
|
||||
message: Record<string, unknown>
|
||||
): boolean {
|
||||
let changed = false
|
||||
for (const part of envelope.content.filter((part) => !isModeledClaudeContent(part))) {
|
||||
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
|
||||
fallback.append(
|
||||
`message:${envelope.role}:content:${partType}`,
|
||||
part,
|
||||
readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
if (envelope.content.length === 0 && envelope.role === 'assistant') {
|
||||
fallback.append(`message:${envelope.role}:empty`, message)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
|
||||
import { claudeSubagentGroupBody } from './claude-subagent-group-row'
|
||||
|
||||
function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry {
|
||||
return { id, label: id, state, startedAt: 1 }
|
||||
}
|
||||
|
||||
/** The fallback sentence is the WHOLE row on mobile and paired web, which have
|
||||
* no roster renderer, so these assertions are the entire contract there. */
|
||||
function sentence(agents: readonly NativeChatSubagentEntry[]): string {
|
||||
const body = claudeSubagentGroupBody('turn-1', agents)
|
||||
const block = body.kind === 'message' ? body.blocks[0] : undefined
|
||||
return block && block.type === 'text' ? block.text : ''
|
||||
}
|
||||
|
||||
describe('claudeSubagentGroupBody fallback sentence', () => {
|
||||
it('reads as a plain completion when every child completed', () => {
|
||||
expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents')
|
||||
})
|
||||
|
||||
it('keeps the singular noun for a lone child', () => {
|
||||
expect(sentence([entry('a', 'completed')])).toBe('Ran 1 subagent')
|
||||
expect(sentence([entry('a', 'working')])).toBe('Kicked off 1 subagent')
|
||||
})
|
||||
|
||||
it('names an unverifiable child instead of claiming the group ran', () => {
|
||||
expect(sentence([entry('a', 'completed'), entry('b', 'unverifiable')])).toBe(
|
||||
'Ran 2 subagents (1 unverifiable)'
|
||||
)
|
||||
})
|
||||
|
||||
it('ranks the adverse outcome worst-first', () => {
|
||||
expect(
|
||||
sentence([entry('a', 'failed'), entry('b', 'unverifiable'), entry('c', 'completed')])
|
||||
).toBe('Ran 3 subagents (1 failed)')
|
||||
expect(sentence([entry('a', 'stopped'), entry('b', 'unverifiable')])).toBe(
|
||||
'Ran 2 subagents (1 stopped)'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the adverse outcome while a sibling still works', () => {
|
||||
expect(
|
||||
sentence([entry('a', 'working'), entry('b', 'working'), entry('c', 'unverifiable')])
|
||||
).toBe('Kicked off 3 subagents (1 unverifiable)')
|
||||
})
|
||||
|
||||
it('leaves a benign settled state out of the sentence', () => {
|
||||
expect(sentence([entry('a', 'idle'), entry('b', 'completed')])).toBe('Ran 2 subagents')
|
||||
})
|
||||
|
||||
it('counts every child holding the worst adverse state', () => {
|
||||
expect(sentence([entry('a', 'failed'), entry('b', 'failed'), entry('c', 'stopped')])).toBe(
|
||||
'Ran 3 subagents (2 failed)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
// The journal row one Claude spawn group writes: its durable identity and the
|
||||
// body it revises in place.
|
||||
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary'
|
||||
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
|
||||
|
||||
/** Durable journal identity for the group's row — stable across revisions and
|
||||
* across a restart, so replay finds the same row instead of appending a new one. */
|
||||
export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
|
||||
return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` }
|
||||
}
|
||||
|
||||
/** The roster row: the structured block plus the plain sentence an older client
|
||||
* renders in its place. A message whose only block is the new variant would
|
||||
* reach such a client with nothing it can draw. */
|
||||
export function claudeSubagentGroupBody(
|
||||
groupId: string,
|
||||
agents: readonly NativeChatSubagentEntry[]
|
||||
): AgentJournalItemBody {
|
||||
return {
|
||||
kind: 'message',
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{ type: 'text', text: subagentGroupFallbackText(agents) },
|
||||
{ type: 'subagent-group', groupId, agents: [...agents] }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
|
||||
|
||||
describe('ClaudeSubagentIds', () => {
|
||||
it('resolves an aliased tool id to its task, and an unaliased id to itself', () => {
|
||||
const ids = new ClaudeSubagentIds()
|
||||
ids.alias('toolu_1', 'task-1')
|
||||
expect(ids.canonical('toolu_1')).toBe('task-1')
|
||||
expect(ids.canonical('toolu_unknown')).toBe('toolu_unknown')
|
||||
})
|
||||
|
||||
it('remembers an exclusion under either of the ids that named it', () => {
|
||||
const ids = new ClaudeSubagentIds()
|
||||
ids.exclude('task-bash')
|
||||
expect(ids.isExcluded('toolu_bash', 'task-bash')).toBe(true)
|
||||
expect(ids.isExcluded(null, null)).toBe(false)
|
||||
expect(ids.isExcluded('task-agent')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the oldest alias past the bound and keeps the newest', () => {
|
||||
const ids = new ClaudeSubagentIds()
|
||||
for (let index = 0; index <= 512; index += 1) {
|
||||
ids.alias(`toolu_${index}`, `task-${index}`)
|
||||
}
|
||||
// Evicted: the id now stands only for itself.
|
||||
expect(ids.canonical('toolu_0')).toBe('toolu_0')
|
||||
expect(ids.canonical('toolu_512')).toBe('task-512')
|
||||
expect(ids.canonical('toolu_1')).toBe('task-1')
|
||||
})
|
||||
|
||||
it('drops the oldest exclusion past the bound and keeps the newest', () => {
|
||||
const ids = new ClaudeSubagentIds()
|
||||
for (let index = 0; index <= 512; index += 1) {
|
||||
ids.exclude(`task-${index}`)
|
||||
}
|
||||
expect(ids.isExcluded('task-0')).toBe(false)
|
||||
expect(ids.isExcluded('task-512')).toBe(true)
|
||||
expect(ids.isExcluded('task-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not retain oversized aliases or exclusions', () => {
|
||||
const ids = new ClaudeSubagentIds()
|
||||
const oversized = 'x'.repeat(513)
|
||||
ids.alias(oversized, 'task-1')
|
||||
ids.alias('tool-1', oversized)
|
||||
ids.exclude(oversized)
|
||||
expect(ids.canonical(oversized)).toBe(oversized)
|
||||
expect(ids.canonical('tool-1')).toBe('tool-1')
|
||||
expect(ids.isExcluded(oversized)).toBe(false)
|
||||
})
|
||||
|
||||
it('forgets everything on clear', () => {
|
||||
const ids = new ClaudeSubagentIds()
|
||||
ids.alias('toolu_1', 'task-1')
|
||||
ids.exclude('task-1')
|
||||
ids.clear()
|
||||
expect(ids.canonical('toolu_1')).toBe('toolu_1')
|
||||
expect(ids.isExcluded('task-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// Which Claude ids name the same subagent, and which name no subagent at all.
|
||||
//
|
||||
// Claude re-announces a resumed task under a NEW `tool_use_id` while `task_id`
|
||||
// stays put, so tool ids are aliases of a canonical task id — a store keyed on
|
||||
// the tool id would show the child twice after every resume.
|
||||
//
|
||||
// The exclusions matter just as much: `task_updated` carries no `task_type` and
|
||||
// child traffic carries no task metadata at all, so the one announcement that
|
||||
// said "this is a backgrounded shell, not an agent" has to be remembered or a
|
||||
// later frame re-admits it.
|
||||
|
||||
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
|
||||
|
||||
/** Both maps are event-accumulated and nothing prunes them, so both are bounded. */
|
||||
const MAX_TOOL_USE_ALIASES = 512
|
||||
const MAX_EXCLUDED_IDS = 512
|
||||
|
||||
export class ClaudeSubagentIds {
|
||||
private readonly canonicalByToolUse = new Map<string, string>()
|
||||
private readonly excluded = new Set<string>()
|
||||
|
||||
/** The task id a tool id stands for, or the id itself when nothing aliases it. */
|
||||
canonical(id: string): string {
|
||||
return this.canonicalByToolUse.get(id) ?? id
|
||||
}
|
||||
|
||||
alias(toolUseId: string, taskId: string): void {
|
||||
if (!isBoundedClaudeTaskId(toolUseId) || !isBoundedClaudeTaskId(taskId)) {
|
||||
return
|
||||
}
|
||||
this.canonicalByToolUse.set(toolUseId, taskId)
|
||||
while (this.canonicalByToolUse.size > MAX_TOOL_USE_ALIASES) {
|
||||
const oldest = this.canonicalByToolUse.keys().next()
|
||||
if (oldest.done || oldest.value === toolUseId) {
|
||||
break
|
||||
}
|
||||
this.canonicalByToolUse.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
exclude(id: string): void {
|
||||
if (!isBoundedClaudeTaskId(id)) {
|
||||
return
|
||||
}
|
||||
this.excluded.add(id)
|
||||
while (this.excluded.size > MAX_EXCLUDED_IDS) {
|
||||
const oldest = this.excluded.values().next()
|
||||
if (oldest.done || oldest.value === id) {
|
||||
break
|
||||
}
|
||||
this.excluded.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
isExcluded(...ids: (string | null)[]): boolean {
|
||||
return ids.some((id) => id !== null && this.excluded.has(id))
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.canonicalByToolUse.clear()
|
||||
this.excluded.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
|
||||
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
|
||||
import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
|
||||
|
||||
const MAX_INVOCATIONS_PER_SUBAGENT = 16
|
||||
|
||||
export type TrackedEntry = {
|
||||
entry: NativeChatSubagentEntry
|
||||
/** The only signal separating a child that dies with its turn from one told to
|
||||
* outlive it. A turn-end sweep must leave a backgrounded child alone. */
|
||||
backgrounded: boolean
|
||||
toolUseId: string | null
|
||||
invocationIds: Set<string> | null
|
||||
/** Label before its ordinal suffix, so a later announcement can tell a
|
||||
* provisional row from one that already carries the provider's own name. */
|
||||
labelBase: string
|
||||
}
|
||||
|
||||
export type RosterGroup = {
|
||||
groupId: string
|
||||
identity: AgentJournalItemIdentity
|
||||
/** Insertion order is the display order; the map holds the state. */
|
||||
entries: Map<string, TrackedEntry>
|
||||
/** Lifetime admissions bound retained labels even when entries are removed. */
|
||||
admittedEntries: number
|
||||
/** Labels remain reserved after removal or provisional-name replacement. */
|
||||
claimedLabels: Set<string>
|
||||
/** Last body written, so an idempotent replay writes no new revision. */
|
||||
lastSerialized: string | null
|
||||
}
|
||||
|
||||
// Invocation history stays with the entry, independent of the evicting alias cache.
|
||||
export function applyClaudeSubagentInvocation(
|
||||
tracked: TrackedEntry,
|
||||
frame: ClaudeSubagentTaskFrame,
|
||||
now: () => number
|
||||
): boolean {
|
||||
if (tracked.invocationIds === null) {
|
||||
return false
|
||||
}
|
||||
const newInvocation =
|
||||
frame.announcement && frame.toolUseId !== null && !tracked.invocationIds.has(frame.toolUseId)
|
||||
if (newInvocation && frame.toolUseId) {
|
||||
if (tracked.invocationIds.size >= MAX_INVOCATIONS_PER_SUBAGENT) {
|
||||
tracked.invocationIds = null
|
||||
tracked.entry = { ...tracked.entry, state: 'unverifiable', settledAt: now() }
|
||||
return true
|
||||
}
|
||||
tracked.invocationIds.add(frame.toolUseId)
|
||||
if (tracked.toolUseId !== null && tracked.toolUseId !== frame.toolUseId) {
|
||||
tracked.backgrounded = frame.backgrounded ?? false
|
||||
tracked.entry = { ...tracked.entry, state: frame.state ?? 'working', settledAt: undefined }
|
||||
}
|
||||
tracked.toolUseId = frame.toolUseId
|
||||
} else if (tracked.toolUseId && frame.toolUseId && tracked.toolUseId !== frame.toolUseId) {
|
||||
return false
|
||||
}
|
||||
if (tracked.toolUseId === null) {
|
||||
tracked.toolUseId = frame.toolUseId
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Two children can share a description; the ordinal keeps their rows apart
|
||||
* without inventing a name the provider never sent. The probe is over the
|
||||
* labels actually rendered, not a per-base counter: a generated `Audit 2`
|
||||
* must not collide with a provider that names its own child `Audit 2`. */
|
||||
export function claimClaudeSubagentLabel(group: RosterGroup, base: string): string {
|
||||
let candidate = base
|
||||
for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) {
|
||||
candidate = `${base} ${ordinal}`
|
||||
}
|
||||
group.claimedLabels.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
NativeChatSubagentEntry,
|
||||
NativeChatSubagentGroupBlock
|
||||
} from '../../shared/native-chat-types'
|
||||
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
|
||||
import {
|
||||
createDeferredStructuredAgentSessionEventSink,
|
||||
type StructuredAgentSessionEventSink
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { ClaudeSubagentRoster } from './claude-subagent-roster'
|
||||
|
||||
const TURN_1 = 'claude-session:turn-1'
|
||||
|
||||
function agentsOf(body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] {
|
||||
if (!body || body.kind !== 'message') {
|
||||
return []
|
||||
}
|
||||
const block = body.blocks.find(
|
||||
(candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group'
|
||||
)
|
||||
return block ? block.agents : []
|
||||
}
|
||||
|
||||
function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolean {
|
||||
return identity.provider === 'orca' && identity.clientMessageId === `claude-subagents:${groupId}`
|
||||
}
|
||||
|
||||
function harness(groupKey: string | null = TURN_1) {
|
||||
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
|
||||
const tombstones: AgentJournalItemIdentity[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body) => items.push({ identity, body }),
|
||||
appendTombstone: (identity) => tombstones.push(identity),
|
||||
publish: vi.fn()
|
||||
}
|
||||
let clock = 1_000
|
||||
let key = groupKey
|
||||
const roster = new ClaudeSubagentRoster({
|
||||
sink,
|
||||
currentGroupKey: () => key,
|
||||
now: () => (clock += 1)
|
||||
})
|
||||
const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body)
|
||||
/** The last row written for one group, so a test can read a row that is no
|
||||
* longer the newest one. */
|
||||
const rolesIn = (groupId: string): NativeChatSubagentEntry[] =>
|
||||
agentsOf(items.findLast((item) => isGroupRow(item.identity, groupId))?.body)
|
||||
return {
|
||||
roster,
|
||||
items,
|
||||
tombstones,
|
||||
roles,
|
||||
rolesIn,
|
||||
setGroupKey: (next: string | null) => {
|
||||
key = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function system(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
|
||||
return { type: 'system', subtype, session_id: 'claude-session', ...fields }
|
||||
}
|
||||
|
||||
function started(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
return system('task_started', { task_type: 'local_agent', ...fields })
|
||||
}
|
||||
|
||||
describe('ClaudeSubagentRoster', () => {
|
||||
it('builds the row from task_started, with the fallback sentence beside the block', () => {
|
||||
const { roster, items, roles } = harness()
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Review the diff' })
|
||||
)
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]?.identity).toEqual({
|
||||
provider: 'orca',
|
||||
clientMessageId: 'claude-subagents:claude-session:turn-1'
|
||||
})
|
||||
const body = items[0]?.body
|
||||
expect(body?.kind === 'message' && body.blocks[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Kicked off 1 subagent'
|
||||
})
|
||||
expect(roles()).toEqual([
|
||||
expect.objectContaining({ id: 'task-1', label: 'Review the diff', state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a backgrounded shell task out of the roster', () => {
|
||||
const { roster, items } = harness()
|
||||
roster.observeSystemFrame(
|
||||
system('task_started', {
|
||||
task_id: 'task-bash',
|
||||
tool_use_id: 'toolu_bash',
|
||||
task_type: 'local_bash',
|
||||
description: 'sleep 20',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-bash', patch: { status: 'running' } })
|
||||
)
|
||||
// Its own frames carry a tool_use_id, so only the excluded-id memory stops it.
|
||||
roster.observeChildActivity('toolu_bash')
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never renders a task marked skip_transcript', () => {
|
||||
const { roster, items } = harness()
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-a', tool_use_id: 'toolu_a', skip_transcript: true })
|
||||
)
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } })
|
||||
)
|
||||
roster.observeChildActivity('toolu_a')
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops a provisional row once an announcement says the task is not a subagent', () => {
|
||||
const { roster, items, tombstones, roles } = harness()
|
||||
roster.observeChildActivity('toolu_bash')
|
||||
expect(roles()).toHaveLength(1)
|
||||
roster.observeSystemFrame(
|
||||
system('task_started', {
|
||||
task_id: 'task-bash',
|
||||
tool_use_id: 'toolu_bash',
|
||||
task_type: 'local_bash'
|
||||
})
|
||||
)
|
||||
expect(tombstones).toEqual([
|
||||
{ provider: 'orca', clientMessageId: 'claude-subagents:claude-session:turn-1' }
|
||||
])
|
||||
expect(items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate a resumed task re-announced under a new tool_use_id', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'toolu_first', description: 'Audit' })
|
||||
)
|
||||
roster.observeChildActivity('toolu_first')
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'toolu_second', description: 'Audit' })
|
||||
)
|
||||
roster.observeChildActivity('toolu_second')
|
||||
expect(roles()).toEqual([
|
||||
expect.objectContaining({ id: 'task-1', label: 'Audit', state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('adopts a row built from child traffic when the announcement finally names it', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeChildActivity('toolu_1')
|
||||
expect(roles()).toEqual([expect.objectContaining({ id: 'toolu_1', label: 'subagent' })])
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
|
||||
)
|
||||
expect(roles()).toEqual([
|
||||
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('is idempotent: a repeated frame writes no new revision', () => {
|
||||
const { roster, items } = harness()
|
||||
const frame = started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' })
|
||||
roster.observeSystemFrame(frame)
|
||||
roster.observeSystemFrame(frame)
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { status: 'running' } })
|
||||
)
|
||||
expect(items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('latches a terminal state against a later live report', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { status: 'failed' } })
|
||||
)
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { status: 'running' } })
|
||||
)
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
|
||||
})
|
||||
|
||||
it('ignores an update for a task it never rostered', () => {
|
||||
const { roster, items } = harness()
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-unknown', patch: { status: 'running' } })
|
||||
)
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disambiguates children that share a description', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Explore' }))
|
||||
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Explore' }))
|
||||
expect(roles().map((agent) => agent.label)).toEqual(['Explore', 'Explore 2'])
|
||||
})
|
||||
|
||||
describe('turn end', () => {
|
||||
it('leaves a backgrounded child working and marks a foreground one unverifiable', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-fg', description: 'Foreground' }))
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
|
||||
)
|
||||
roster.settleTurn(TURN_1)
|
||||
expect(roles()).toEqual([
|
||||
expect.objectContaining({ label: 'Foreground', state: 'unverifiable' }),
|
||||
expect.objectContaining({ label: 'Background', state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('never re-settles a child that already reported an outcome', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
|
||||
)
|
||||
roster.settleTurn(TURN_1)
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
|
||||
})
|
||||
|
||||
it('sweeps backgrounded children only when the provider itself is gone', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
|
||||
)
|
||||
roster.settleTurn(TURN_1)
|
||||
roster.settleSession()
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawn tool result', () => {
|
||||
it('settles a foreground child', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' }))
|
||||
roster.observeToolResult('toolu_1', false)
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
|
||||
})
|
||||
|
||||
it('reports a failed spawn as failed', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' }))
|
||||
roster.observeToolResult('toolu_1', true)
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
|
||||
})
|
||||
|
||||
it('ignores the immediate result a backgrounded spawn returns', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'toolu_1', is_backgrounded: true })
|
||||
)
|
||||
roster.observeToolResult('toolu_1', false)
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'working' })])
|
||||
})
|
||||
|
||||
it('ignores results for tools that are not spawn calls', () => {
|
||||
const { roster, items } = harness()
|
||||
roster.observeToolResult('toolu_read', false)
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('label ordinals', () => {
|
||||
it('never re-issues an ordinal a removed row gave up', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
|
||||
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' }))
|
||||
// task-1 is re-announced as a shell task, so its row goes; reclaiming the
|
||||
// ordinal it held would print a second 'Audit 2' beside the one still shown.
|
||||
roster.observeSystemFrame(
|
||||
system('task_started', { task_id: 'task-1', task_type: 'local_bash' })
|
||||
)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit' }))
|
||||
expect(roles().map((agent) => agent.label)).toEqual(['Audit 2', 'Audit 3'])
|
||||
})
|
||||
|
||||
it('never generates a label a provider-supplied one already took', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
|
||||
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' }))
|
||||
// The provider's own name for the third child is the label the ordinal just
|
||||
// generated for the second; a per-base counter would print it twice.
|
||||
roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit 2' }))
|
||||
const labels = roles().map((agent) => agent.label)
|
||||
expect(labels).toEqual(['Audit', 'Audit 2', 'Audit 2 2'])
|
||||
expect(new Set(labels).size).toBe(labels.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('child traffic for an id the CLI never declared', () => {
|
||||
it('creates nothing once the CLI has announced any task at all', () => {
|
||||
const { roster, items } = harness()
|
||||
// A rejected announcement still proves this CLI declares what it spawns.
|
||||
roster.observeSystemFrame(
|
||||
system('task_started', { task_id: 'task-bash', task_type: 'local_bash' })
|
||||
)
|
||||
roster.observeChildActivity('toolu_never_announced')
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects an over-long provisional id instead of storing it as an entry id', () => {
|
||||
const { roster, items } = harness()
|
||||
// The announced path drops an id past `claudeTaskId`'s bound; the
|
||||
// provisional one writes the same durable entry id, so it must too.
|
||||
roster.observeChildActivity(`toolu_${'x'.repeat(512)}`)
|
||||
expect(items).toHaveLength(0)
|
||||
roster.observeChildActivity(`toolu_${'x'.repeat(500)}`)
|
||||
expect(items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still rosters a subagent announced after a task the filter rejected', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(
|
||||
system('task_started', { task_id: 'task-bash', task_type: 'local_bash' })
|
||||
)
|
||||
// The gate closes the child-traffic fallback, never the announcement path.
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
|
||||
)
|
||||
roster.observeChildActivity('toolu_1')
|
||||
expect(roles()).toEqual([
|
||||
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a grandchild parented inside the sidechain out of the roster', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
|
||||
)
|
||||
roster.observeChildActivity('toolu_1')
|
||||
// A tool the subagent itself ran: never announced, so never excluded either.
|
||||
roster.observeChildActivity('toolu_inner')
|
||||
expect(roles()).toEqual([
|
||||
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('still mints the provisional row for a release that announces no task', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeChildActivity('toolu_1')
|
||||
// Not an announcement: the fallback path stays open for this release.
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-x', patch: { status: 'running' } })
|
||||
)
|
||||
roster.observeChildActivity('toolu_2')
|
||||
expect(roles().map((agent) => agent.label)).toEqual(['subagent', 'subagent 2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groups that no later event can reach', () => {
|
||||
it('loses contact with a group evicted past the bound', () => {
|
||||
const { roster, rolesIn, setGroupKey } = harness('turn-0')
|
||||
for (let index = 0; index < 33; index += 1) {
|
||||
setGroupKey(`turn-${index}`)
|
||||
roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' }))
|
||||
}
|
||||
expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })])
|
||||
})
|
||||
|
||||
it('loses contact with a live child when the translator is disposed without an end', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
|
||||
)
|
||||
roster.dispose()
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
})
|
||||
|
||||
it('writes nothing on dispose when the session already settled', () => {
|
||||
const { roster, items } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
|
||||
roster.settleSession()
|
||||
const written = items.length
|
||||
roster.dispose()
|
||||
expect(items).toHaveLength(written)
|
||||
})
|
||||
})
|
||||
|
||||
it('groups children outside any turn under their own row', () => {
|
||||
const { roster, items } = harness(null)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
|
||||
expect(items[0]?.identity).toEqual({
|
||||
provider: 'orca',
|
||||
clientMessageId: 'claude-subagents:outside-turn'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeSubagentRoster — the turn that is ending', () => {
|
||||
it('leaves a child announced outside any turn alone when an unrelated turn ends', () => {
|
||||
const { roster, rolesIn, setGroupKey } = harness(null)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
|
||||
setGroupKey(TURN_1)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' }))
|
||||
roster.settleTurn(TURN_1)
|
||||
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })])
|
||||
expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
// `unverifiable` latches, so sweeping it above would have swallowed this.
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-early', patch: { status: 'completed' } })
|
||||
)
|
||||
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })])
|
||||
})
|
||||
|
||||
it('sweeps the outside-turn group when a turn with no key of its own ends', () => {
|
||||
const { roster, rolesIn } = harness(null)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
|
||||
roster.settleTurn(null)
|
||||
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
})
|
||||
|
||||
it('still settles an outside-turn child once the session itself ends', () => {
|
||||
const { roster, rolesIn, setGroupKey } = harness(null)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
|
||||
setGroupKey(TURN_1)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' }))
|
||||
roster.settleTurn(TURN_1)
|
||||
roster.settleSession()
|
||||
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
})
|
||||
|
||||
it('sweeps the turn that ended, not whichever turn is live now', () => {
|
||||
const { roster, rolesIn, setGroupKey } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'First turn' }))
|
||||
setGroupKey('claude-session:turn-2')
|
||||
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Second turn' }))
|
||||
// Turn 1's result lands after turn 2 has already begun.
|
||||
roster.settleTurn(TURN_1)
|
||||
expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })])
|
||||
expect(rolesIn('claude-session:turn-2')).toEqual([
|
||||
expect.objectContaining({ state: 'working' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeSubagentRoster — through the real sink queue', () => {
|
||||
it('lands every revision, not just the one that was already in flight', async () => {
|
||||
const appended: AgentJournalItemBody[] = []
|
||||
let published = 0
|
||||
const journal = {
|
||||
appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
|
||||
appended.push(body)
|
||||
return { cursor: { epoch: 'e', sequence: appended.length } }
|
||||
},
|
||||
appendTombstone: async () => ({ epoch: 'e', sequence: 0 })
|
||||
} as unknown as AgentSessionJournal
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink()
|
||||
deferred.bind({
|
||||
journal,
|
||||
fence: 1,
|
||||
publish: () => {
|
||||
published += 1
|
||||
}
|
||||
})
|
||||
const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 })
|
||||
|
||||
// The first append is in flight while the rest are submitted, so a publish
|
||||
// sharing the row's coalescing key would evict them.
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' }))
|
||||
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' }))
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
|
||||
)
|
||||
const drained = await deferred.drained()
|
||||
|
||||
expect(drained).toEqual({ ok: true })
|
||||
expect(agentsOf(appended.at(-1))).toEqual([
|
||||
expect.objectContaining({ id: 'task-1', label: 'One', state: 'completed' }),
|
||||
expect.objectContaining({ id: 'task-2', label: 'Two', state: 'working' })
|
||||
])
|
||||
expect(published).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', () => {
|
||||
it('accepts a notification after the foreground turn lost contact', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1' }))
|
||||
roster.settleTurn(TURN_1)
|
||||
roster.observeSystemFrame(
|
||||
system('task_notification', { task_id: 'task-1', status: 'completed' })
|
||||
)
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
|
||||
})
|
||||
|
||||
it('settles a background child from its notification without a task_updated', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', is_backgrounded: true }))
|
||||
roster.settleTurn(TURN_1)
|
||||
roster.observeSystemFrame(system('task_notification', { task_id: 'task-1', status: 'failed' }))
|
||||
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
|
||||
})
|
||||
|
||||
it('bounds lifetime admissions when reclassification repeatedly removes entries', () => {
|
||||
const { roster, items } = harness()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
roster.observeSystemFrame(started({ task_id: `task-${i}`, description: `Agent ${i}` }))
|
||||
roster.observeSystemFrame(
|
||||
system('task_started', { task_id: `task-${i}`, task_type: 'local_bash' })
|
||||
)
|
||||
}
|
||||
expect(items).toHaveLength(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeSubagentRoster — resumed invocation', () => {
|
||||
it('reopens one canonical child on a new announcement without replaying old results', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
|
||||
roster.observeToolResult('first', false)
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true })
|
||||
)
|
||||
expect(roles()).toEqual([expect.objectContaining({ id: 'task-1', state: 'working' })])
|
||||
expect(roles()[0].settledAt).toBeUndefined()
|
||||
roster.observeSystemFrame(
|
||||
system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' })
|
||||
)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
|
||||
expect(roles()[0].state).toBe('working')
|
||||
roster.observeSystemFrame(
|
||||
system('task_notification', {
|
||||
task_id: 'task-1',
|
||||
tool_use_id: 'resumed',
|
||||
status: 'completed'
|
||||
})
|
||||
)
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true })
|
||||
)
|
||||
expect(roles()[0].state).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeSubagentRoster — invocation fences', () => {
|
||||
it('ignores a previous invocation tool result even without a background flag', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
|
||||
roster.observeToolResult('first', false)
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'next' }))
|
||||
roster.observeToolResult('first', true)
|
||||
expect(roles()[0].state).toBe('working')
|
||||
roster.observeToolResult('next', false)
|
||||
expect(roles()[0].state).toBe('completed')
|
||||
})
|
||||
|
||||
it('does not treat an evicted alias as a new invocation', () => {
|
||||
const { roster, rolesIn, setGroupKey } = harness()
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
|
||||
roster.observeToolResult('first', false)
|
||||
setGroupKey('churn')
|
||||
for (let i = 0; i < 513; i++) {
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: `other-${i}`, tool_use_id: `tool-${i}` })
|
||||
)
|
||||
}
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
|
||||
expect(rolesIn(TURN_1)[0].state).toBe('completed')
|
||||
})
|
||||
|
||||
it('bounds invocation history and refuses to reopen beyond the retained budget', () => {
|
||||
const { roster, roles } = harness()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` }))
|
||||
if (i >= 16) {
|
||||
expect(roles()[0].state).toBe('unverifiable')
|
||||
}
|
||||
roster.observeToolResult(`tool-${i}`, false)
|
||||
}
|
||||
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' }))
|
||||
expect(roles()[0].state).toBe('unverifiable')
|
||||
})
|
||||
})
|
||||
|
||||
it('merges an explicit foreground patch without clearing on absent metadata', () => {
|
||||
const { roster, roles } = harness()
|
||||
roster.observeSystemFrame(
|
||||
started({ task_id: 'task-1', tool_use_id: 'tool', is_backgrounded: true })
|
||||
)
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { description: 'Audit' } })
|
||||
)
|
||||
roster.observeToolResult('tool', false)
|
||||
expect(roles()[0].state).toBe('working')
|
||||
roster.observeSystemFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: false } })
|
||||
)
|
||||
roster.observeToolResult('tool', false)
|
||||
expect(roles()[0].state).toBe('completed')
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
// The Claude subagent roster: one journal row per turn that spawned children.
|
||||
//
|
||||
// Entries are built from `task_started`, never from child traffic: a
|
||||
// BACKGROUNDED subagent emits no child frames at all, so a roster fed by
|
||||
// `parent_tool_use_id` alone would leave every one of them an unlabelled row
|
||||
// forever. Child traffic only creates an entry for CLI releases that announce
|
||||
// no task frames.
|
||||
//
|
||||
// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is
|
||||
// the key and tool ids are aliases; keying on the tool id would duplicate the
|
||||
// child on every resume. Outcomes latch within an invocation; a new spawn
|
||||
// alias can reopen it, and authoritative evidence can correct lost contact.
|
||||
|
||||
import {
|
||||
canReplaceSubagentState,
|
||||
isTerminalSubagentState
|
||||
} from '../../shared/native-chat-subagent-summary'
|
||||
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
|
||||
import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row'
|
||||
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
|
||||
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
|
||||
import {
|
||||
applyClaudeSubagentInvocation,
|
||||
claimClaudeSubagentLabel,
|
||||
type RosterGroup,
|
||||
type TrackedEntry
|
||||
} from './claude-subagent-roster-state'
|
||||
|
||||
/** Spawn-group rows kept live per session, and children per row. Both bound an
|
||||
* event-accumulated map that no provider snapshot ever prunes. */
|
||||
const MAX_SUBAGENT_GROUPS = 32
|
||||
const MAX_SUBAGENTS_PER_GROUP = 64
|
||||
|
||||
/** The turn a group belongs to when Claude reports a task outside any turn. */
|
||||
const OUTSIDE_TURN = 'outside-turn'
|
||||
|
||||
const UNLABELLED_AGENT = 'subagent'
|
||||
|
||||
export type ClaudeSubagentRosterDeps = {
|
||||
sink: StructuredAgentSessionEventSink
|
||||
/** The turn that owns children spawned right now; null outside any turn. */
|
||||
currentGroupKey: () => string | null
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export class ClaudeSubagentRoster {
|
||||
private readonly groups = new Map<string, RosterGroup>()
|
||||
/** Canonical id → the group holding its entry, so a late update for a child
|
||||
* from an earlier turn revises that turn's row instead of the live one. */
|
||||
private readonly groupIdByEntry = new Map<string, string>()
|
||||
private readonly ids = new ClaudeSubagentIds()
|
||||
/** Set by ANY `task_started`, including one the subagent filter rejects. Once
|
||||
* this CLI has proven it declares its tasks, child traffic for an id it never
|
||||
* announced is a nested tool or a grandchild, not a subagent. */
|
||||
private announcesTasks = false
|
||||
private readonly now: () => number
|
||||
|
||||
constructor(private readonly deps: ClaudeSubagentRosterDeps) {
|
||||
this.now = deps.now ?? (() => Date.now())
|
||||
}
|
||||
|
||||
/** Consume a `message:system:task_*` frame. Returns false when it is not one. */
|
||||
observeSystemFrame(message: Record<string, unknown>): boolean {
|
||||
const frame = readClaudeSubagentTaskFrame(message)
|
||||
if (!frame) {
|
||||
return false
|
||||
}
|
||||
this.announcesTasks ||= frame.announcement
|
||||
if (frame.excluded) {
|
||||
// Child traffic may already have built a provisional row under the tool id;
|
||||
// the announcement is the first frame that says it is not a subagent.
|
||||
for (const id of [frame.taskId, frame.toolUseId]) {
|
||||
if (id !== null) {
|
||||
this.ids.exclude(id)
|
||||
this.remove(id)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) {
|
||||
return true
|
||||
}
|
||||
if (frame.toolUseId) {
|
||||
this.ids.alias(frame.toolUseId, frame.taskId)
|
||||
}
|
||||
const located =
|
||||
this.locate(frame.taskId) ??
|
||||
(frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null)
|
||||
if (!located) {
|
||||
if (frame.announcesSubagent) {
|
||||
this.create(
|
||||
frame.taskId,
|
||||
frame.label,
|
||||
frame.state ?? 'working',
|
||||
frame.backgrounded ?? false,
|
||||
frame.toolUseId
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
const tracked = located.group.entries.get(frame.taskId)
|
||||
if (tracked && !applyClaudeSubagentInvocation(tracked, frame, this.now)) {
|
||||
return true
|
||||
}
|
||||
this.revise(located.group, frame.taskId, {
|
||||
label: frame.label,
|
||||
state: frame.state,
|
||||
backgrounded: frame.backgrounded
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame carrying `parent_tool_use_id` — the child's own traffic. It refreshes
|
||||
* nothing on an announced child; it exists so a CLI release that sends no task
|
||||
* frames still shows the subagent it is running.
|
||||
*/
|
||||
observeChildActivity(parentToolUseId: string): void {
|
||||
const canonical = this.ids.canonical(parentToolUseId)
|
||||
if (this.ids.isExcluded(parentToolUseId, canonical)) {
|
||||
return
|
||||
}
|
||||
if (this.locate(canonical)) {
|
||||
return
|
||||
}
|
||||
if (this.announcesTasks) {
|
||||
// A nested Task, a workflow child, or a grandchild parented to a tool id
|
||||
// inside the sidechain all reach here. This CLI announces what it spawns,
|
||||
// so an id it never declared cannot be a subagent — and a row invented for
|
||||
// one is unlabelled forever and can only ever end `unverifiable`. The
|
||||
// bounded exclusion set cannot cover an id that was never announced.
|
||||
return
|
||||
}
|
||||
if (!isBoundedClaudeTaskId(canonical)) {
|
||||
// `claudeTaskId` rejects an over-long announced id rather than truncating
|
||||
// it; a provisional id becomes the same durable entry key, so it cannot
|
||||
// enter under a looser rule.
|
||||
return
|
||||
}
|
||||
this.create(canonical, null, 'working', false, parentToolUseId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent turn's tool result for a spawn call. It settles a foreground
|
||||
* child, whose result IS the turn's evidence the child finished. A backgrounded
|
||||
* child's spawn call returns immediately while the child keeps running, so its
|
||||
* result proves nothing and is ignored.
|
||||
*/
|
||||
observeToolResult(toolUseId: string, failed: boolean): void {
|
||||
const canonical = this.ids.canonical(toolUseId)
|
||||
const located = this.locate(canonical)
|
||||
if (
|
||||
!located ||
|
||||
located.tracked.invocationIds === null ||
|
||||
located.tracked.backgrounded ||
|
||||
(located.tracked.toolUseId !== null && located.tracked.toolUseId !== toolUseId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.revise(located.group, canonical, {
|
||||
label: null,
|
||||
state: failed ? 'failed' : 'completed',
|
||||
backgrounded: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent turn ended. A foreground child still reported as working will
|
||||
* never be settled by an event, so it becomes `unverifiable`: contact was
|
||||
* lost, which is NOT evidence the child exited. A backgrounded child was
|
||||
* explicitly told to outlive the turn and is left alone.
|
||||
*/
|
||||
settleTurn(groupKey: string | null): void {
|
||||
// Only the group this key names. `OUTSIDE_TURN` belongs to no turn, so an
|
||||
// unrelated turn ending is no evidence about a child announced outside it.
|
||||
// `settleSession` reaches what no turn does.
|
||||
this.sweep(this.groups.get(groupKey ?? OUTSIDE_TURN), false)
|
||||
}
|
||||
|
||||
/** The provider is gone. Nothing more will arrive for any child, backgrounded
|
||||
* or not, so every one of them loses contact at once. */
|
||||
settleSession(): void {
|
||||
for (const group of this.groups.values()) {
|
||||
this.sweep(group, true)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// Teardown paths reach here without an `ended` event, so a row still
|
||||
// reporting `working` would have nothing left to revise it. A session that
|
||||
// did settle first leaves every child terminal, so this writes nothing.
|
||||
this.settleSession()
|
||||
this.groups.clear()
|
||||
this.groupIdByEntry.clear()
|
||||
this.ids.clear()
|
||||
this.announcesTasks = false
|
||||
}
|
||||
|
||||
private sweep(group: RosterGroup | undefined, includeBackgrounded: boolean): void {
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
let changed = false
|
||||
for (const [id, tracked] of group.entries) {
|
||||
if (isTerminalSubagentState(tracked.entry.state)) {
|
||||
continue
|
||||
}
|
||||
if (tracked.backgrounded && !includeBackgrounded) {
|
||||
continue
|
||||
}
|
||||
group.entries.set(id, {
|
||||
...tracked,
|
||||
entry: { ...tracked.entry, state: 'unverifiable', settledAt: this.now() }
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
this.write(group)
|
||||
}
|
||||
}
|
||||
|
||||
private create(
|
||||
id: string,
|
||||
label: string | null,
|
||||
state: NativeChatSubagentEntry['state'],
|
||||
backgrounded: boolean,
|
||||
toolUseId: string | null
|
||||
): void {
|
||||
const group = this.groupFor()
|
||||
if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) {
|
||||
return
|
||||
}
|
||||
group.admittedEntries += 1
|
||||
const now = this.now()
|
||||
const labelBase = label ?? UNLABELLED_AGENT
|
||||
group.entries.set(id, {
|
||||
backgrounded,
|
||||
toolUseId,
|
||||
invocationIds: new Set(toolUseId ? [toolUseId] : []),
|
||||
labelBase,
|
||||
entry: {
|
||||
id,
|
||||
label: claimClaudeSubagentLabel(group, labelBase),
|
||||
state,
|
||||
startedAt: now,
|
||||
...(isTerminalSubagentState(state) ? { settledAt: now } : {})
|
||||
}
|
||||
})
|
||||
this.groupIdByEntry.set(id, group.groupId)
|
||||
this.write(group)
|
||||
}
|
||||
|
||||
private revise(
|
||||
group: RosterGroup,
|
||||
id: string,
|
||||
change: {
|
||||
label: string | null
|
||||
state: NativeChatSubagentEntry['state'] | null
|
||||
backgrounded: boolean | null
|
||||
}
|
||||
): void {
|
||||
const tracked = group.entries.get(id)
|
||||
if (!tracked) {
|
||||
return
|
||||
}
|
||||
const next: TrackedEntry = {
|
||||
...tracked,
|
||||
backgrounded: change.backgrounded ?? tracked.backgrounded,
|
||||
entry: { ...tracked.entry }
|
||||
}
|
||||
// A provisional row built from child traffic takes the real name the first
|
||||
// announcement carries; an announced row keeps the name it was given.
|
||||
if (
|
||||
change.label &&
|
||||
tracked.labelBase === UNLABELLED_AGENT &&
|
||||
change.label !== UNLABELLED_AGENT
|
||||
) {
|
||||
next.labelBase = change.label
|
||||
next.entry.label = claimClaudeSubagentLabel(group, change.label)
|
||||
}
|
||||
// Proven outcomes latch; lost contact can still receive a later verdict.
|
||||
if (change.state && canReplaceSubagentState(tracked.entry.state, change.state)) {
|
||||
next.entry.state = change.state
|
||||
if (isTerminalSubagentState(change.state)) {
|
||||
next.entry.settledAt = this.now()
|
||||
}
|
||||
}
|
||||
group.entries.set(id, next)
|
||||
this.write(group)
|
||||
}
|
||||
|
||||
/** Re-key a provisional entry from its tool id onto the canonical task id the
|
||||
* announcement finally named, so the child does not appear twice. */
|
||||
private adopt(toolUseId: string, taskId: string): { group: RosterGroup } | null {
|
||||
if (toolUseId === taskId) {
|
||||
return null
|
||||
}
|
||||
const located = this.locate(toolUseId)
|
||||
if (!located) {
|
||||
return null
|
||||
}
|
||||
located.group.entries.delete(toolUseId)
|
||||
located.group.entries.set(taskId, {
|
||||
...located.tracked,
|
||||
entry: { ...located.tracked.entry, id: taskId }
|
||||
})
|
||||
this.groupIdByEntry.delete(toolUseId)
|
||||
this.groupIdByEntry.set(taskId, located.group.groupId)
|
||||
return { group: located.group }
|
||||
}
|
||||
|
||||
private remove(id: string): void {
|
||||
const located = this.locate(id)
|
||||
if (!located) {
|
||||
return
|
||||
}
|
||||
located.group.entries.delete(id)
|
||||
this.groupIdByEntry.delete(id)
|
||||
this.write(located.group)
|
||||
}
|
||||
|
||||
private locate(id: string): { group: RosterGroup; tracked: TrackedEntry } | null {
|
||||
const groupId = this.groupIdByEntry.get(id)
|
||||
const group = groupId === undefined ? undefined : this.groups.get(groupId)
|
||||
const tracked = group?.entries.get(id)
|
||||
return group && tracked ? { group, tracked } : null
|
||||
}
|
||||
|
||||
private groupFor(): RosterGroup {
|
||||
const groupId = this.deps.currentGroupKey() ?? OUTSIDE_TURN
|
||||
const existing = this.groups.get(groupId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const group: RosterGroup = {
|
||||
groupId,
|
||||
identity: claudeSubagentGroupIdentity(groupId),
|
||||
entries: new Map(),
|
||||
admittedEntries: 0,
|
||||
claimedLabels: new Set(),
|
||||
lastSerialized: null
|
||||
}
|
||||
this.groups.set(groupId, group)
|
||||
while (this.groups.size > MAX_SUBAGENT_GROUPS) {
|
||||
const oldest = this.groups.keys().next()
|
||||
if (oldest.done || oldest.value === groupId) {
|
||||
break
|
||||
}
|
||||
const evicted = this.groups.get(oldest.value)
|
||||
// Once the group leaves the map nothing can reach its children again —
|
||||
// not even a session sweep — so contact is lost here.
|
||||
this.sweep(evicted, true)
|
||||
for (const id of evicted?.entries.keys() ?? []) {
|
||||
this.groupIdByEntry.delete(id)
|
||||
}
|
||||
this.groups.delete(oldest.value)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
private write(group: RosterGroup): void {
|
||||
const agents = [...group.entries.values()].map((tracked) => tracked.entry)
|
||||
const options = { coalescingKey: `claude-subagents:${group.groupId}` }
|
||||
if (agents.length === 0) {
|
||||
// The row's last child turned out not to be a subagent. An empty roster is
|
||||
// not a roster of nothing, so the row goes rather than reading "Ran 0".
|
||||
if (group.lastSerialized !== null) {
|
||||
group.lastSerialized = null
|
||||
this.deps.sink.appendTombstone(group.identity, options)
|
||||
this.deps.sink.publish()
|
||||
}
|
||||
return
|
||||
}
|
||||
const body = claudeSubagentGroupBody(group.groupId, agents)
|
||||
const serialized = JSON.stringify(body)
|
||||
if (serialized === group.lastSerialized) {
|
||||
// Nothing changed — a duplicate delivery must not burn a revision.
|
||||
return
|
||||
}
|
||||
group.lastSerialized = serialized
|
||||
this.deps.sink.appendItem(group.identity, body, options)
|
||||
// Publish keeps the sink's own coalescing slot: sharing the row's key makes
|
||||
// each queued publish evict the append it was meant to flush.
|
||||
this.deps.sink.publish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
|
||||
|
||||
function system(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
|
||||
return { type: 'system', subtype, session_id: 'claude-session', ...fields }
|
||||
}
|
||||
|
||||
describe('readClaudeSubagentTaskFrame', () => {
|
||||
it('ignores frames that are not task frames', () => {
|
||||
expect(readClaudeSubagentTaskFrame({ type: 'assistant', subtype: 'task_started' })).toBeNull()
|
||||
expect(readClaudeSubagentTaskFrame(system('init', { task_id: 'task-1' }))).toBeNull()
|
||||
expect(readClaudeSubagentTaskFrame(system('task_started', {}))).toBeNull()
|
||||
expect(readClaudeSubagentTaskFrame(system('task_started', { task_id: '' }))).toBeNull()
|
||||
})
|
||||
|
||||
describe('task_type triage', () => {
|
||||
it('announces a local_agent task', () => {
|
||||
const frame = readClaudeSubagentTaskFrame(
|
||||
system('task_started', {
|
||||
task_id: 'task-1',
|
||||
tool_use_id: 'toolu_1',
|
||||
task_type: 'local_agent',
|
||||
subagent_type: 'code-reviewer',
|
||||
description: 'Review the diff'
|
||||
})
|
||||
)
|
||||
expect(frame).toMatchObject({
|
||||
taskId: 'task-1',
|
||||
toolUseId: 'toolu_1',
|
||||
label: 'Review the diff',
|
||||
announcesSubagent: true,
|
||||
excluded: false
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes a backgrounded shell command even though it carries a tool_use_id', () => {
|
||||
const frame = readClaudeSubagentTaskFrame(
|
||||
system('task_started', {
|
||||
task_id: 'task-bash',
|
||||
tool_use_id: 'toolu_bash',
|
||||
task_type: 'local_bash',
|
||||
description: 'sleep 20',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(frame).toMatchObject({
|
||||
taskId: 'task-bash',
|
||||
toolUseId: 'toolu_bash',
|
||||
announcesSubagent: false,
|
||||
excluded: true
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes workflows and monitors', () => {
|
||||
for (const taskType of ['local_workflow', 'monitor']) {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_started', { task_id: `task-${taskType}`, task_type: taskType })
|
||||
)
|
||||
).toMatchObject({ announcesSubagent: false, excluded: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('caps a subagent_type label the way a description is capped', () => {
|
||||
const frame = readClaudeSubagentTaskFrame(
|
||||
system('task_started', { task_id: 'task-1', subagent_type: 'a'.repeat(900) })
|
||||
)
|
||||
// The roster stores this label verbatim, so nothing downstream bounds it.
|
||||
expect(frame?.label).toHaveLength(512)
|
||||
})
|
||||
|
||||
it('falls back to subagent_type only when the release sends no task_type', () => {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_started', { task_id: 'task-old', subagent_type: 'explorer' })
|
||||
)
|
||||
).toMatchObject({ announcesSubagent: true, label: 'explorer' })
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(system('task_started', { task_id: 'task-bare' }))
|
||||
).toMatchObject({ announcesSubagent: false, excluded: true })
|
||||
// A type this build does not recognise is not an agent on subagent_type's word.
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_started', {
|
||||
task_id: 'task-new',
|
||||
task_type: 'local_something_new',
|
||||
subagent_type: 'explorer'
|
||||
})
|
||||
)
|
||||
).toMatchObject({ announcesSubagent: false, excluded: true })
|
||||
})
|
||||
|
||||
it('excludes ambient housekeeping tasks', () => {
|
||||
for (const suppression of [{ skip_transcript: true }, { ambient: true }]) {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_started', {
|
||||
task_id: 'task-ambient',
|
||||
task_type: 'local_agent',
|
||||
subagent_type: 'watcher',
|
||||
...suppression
|
||||
})
|
||||
)
|
||||
).toMatchObject({ announcesSubagent: false, excluded: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('status', () => {
|
||||
it('collapses every in-flight status to working', () => {
|
||||
for (const status of ['pending', 'running', 'paused']) {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { status } })
|
||||
)
|
||||
).toMatchObject({ state: 'working' })
|
||||
}
|
||||
})
|
||||
|
||||
it('maps the settled statuses onto the carrier vocabulary', () => {
|
||||
const mapped: [string, string][] = [
|
||||
['completed', 'completed'],
|
||||
['failed', 'failed'],
|
||||
['killed', 'stopped'],
|
||||
['stopped', 'stopped']
|
||||
]
|
||||
for (const [status, state] of mapped) {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { status } })
|
||||
)
|
||||
).toMatchObject({ state })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports no state for a status it cannot map', () => {
|
||||
for (const status of ['__proto__', 'toString', 'invented', 7, null]) {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { status } })
|
||||
)
|
||||
).toMatchObject({ state: null })
|
||||
}
|
||||
})
|
||||
|
||||
it('treats progress as no lifecycle verdict', () => {
|
||||
for (const subtype of ['task_progress']) {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system(subtype, { task_id: 'task-1', status: 'completed', patch: { status: 'failed' } })
|
||||
)
|
||||
).toMatchObject({ state: null })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the notification verdict from its top-level status', () => {
|
||||
for (const state of ['completed', 'failed', 'stopped']) {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_notification', {
|
||||
task_id: 'task-1',
|
||||
status: state,
|
||||
patch: { status: 'running' }
|
||||
})
|
||||
)
|
||||
).toMatchObject({ state })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the backgrounded flag from the frame or its patch', () => {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
).toMatchObject({ backgrounded: true })
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } })
|
||||
)
|
||||
).toMatchObject({ backgrounded: true })
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(system('task_updated', { task_id: 'task-1', patch: {} }))
|
||||
).toMatchObject({ backgrounded: null })
|
||||
})
|
||||
|
||||
it('collapses a multi-line description into one bounded label', () => {
|
||||
expect(
|
||||
readClaudeSubagentTaskFrame(
|
||||
system('task_updated', {
|
||||
task_id: 'task-1',
|
||||
patch: { description: ' audit\n the lockfile ' }
|
||||
})
|
||||
)
|
||||
).toMatchObject({ label: 'audit the lockfile' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
// Claude's declarative task protocol, read as subagent roster events.
|
||||
//
|
||||
// `local_agent`, `local_workflow` and `local_bash` tasks all arrive on the same
|
||||
// `message:system:task_*` channel and ALL carry a `tool_use_id`, so id presence
|
||||
// discriminates nothing: filtering on it alone puts a backgrounded `sleep 20` in
|
||||
// the subagent roster. `task_type` is the discriminator, with `subagent_type`
|
||||
// covering CLI releases that predate it.
|
||||
|
||||
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
|
||||
import {
|
||||
classifyClaudeBackgroundTaskKind,
|
||||
claudeTaskDescription,
|
||||
claudeTaskId,
|
||||
isBoundedClaudeTaskId
|
||||
} from './claude-background-task-tracker'
|
||||
import { claudeRecord, claudeText } from './claude-structured-item-translation'
|
||||
|
||||
const TASK_SUBTYPES: ReadonlySet<string> = new Set([
|
||||
'task_started',
|
||||
'task_updated',
|
||||
'task_progress',
|
||||
'task_notification'
|
||||
])
|
||||
|
||||
/** Provider status → the carrier's vocabulary. `killed` and `stopped` both mean
|
||||
* the task was deliberately ended, which the carrier calls `stopped`; every
|
||||
* in-flight status collapses to `working`. A Map, not an object, so a payload
|
||||
* carrying `__proto__` as its status cannot resolve to an inherited value. */
|
||||
const TASK_STATES: ReadonlyMap<string, NativeChatSubagentState> = new Map([
|
||||
['pending', 'working'],
|
||||
['running', 'working'],
|
||||
['paused', 'working'],
|
||||
['completed', 'completed'],
|
||||
['failed', 'failed'],
|
||||
['killed', 'stopped'],
|
||||
['stopped', 'stopped']
|
||||
] satisfies [string, NativeChatSubagentState][])
|
||||
|
||||
export type ClaudeSubagentTaskFrame = {
|
||||
/** Canonical, resume-stable id — the roster key. */
|
||||
taskId: string
|
||||
/** Re-minted when Claude re-announces a resumed task, so it is only an alias. */
|
||||
toolUseId: string | null
|
||||
label: string | null
|
||||
/** null when the frame reported no lifecycle status. */
|
||||
state: NativeChatSubagentState | null
|
||||
backgrounded: boolean | null
|
||||
/** Any `task_started`, subagent or not. Proof this CLI declares its tasks. */
|
||||
announcement: boolean
|
||||
/** `task_started` for a task the roster should show. Only an announcement
|
||||
* creates an entry: an update carries no `task_type`, so honouring one for an
|
||||
* unknown id would roster whatever else shares this channel. */
|
||||
announcesSubagent: boolean
|
||||
/** Ambient housekeeping, or a task that is not a subagent at all. Its ids must
|
||||
* never reach the roster, by this frame or by later child traffic. */
|
||||
excluded: boolean
|
||||
}
|
||||
|
||||
/** True when the task Claude announced is a subagent rather than a backgrounded
|
||||
* shell command or a workflow. */
|
||||
export function isClaudeSubagentTask(message: Record<string, unknown>): boolean {
|
||||
if (classifyClaudeBackgroundTaskKind(message.task_type) === 'agent') {
|
||||
return true
|
||||
}
|
||||
// Releases predating `task_type` still name the child in `subagent_type`. A
|
||||
// task_type Orca does not recognise is NOT covered: it is a type this build
|
||||
// has no reason to believe is an agent.
|
||||
return (
|
||||
(message.task_type === undefined || message.task_type === null) &&
|
||||
claudeText(message.subagent_type) !== null
|
||||
)
|
||||
}
|
||||
|
||||
function taskState(value: unknown): NativeChatSubagentState | null {
|
||||
return typeof value === 'string' ? (TASK_STATES.get(value) ?? null) : null
|
||||
}
|
||||
|
||||
export function readClaudeSubagentTaskFrame(
|
||||
message: Record<string, unknown>
|
||||
): ClaudeSubagentTaskFrame | null {
|
||||
if (message.type !== 'system') {
|
||||
return null
|
||||
}
|
||||
const subtype = claudeText(message.subtype)
|
||||
if (!subtype || !TASK_SUBTYPES.has(subtype)) {
|
||||
return null
|
||||
}
|
||||
const taskId = claudeTaskId(message)
|
||||
if (!taskId) {
|
||||
return null
|
||||
}
|
||||
const patch = claudeRecord(message.patch)
|
||||
const toolUseId = claudeText(message.tool_use_id) ?? claudeText(patch?.tool_use_id)
|
||||
const announcement = subtype === 'task_started'
|
||||
// Housekeeping Claude runs for itself; the user never asked for it.
|
||||
const suppressed = message.ambient === true || message.skip_transcript === true
|
||||
const subagent = announcement && !suppressed && isClaudeSubagentTask(message)
|
||||
return {
|
||||
taskId,
|
||||
toolUseId: toolUseId && isBoundedClaudeTaskId(toolUseId) ? toolUseId : null,
|
||||
label:
|
||||
claudeTaskDescription(message.description) ??
|
||||
claudeTaskDescription(patch?.description) ??
|
||||
// Bounded like a description: the roster stores whatever this returns.
|
||||
(announcement ? (claudeTaskDescription(message.subagent_type) ?? null) : null),
|
||||
// Notifications carry terminal evidence; progress carries usage only.
|
||||
state:
|
||||
subtype === 'task_notification'
|
||||
? taskState(message.status)
|
||||
: announcement || subtype === 'task_updated'
|
||||
? taskState(patch?.status ?? message.status)
|
||||
: null,
|
||||
backgrounded:
|
||||
typeof patch?.is_backgrounded === 'boolean'
|
||||
? patch.is_backgrounded
|
||||
: typeof message.is_backgrounded === 'boolean'
|
||||
? message.is_backgrounded
|
||||
: null,
|
||||
announcement,
|
||||
announcesSubagent: subagent,
|
||||
excluded: announcement && !subagent
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
compareCodexSessionBackfillDates,
|
||||
expandCodexSessionBackfillDatesThroughToday,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseCodexSessionBackfillDates,
|
||||
subtractCodexSessionBackfillDates
|
||||
} from './codex-session-backfill-scan-dates'
|
||||
import type { CodexSessionBackfillDate } from './codex-session-backfill-types'
|
||||
|
||||
describe('codex session backfill scan dates', () => {
|
||||
it('reads UTC parts so a local evening never lands on the wrong directory', () => {
|
||||
@@ -100,3 +101,63 @@ describe('codex session backfill scan dates', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bounded backfill range construction', () => {
|
||||
it('does not allocate rejected dates for a decades-old pending marker', () => {
|
||||
const advance = vi.spyOn(Date.prototype, 'setUTCDate')
|
||||
try {
|
||||
expect(
|
||||
expandCodexSessionBackfillDatesThroughToday(
|
||||
[['2000', '01', '01']],
|
||||
['2026', '09', '07'],
|
||||
31
|
||||
)
|
||||
).toBeNull()
|
||||
expect(advance).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
advance.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps exact, fractional, leap-day and future-clock bounds', () => {
|
||||
const dates = [['2024', '02', '28']] as [string, string, string][]
|
||||
expect(expandCodexSessionBackfillDatesThroughToday(dates, ['2024', '03', '01'], 3)).toEqual([
|
||||
['2024', '02', '28'],
|
||||
['2024', '02', '29'],
|
||||
['2024', '03', '01']
|
||||
])
|
||||
expect(expandCodexSessionBackfillDatesThroughToday(dates, ['2024', '03', '01'], 2.5)).toBeNull()
|
||||
expect(
|
||||
expandCodexSessionBackfillDatesThroughToday([['2024', '03', '01']], ['2024', '02', '28'], 3)
|
||||
).toEqual(expandCodexSessionBackfillDatesThroughToday(dates, ['2024', '03', '01'], 3))
|
||||
})
|
||||
|
||||
// The arithmetic cardinality gate must admit and reject exactly what enumerating the range
|
||||
// would, on every calendar edge that has ever broken a day count: leap days, century rules,
|
||||
// year rollover, and the DST switches the UTC-only arithmetic has to stay indifferent to.
|
||||
it.each<[string, CodexSessionBackfillDate, CodexSessionBackfillDate]>([
|
||||
['leap February', ['2024', '02', '27'], ['2024', '03', '02']],
|
||||
['non-leap February', ['2023', '02', '27'], ['2023', '03', '02']],
|
||||
['US spring-forward', ['2024', '03', '09'], ['2024', '03', '11']],
|
||||
['US fall-back', ['2024', '11', '02'], ['2024', '11', '04']],
|
||||
['EU spring-forward', ['2025', '03', '29'], ['2025', '03', '31']],
|
||||
['southern-hemisphere DST', ['2025', '04', '05'], ['2025', '04', '07']],
|
||||
['year rollover', ['2024', '12', '30'], ['2025', '01', '02']],
|
||||
['leap century', ['1999', '12', '31'], ['2000', '01', '02']],
|
||||
['non-leap century', ['2100', '02', '27'], ['2100', '03', '02']],
|
||||
['30-day month end', ['2026', '04', '29'], ['2026', '05', '02']],
|
||||
['single day', ['2026', '09', '07'], ['2026', '09', '07']]
|
||||
])('matches the enumerated range at the %s cap boundary', (_label, from, to) => {
|
||||
const start = new Date(Date.UTC(Number(from[0]), Number(from[1]) - 1, Number(from[2])))
|
||||
const end = new Date(Date.UTC(Number(to[0]), Number(to[1]) - 1, Number(to[2])))
|
||||
const enumerated = getCodexSessionBackfillDatesBetween(start, end)
|
||||
const pending = [from]
|
||||
|
||||
expect(expandCodexSessionBackfillDatesThroughToday(pending, to, enumerated.length)).toEqual(
|
||||
enumerated
|
||||
)
|
||||
expect(
|
||||
expandCodexSessionBackfillDatesThroughToday(pending, to, enumerated.length - 1)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,8 +99,13 @@ export function expandCodexSessionBackfillDatesThroughToday(
|
||||
return []
|
||||
}
|
||||
const bounds = mergeCodexSessionBackfillDates(dates, [today])
|
||||
const range = getCodexSessionBackfillDatesBetween(toUtcDate(bounds[0]), toUtcDate(bounds.at(-1)!))
|
||||
return range.length > maxDates ? null : range
|
||||
const first = toUtcDate(bounds[0])
|
||||
const last = toUtcDate(bounds.at(-1)!)
|
||||
const dateCount = (last.getTime() - first.getTime()) / 86_400_000 + 1
|
||||
if (dateCount > maxDates) {
|
||||
return null
|
||||
}
|
||||
return getCodexSessionBackfillDatesBetween(first, last)
|
||||
}
|
||||
|
||||
function toUtcDate([year, month, day]: readonly string[]): Date {
|
||||
|
||||
@@ -56,7 +56,10 @@ function upsertTrustBlocks(
|
||||
hash: string,
|
||||
explicitEnabled?: boolean
|
||||
): string {
|
||||
const ranges = getUniqueTrustBlockRanges(content, keys)
|
||||
const ranges = findHookTrustBlockRanges(
|
||||
content,
|
||||
new Set(keys.map(normalizeCodexHookTrustLookupKey))
|
||||
)
|
||||
if (ranges.length === 0) {
|
||||
return appendTrustBlocks(content, keys, hash, explicitEnabled ?? true)
|
||||
}
|
||||
@@ -74,21 +77,6 @@ function upsertTrustBlocks(
|
||||
return deduped + content.slice(cursor)
|
||||
}
|
||||
|
||||
function getUniqueTrustBlockRanges(
|
||||
content: string,
|
||||
keys: readonly string[]
|
||||
): HookTrustBlockRange[] {
|
||||
const normalizedKeys = new Set(keys.map(normalizeCodexHookTrustLookupKey))
|
||||
return findHookTrustBlockRanges(content, normalizedKeys)
|
||||
.filter(
|
||||
(range, index, ranges) =>
|
||||
ranges.findIndex(
|
||||
(candidate) => candidate.start === range.start && candidate.end === range.end
|
||||
) === index
|
||||
)
|
||||
.sort((left, right) => left.start - right.start)
|
||||
}
|
||||
|
||||
function isBlockDisabled(content: string, range: HookTrustBlockRange): boolean {
|
||||
const block = content.slice(range.headerLineEnd, range.end)
|
||||
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type * as HookTrustBlocks from './config-toml-hook-trust-blocks'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { upsertHookTrustContent } from './config-toml-hook-trust-edit'
|
||||
|
||||
const counts = vi.hoisted(() => ({ starts: 0 }))
|
||||
vi.mock('./config-toml-hook-trust-blocks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof HookTrustBlocks>()
|
||||
return {
|
||||
...actual,
|
||||
findHookTrustBlockRanges: (...args: Parameters<typeof actual.findHookTrustBlockRanges>) =>
|
||||
actual.findHookTrustBlockRanges(...args).map((range) => ({
|
||||
...range,
|
||||
get start() {
|
||||
counts.starts += 1
|
||||
return range.start
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
it('consumes monotonically scanned trust ranges without pairwise deduplication', () => {
|
||||
const header = '[hooks.state."/foo/hooks.json:pre_tool_use:0:0"]'
|
||||
const content = `${header}\nenabled = true\ntrusted_hash = "old"\n`.repeat(1000)
|
||||
counts.starts = 0
|
||||
const result = upsertHookTrustContent(content, [
|
||||
{
|
||||
sourcePath: '/foo/hooks.json',
|
||||
eventLabel: 'pre_tool_use',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: '/bin/echo hi',
|
||||
trustedHash: 'updated'
|
||||
}
|
||||
])
|
||||
expect(counts.starts).toBeLessThan(5000)
|
||||
expect(result.split(header)).toHaveLength(2)
|
||||
expect(result).toContain('trusted_hash = "updated"')
|
||||
})
|
||||
|
||||
// Dropping the old dedup+sort is only sound because the scanner advances its
|
||||
// cursor past each block it emits. Pin that precondition: if a future scanner
|
||||
// change lets ranges repeat or overlap, the upsert below would delete or widen
|
||||
// a neighbouring trust block instead of rewriting just the matched one.
|
||||
it('emits trust ranges with strictly ascending, non-overlapping spans', async () => {
|
||||
const { findHookTrustBlockRanges } = await vi.importActual<typeof HookTrustBlocks>(
|
||||
'./config-toml-hook-trust-blocks'
|
||||
)
|
||||
const key = '/foo/hooks.json:pre_tool_use:0:0'
|
||||
const header = `[hooks.state."${key}"]`
|
||||
const contents = [
|
||||
'',
|
||||
header,
|
||||
`${header}\n${header}\n`,
|
||||
`${header}\nenabled = true\n`.repeat(50),
|
||||
`${header}\r\nenabled = true\r\n`.repeat(3),
|
||||
`[x]\nv = """\n${header}\n"""\n${header}\nenabled = true\n`,
|
||||
`[x]\na = [\n${header}\n]\n${header}\nenabled = true\n`,
|
||||
`${header}\nenabled = true\n[[arr]]\nz = 1\n${header}\n`
|
||||
]
|
||||
for (const content of contents) {
|
||||
const ranges = findHookTrustBlockRanges(content, new Set([key]))
|
||||
for (const [index, range] of ranges.entries()) {
|
||||
expect(range.end).toBeGreaterThanOrEqual(range.start)
|
||||
expect(range.end).toBeLessThanOrEqual(content.length)
|
||||
if (index > 0) {
|
||||
expect(ranges[index - 1].start).toBeLessThan(range.start)
|
||||
expect(ranges[index - 1].end).toBeLessThanOrEqual(range.start)
|
||||
}
|
||||
}
|
||||
expect(new Set(ranges.map((range) => `${range.start}:${range.end}`)).size).toBe(ranges.length)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { registerSshPtyProvider, getLocalPtyProvider } from './pty'
|
||||
import { installPtyInspectIpcHandlers } from './pty/ipc/inspect'
|
||||
import { ptyOwnership } from './pty/provider/ownership-state'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../opencode/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../mimo/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/server', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../pi/titlebar-extension-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
|
||||
vi.mock('../wsl', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../telemetry/client', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../telemetry/classify-error', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../memory/pty-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-pane-account-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
describe('scoped activation PTY inventory', () => {
|
||||
const { handlers, installDaemonTestProvider } = setupPtyIpcSuite()
|
||||
|
||||
function install() {
|
||||
const localList = vi.fn(async () => [{ id: 'local', cwd: '/', title: 'shell' }])
|
||||
installDaemonTestProvider({ listProcesses: localList })
|
||||
const remoteLists = Array.from({ length: 50 }, (_, index) => {
|
||||
const list = vi.fn(async () => [
|
||||
{
|
||||
id: `ssh:host-${index}@@pty-1`,
|
||||
cwd: '/remote',
|
||||
title: 'agent',
|
||||
worktreeId: 'repo::/remote'
|
||||
}
|
||||
])
|
||||
registerSshPtyProvider(`host-${index}`, {
|
||||
...getLocalPtyProvider(),
|
||||
listProcesses: list,
|
||||
providesAgentSessionOwnerListings: () => true
|
||||
})
|
||||
return list
|
||||
})
|
||||
const startup = vi.fn(async () => {})
|
||||
installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise: startup })
|
||||
const list = (scope?: unknown) => handlers.get('pty:listSessions')!(null, scope)
|
||||
return { localList, remoteLists, startup, list }
|
||||
}
|
||||
|
||||
it('queries only the chosen SSH provider and preserves workspace and ownership evidence', async () => {
|
||||
const { list, localList, remoteLists, startup } = install()
|
||||
expect(await list({ connectionId: 'host-17' })).toEqual([
|
||||
{
|
||||
id: 'ssh:host-17@@pty-1',
|
||||
cwd: '/remote',
|
||||
title: 'agent',
|
||||
worktreeId: 'repo::/remote',
|
||||
agentOwnership: 'absent'
|
||||
}
|
||||
])
|
||||
expect(remoteLists[17]).toHaveBeenCalledOnce()
|
||||
expect(remoteLists.reduce((count, mock) => count + mock.mock.calls.length, 0)).toBe(1)
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
expect(startup).not.toHaveBeenCalled()
|
||||
expect(ptyOwnership.get('ssh:host-17@@pty-1')).toBe('host-17')
|
||||
})
|
||||
|
||||
it('waits for local startup and never visits remote providers for a local scope', async () => {
|
||||
const { list, localList, remoteLists, startup } = install()
|
||||
let release!: () => void
|
||||
startup.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const pending = list({ connectionId: null })
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
release()
|
||||
await pending
|
||||
expect(localList).toHaveBeenCalledOnce()
|
||||
expect(remoteLists.every((mock) => mock.mock.calls.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates selected-host failure and never substitutes the local inventory', async () => {
|
||||
const { list, localList, remoteLists } = install()
|
||||
remoteLists[3].mockRejectedValue(new Error('relay unavailable'))
|
||||
await expect(list({ connectionId: 'host-3' })).rejects.toThrow('relay unavailable')
|
||||
await expect(list({ connectionId: 'missing' })).rejects.toThrow('No PTY provider')
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([null, {}, { connectionId: '' }, { connectionId: 42 }])(
|
||||
'rejects malformed scope %j before inventory admission',
|
||||
async (scope) => {
|
||||
const { list, localList, remoteLists } = install()
|
||||
await expect(list(scope)).rejects.toThrow('invalid_pty_session_list_scope')
|
||||
expect(localList).not.toHaveBeenCalled()
|
||||
expect(remoteLists.every((mock) => mock.mock.calls.length === 0)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves unscoped diagnostic inventory and its remote-error fallback', async () => {
|
||||
const { list, localList, remoteLists } = install()
|
||||
remoteLists[3].mockRejectedValue(new Error('relay unavailable'))
|
||||
expect(await list()).toHaveLength(50)
|
||||
expect(localList).toHaveBeenCalledOnce()
|
||||
expect(remoteLists.every((mock) => mock.mock.calls.length === 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
PtyProcessListAdmission,
|
||||
visitPtyProcessListingsInBatches
|
||||
} from '../../../providers/pty-process-list-admission'
|
||||
import type { PtyListedSession } from '../../../../shared/pty-listed-session'
|
||||
import type { PtyListedSession, PtySessionListScope } from '../../../../shared/pty-listed-session'
|
||||
import { ptyOwnership } from '../provider/ownership-state'
|
||||
import {
|
||||
getProviderForPty,
|
||||
getProvider,
|
||||
hasPtyProviderForInspection,
|
||||
registeredPtyProviders,
|
||||
sshProviders,
|
||||
@@ -40,37 +41,58 @@ export function installPtyInspectIpcHandlers(deps: {
|
||||
)
|
||||
}
|
||||
|
||||
ipcMain.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
|
||||
const deduped = new Map<string, PtyListedSession>()
|
||||
const admission = new PtyProcessListAdmission()
|
||||
await visitPtyProcessListingsInBatches(
|
||||
registeredPtyProviders(),
|
||||
({ provider, connectionId }) =>
|
||||
connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []),
|
||||
({ provider, connectionId }, sessions) => {
|
||||
for (const rawSession of sessions) {
|
||||
const session = admission.admit(rawSession)
|
||||
// Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider.
|
||||
ptyOwnership.set(session.id, connectionId)
|
||||
deduped.set(session.id, {
|
||||
id: session.id,
|
||||
cwd: session.cwd,
|
||||
title: session.title,
|
||||
// Why: the renderer's binding map is empty during restore, so ownership is the only
|
||||
// liveness evidence it has. Absence is authoritative only from a provider that
|
||||
// serializes claims — otherwise it is 'unknown', never 'absent' (#8459).
|
||||
agentOwnership:
|
||||
(session.agentSessionOwners?.length ?? 0) > 0
|
||||
? 'present'
|
||||
: provider.providesAgentSessionOwnerListings?.(session.id) === true
|
||||
? 'absent'
|
||||
: 'unknown'
|
||||
})
|
||||
ipcMain.handle(
|
||||
'pty:listSessions',
|
||||
async (_event, scope?: PtySessionListScope): Promise<PtyListedSession[]> => {
|
||||
if (scope !== undefined) {
|
||||
if (
|
||||
!scope ||
|
||||
(scope.connectionId !== null &&
|
||||
(typeof scope.connectionId !== 'string' || !scope.connectionId.trim()))
|
||||
) {
|
||||
throw new Error('invalid_pty_session_list_scope')
|
||||
}
|
||||
// Select the daemon only after startup has handed off ownership.
|
||||
if (scope.connectionId === null) {
|
||||
await getLocalPtyProviderStartupPromise()
|
||||
}
|
||||
}
|
||||
)
|
||||
return Array.from(deduped.values())
|
||||
})
|
||||
const deduped = new Map<string, PtyListedSession>()
|
||||
const admission = new PtyProcessListAdmission()
|
||||
await visitPtyProcessListingsInBatches(
|
||||
scope === undefined
|
||||
? registeredPtyProviders()
|
||||
: [{ provider: getProvider(scope.connectionId), connectionId: scope.connectionId }],
|
||||
({ provider, connectionId }) =>
|
||||
connectionId === null || scope !== undefined
|
||||
? provider.listProcesses()
|
||||
: provider.listProcesses().catch(() => []),
|
||||
({ provider, connectionId }, sessions) => {
|
||||
for (const rawSession of sessions) {
|
||||
const session = admission.admit(rawSession)
|
||||
// Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider.
|
||||
ptyOwnership.set(session.id, connectionId)
|
||||
deduped.set(session.id, {
|
||||
id: session.id,
|
||||
cwd: session.cwd,
|
||||
title: session.title,
|
||||
...(session.worktreeId !== undefined ? { worktreeId: session.worktreeId } : {}),
|
||||
// Why: the renderer's binding map is empty during restore, so ownership is the only
|
||||
// liveness evidence it has. Absence is authoritative only from a provider that
|
||||
// serializes claims — otherwise it is 'unknown', never 'absent' (#8459).
|
||||
agentOwnership:
|
||||
(session.agentSessionOwners?.length ?? 0) > 0
|
||||
? 'present'
|
||||
: provider.providesAgentSessionOwnerListings?.(session.id) === true
|
||||
? 'absent'
|
||||
: 'unknown'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
return Array.from(deduped.values())
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'pty:getAuthoritativeBufferSnapshotCapabilities',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortByUpdatedAtDescending } from '../../shared/updated-at-order'
|
||||
import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types'
|
||||
import { acquire, release } from './request-queue'
|
||||
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
|
||||
@@ -18,9 +19,7 @@ function clampLimit(limit: number | undefined, fallback = 30): number {
|
||||
}
|
||||
|
||||
function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] {
|
||||
return issues
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.slice(0, limit)
|
||||
return sortByUpdatedAtDescending(issues).slice(0, limit)
|
||||
}
|
||||
|
||||
function filterToJql(filter: JiraIssueFilter): string {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortByUpdatedAtDescending } from '../../shared/updated-at-order'
|
||||
import type { LinearIssue } from '../../shared/linear/issue-types'
|
||||
import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
|
||||
import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear/issue-read-limits'
|
||||
@@ -30,18 +31,14 @@ export async function mapIssueForWorkspace(
|
||||
}
|
||||
|
||||
export function sortAndLimitIssues(issues: LinearIssue[], limit: number): LinearIssue[] {
|
||||
return issues
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.slice(0, limit)
|
||||
return sortByUpdatedAtDescending(issues).slice(0, limit)
|
||||
}
|
||||
|
||||
export function sortLimitAndDescribeIssues(
|
||||
issues: LinearIssue[],
|
||||
limit: number
|
||||
): { items: LinearIssue[]; clipped: boolean } {
|
||||
const sorted = issues.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
const sorted = sortByUpdatedAtDescending(issues)
|
||||
return {
|
||||
items: sorted.slice(0, limit),
|
||||
clipped: sorted.length > limit
|
||||
|
||||
@@ -432,6 +432,86 @@ describe('payload bounds on import', () => {
|
||||
expect(body.output.byteLength).toBe(64 * 1024)
|
||||
expect(body.output.head).toHaveLength(1_024)
|
||||
})
|
||||
|
||||
it('bounds an imported subagent roster by entry count, label and id', async () => {
|
||||
// The import reads an untrusted file: nothing upstream bounded either string.
|
||||
const oversized = 'z'.repeat(20 * 1024)
|
||||
const journal = await open('claude', CLAUDE_SESSION)
|
||||
await appendLegacyTranscriptMessages({
|
||||
journal,
|
||||
agent: 'claude',
|
||||
sessionId: CLAUDE_SESSION,
|
||||
fence: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 'legacy-roster',
|
||||
role: 'assistant',
|
||||
timestamp: null,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'subagent-group',
|
||||
groupId: 'group-1',
|
||||
agents: Array.from({ length: 80 }, (_, index) => ({
|
||||
id: index === 0 ? oversized : `task-${index}`,
|
||||
label: index === 0 ? oversized : `label-${index}`,
|
||||
state: 'working' as const
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const body = journal.snapshot().items[0]?.body
|
||||
const block = body?.kind === 'message' ? body.blocks[0] : undefined
|
||||
if (block?.type !== 'subagent-group') {
|
||||
throw new Error('expected a subagent-group block')
|
||||
}
|
||||
expect(block.agents).toHaveLength(64)
|
||||
expect(block.agents[0]?.label.length).toBeLessThan(oversized.length)
|
||||
expect(block.agents[0]?.id.length).toBeLessThan(oversized.length)
|
||||
expect(block.agents[0]?.id.startsWith('z')).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds a roster id in the shared format, keeping a shared prefix distinct', async () => {
|
||||
// The id is the roster key: it takes the same bounded-id format the wires
|
||||
// use, so a later wire bound is a no-op instead of a second, merging clip.
|
||||
const head = 'y'.repeat(512)
|
||||
const journal = await open('claude', CLAUDE_SESSION)
|
||||
await appendLegacyTranscriptMessages({
|
||||
journal,
|
||||
agent: 'claude',
|
||||
sessionId: CLAUDE_SESSION,
|
||||
fence: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 'legacy-roster-collision',
|
||||
role: 'assistant',
|
||||
timestamp: null,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'subagent-group',
|
||||
groupId: 'group-1',
|
||||
agents: [
|
||||
{ id: `${head}-one`, label: 'Audit', state: 'working' as const },
|
||||
{ id: `${head}-two`, label: 'Audit', state: 'working' as const }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const body = journal.snapshot().items[0]?.body
|
||||
const block = body?.kind === 'message' ? body.blocks[0] : undefined
|
||||
if (block?.type !== 'subagent-group') {
|
||||
throw new Error('expected a subagent-group block')
|
||||
}
|
||||
expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id)
|
||||
expect(block.agents[0]?.id).toHaveLength(512)
|
||||
})
|
||||
})
|
||||
|
||||
describe('import failures', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
decodeOmpTranscriptLine
|
||||
} from '../transcript-line-decoders'
|
||||
import { decodeTranscriptStream } from '../transcript-stream-lines'
|
||||
import { boundSubagentEntryId } from '../subagent-entry-id-bounds'
|
||||
import { createLegacyIdentityTracker } from './journal-legacy-identity'
|
||||
import type { JournalReplacementItem } from './journal-epoch-replacement'
|
||||
import {
|
||||
@@ -47,6 +48,8 @@ export type LegacyImportOptions = ResolveSessionFileOptions & {
|
||||
}
|
||||
|
||||
const MAX_LEGACY_IMPORT_SOURCE_BYTES = 16 * 1024 * 1024
|
||||
/** A roster is a status list; an imported one is as untrusted as any other block. */
|
||||
const MAX_LEGACY_IMPORT_SUBAGENTS = 64
|
||||
|
||||
export type LegacyImportResult =
|
||||
| {
|
||||
@@ -271,5 +274,17 @@ function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): Nativ
|
||||
if (block.type === 'tool-call') {
|
||||
return { ...block, input: boundToolInput(block.input, limits) }
|
||||
}
|
||||
if (block.type === 'subagent-group') {
|
||||
return {
|
||||
...block,
|
||||
agents: block.agents.slice(0, MAX_LEGACY_IMPORT_SUBAGENTS).map((agent) => ({
|
||||
...agent,
|
||||
// The id is the roster key, so it is bounded with a digest rather than
|
||||
// clipped to a prefix that two distinct children could share.
|
||||
id: boundSubagentEntryId(agent.id),
|
||||
label: boundInlineText(agent.label, limits).text
|
||||
}))
|
||||
}
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
boundJournalKeyComponent,
|
||||
MAX_JOURNAL_KEY_COMPONENT_CHARS
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalMessageItem
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
|
||||
import {
|
||||
applyJournalRow,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
renderJournalState,
|
||||
type JournalReducerState
|
||||
} from './journal-reducer'
|
||||
import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
|
||||
const EPOCH = 'epoch-1'
|
||||
@@ -444,3 +448,49 @@ describe('bounded item-key collisions', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('re-adding a tombstoned row', () => {
|
||||
it('builds the rebuilt row above the tombstone that removed it', () => {
|
||||
const identity: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'roster' }
|
||||
const itemId = agentJournalItemKey(identity)
|
||||
const state = createJournalReducerState('session-1', EPOCH)
|
||||
applyJournalRow(
|
||||
state,
|
||||
buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 })
|
||||
)
|
||||
applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 }))
|
||||
expect(renderJournalState(state).items).toEqual([])
|
||||
|
||||
// Same identity, re-added later in the session: a revision built only from
|
||||
// `items` would restart at 1 and lose to the tombstone forever.
|
||||
applyJournalRow(
|
||||
state,
|
||||
buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 })
|
||||
)
|
||||
expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('second')])
|
||||
})
|
||||
|
||||
// `upsertItem` clearing the tombstone on a re-add is a map-state invariant:
|
||||
// `items` and `tombstones` stay disjoint, so a re-added row is never both
|
||||
// present and removed. Revision ordering is now independent of it —
|
||||
// `buildJournalTombstoneRow` takes `max(itemRevision, tombstoneRevision) + 1`
|
||||
// — so what this pins is the map state itself, not the ranking.
|
||||
it('removes the row again after it was re-added', () => {
|
||||
const identity: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'roster' }
|
||||
const itemId = agentJournalItemKey(identity)
|
||||
const state = createJournalReducerState('session-1', EPOCH)
|
||||
applyJournalRow(
|
||||
state,
|
||||
buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 })
|
||||
)
|
||||
applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 }))
|
||||
applyJournalRow(
|
||||
state,
|
||||
buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 })
|
||||
)
|
||||
expect(state.tombstones.get(itemId)).toBeUndefined()
|
||||
|
||||
applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 4, fence: 1, ts: 1_004 }))
|
||||
expect(renderJournalState(state).items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,7 +148,13 @@ export function buildJournalItemRow(input: {
|
||||
}): JournalItemRow {
|
||||
const itemId = agentJournalItemKey(input.identity)
|
||||
const resolved = input.state.aliases.get(itemId) ?? itemId
|
||||
const revision = (input.state.items.get(resolved)?.revision ?? 0) + 1
|
||||
// A tombstoned row keeps its revision in `tombstones`, and the reducer drops
|
||||
// any item at or below it — so a re-add has to outrank the tombstone too.
|
||||
const revision =
|
||||
Math.max(
|
||||
input.state.items.get(resolved)?.revision ?? 0,
|
||||
input.state.tombstones.get(resolved) ?? 0
|
||||
) + 1
|
||||
return {
|
||||
kind: 'item',
|
||||
itemId,
|
||||
@@ -170,7 +176,15 @@ export function buildJournalTombstoneRow(input: {
|
||||
return {
|
||||
kind: 'tombstone',
|
||||
itemId: input.itemId,
|
||||
revision: (input.state.items.get(resolved)?.revision ?? 0) + 1,
|
||||
// Symmetric with the item builder: `upsertItem` clearing the tombstone on a
|
||||
// re-add is what keeps the two maps disjoint, and that invariant lives in the
|
||||
// reducer. Outranking both here means a repeat removal cannot be dropped as a
|
||||
// stale revision if it ever stops holding.
|
||||
revision:
|
||||
Math.max(
|
||||
input.state.items.get(resolved)?.revision ?? 0,
|
||||
input.state.tombstones.get(resolved) ?? 0
|
||||
) + 1,
|
||||
...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,7 @@ export function boundHistoryItemsByBytes(
|
||||
submissionBytes: ReadonlyMap<string, number>,
|
||||
maxBytes: number
|
||||
): { items: AgentJournalRenderItem[]; dropped: number } {
|
||||
const groups = groupItemsBySequence(items)
|
||||
const ordered = keep === 'newest' ? groups.toReversed() : groups
|
||||
const ordered = groupItemsBySequence(items, keep)
|
||||
const kept: AgentJournalRenderItem[][] = []
|
||||
let total = 0
|
||||
for (const group of ordered) {
|
||||
@@ -88,29 +87,42 @@ export function boundHistoryItemsByBytes(
|
||||
}
|
||||
}
|
||||
|
||||
function groupItemsBySequence(
|
||||
items: readonly AgentJournalRenderItem[]
|
||||
): AgentJournalRenderItem[][] {
|
||||
const groups: AgentJournalRenderItem[][] = []
|
||||
for (const item of items) {
|
||||
const current = groups.at(-1)
|
||||
if (current?.[0]?.sequence === item.sequence) {
|
||||
current.push(item)
|
||||
/** Adjacent same-sequence runs, walked from the end (`newest`) or the start (`oldest`)
|
||||
* so a caller that stops at its window never groups the history it will not return.
|
||||
* Groups and their items keep the order the eager forward grouping produced. */
|
||||
function* groupItemsBySequence(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
keep: 'newest' | 'oldest'
|
||||
): Generator<AgentJournalRenderItem[]> {
|
||||
let cursor = keep === 'newest' ? items.length : 0
|
||||
while (keep === 'newest' ? cursor > 0 : cursor < items.length) {
|
||||
if (keep === 'newest') {
|
||||
let start = cursor - 1
|
||||
const sequence = items[start].sequence
|
||||
while (start > 0 && items[start - 1].sequence === sequence) {
|
||||
start -= 1
|
||||
}
|
||||
yield items.slice(start, cursor)
|
||||
cursor = start
|
||||
} else {
|
||||
groups.push([item])
|
||||
let end = cursor + 1
|
||||
const sequence = items[cursor].sequence
|
||||
while (end < items.length && items[end].sequence === sequence) {
|
||||
end += 1
|
||||
}
|
||||
yield items.slice(cursor, end)
|
||||
cursor = end
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function newestWholeSequenceGroups(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
limit: number
|
||||
): AgentJournalRenderItem[] {
|
||||
const groups = groupItemsBySequence(items)
|
||||
const selected: AgentJournalRenderItem[][] = []
|
||||
let count = 0
|
||||
for (const group of groups.toReversed()) {
|
||||
for (const group of groupItemsBySequence(items, 'newest')) {
|
||||
if (selected.length > 0 && count + group.length > limit) {
|
||||
break
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
boundHistoryItemsByBytes,
|
||||
historyEntryBytes,
|
||||
newestWholeSequenceGroups,
|
||||
oversizedHistoryItem
|
||||
} from './agent-session-history-page-bounds'
|
||||
|
||||
/** The pre-generator eager grouping, verbatim, as the differential oracle. */
|
||||
function referenceGroups(items: readonly AgentJournalRenderItem[]): AgentJournalRenderItem[][] {
|
||||
const groups: AgentJournalRenderItem[][] = []
|
||||
for (const item of items) {
|
||||
const current = groups.at(-1)
|
||||
if (current?.[0]?.sequence === item.sequence) {
|
||||
current.push(item)
|
||||
} else {
|
||||
groups.push([item])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function referenceNewestWholeSequenceGroups(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
limit: number
|
||||
): AgentJournalRenderItem[] {
|
||||
const selected: AgentJournalRenderItem[][] = []
|
||||
let count = 0
|
||||
for (const group of referenceGroups(items).toReversed()) {
|
||||
if (selected.length > 0 && count + group.length > limit) {
|
||||
break
|
||||
}
|
||||
selected.push(group)
|
||||
count += group.length
|
||||
}
|
||||
return selected.toReversed().flat()
|
||||
}
|
||||
|
||||
function referenceBoundHistoryItemsByBytes(
|
||||
items: AgentJournalRenderItem[],
|
||||
keep: 'newest' | 'oldest',
|
||||
submissionBytes: ReadonlyMap<string, number>,
|
||||
maxBytes: number
|
||||
): { items: AgentJournalRenderItem[]; dropped: number } {
|
||||
const groups = referenceGroups(items)
|
||||
const ordered = keep === 'newest' ? groups.toReversed() : groups
|
||||
const kept: AgentJournalRenderItem[][] = []
|
||||
let total = 0
|
||||
for (const group of ordered) {
|
||||
const bytes = group.reduce((sum, item) => sum + historyEntryBytes(item, submissionBytes), 0)
|
||||
if (kept.length === 0 && bytes > maxBytes) {
|
||||
kept.push(group.map((item) => oversizedHistoryItem(item, bytes)))
|
||||
break
|
||||
}
|
||||
if (total + bytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
kept.push(group)
|
||||
total += bytes
|
||||
}
|
||||
return {
|
||||
items: (keep === 'newest' ? kept.toReversed() : kept).flat(),
|
||||
dropped: items.length - kept.reduce((count, group) => count + group.length, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function item(index: number, sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: `i${index}`,
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: index,
|
||||
body: { kind: 'status', text: `s${index}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every sequence-run shape of `length` items, as run-length compositions. */
|
||||
function* runShapes(length: number): Generator<number[]> {
|
||||
if (length === 0) {
|
||||
yield []
|
||||
return
|
||||
}
|
||||
for (let first = 1; first <= length; first += 1) {
|
||||
for (const rest of runShapes(length - first)) {
|
||||
yield [first, ...rest]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build items from run lengths; `repeatSequence` reuses an earlier sequence value
|
||||
* in a later run so non-adjacent duplicates are exercised too. */
|
||||
function buildItems(runs: number[], repeatSequence: boolean): AgentJournalRenderItem[] {
|
||||
const items: AgentJournalRenderItem[] = []
|
||||
let index = 0
|
||||
runs.forEach((runLength, runIndex) => {
|
||||
const sequence = repeatSequence && runIndex > 0 && runIndex % 2 === 0 ? 0 : runIndex
|
||||
for (let i = 0; i < runLength; i += 1) {
|
||||
items.push(item(index++, sequence))
|
||||
}
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
it('matches eager grouping at every newest-window limit for every run shape', () => {
|
||||
let cases = 0
|
||||
for (let length = 0; length <= 7; length += 1) {
|
||||
for (const runs of runShapes(length)) {
|
||||
for (const repeatSequence of [false, true]) {
|
||||
const items = buildItems(runs, repeatSequence)
|
||||
// Every boundary, including 0, each exact group edge, and past the end.
|
||||
for (let limit = 0; limit <= length + 1; limit += 1) {
|
||||
expect(
|
||||
newestWholeSequenceGroups(items, limit),
|
||||
`runs ${runs.join(',')} repeat ${repeatSequence} limit ${limit}`
|
||||
).toEqual(referenceNewestWholeSequenceGroups(items, limit))
|
||||
cases += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(cases).toBeGreaterThan(1000)
|
||||
})
|
||||
|
||||
it('matches eager byte bounding at every budget boundary in both directions', () => {
|
||||
const submissionBytes = new Map<string, number>()
|
||||
let truncatedCases = 0
|
||||
let partialCases = 0
|
||||
for (let length = 1; length <= 6; length += 1) {
|
||||
for (const runs of runShapes(length)) {
|
||||
for (const repeatSequence of [false, true]) {
|
||||
const items = buildItems(runs, repeatSequence)
|
||||
const perItem = historyEntryBytes(items[0]!, submissionBytes)
|
||||
// Sweep exact group-boundary budgets plus one byte either side of each.
|
||||
const budgets = new Set<number>([0, 1])
|
||||
for (let n = 0; n <= length + 1; n += 1) {
|
||||
budgets.add(n * perItem - 1)
|
||||
budgets.add(n * perItem)
|
||||
budgets.add(n * perItem + 1)
|
||||
}
|
||||
for (const keep of ['newest', 'oldest'] as const) {
|
||||
for (const maxBytes of budgets) {
|
||||
const actual = boundHistoryItemsByBytes([...items], keep, submissionBytes, maxBytes)
|
||||
const expected = referenceBoundHistoryItemsByBytes(
|
||||
[...items],
|
||||
keep,
|
||||
submissionBytes,
|
||||
maxBytes
|
||||
)
|
||||
expect(
|
||||
actual,
|
||||
`runs ${runs.join(',')} repeat ${repeatSequence} keep ${keep} bytes ${maxBytes}`
|
||||
).toEqual(expected)
|
||||
if (
|
||||
actual.items.some(
|
||||
(entry) => entry.body.kind === 'status' && /truncated/.test(entry.body.text)
|
||||
)
|
||||
) {
|
||||
truncatedCases += 1
|
||||
} else if (actual.dropped > 0) {
|
||||
partialCases += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The oversized-first-group and partial-window paths must both be exercised.
|
||||
expect(truncatedCases).toBeGreaterThan(50)
|
||||
expect(partialCases).toBeGreaterThan(50)
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
boundHistoryItemsByBytes,
|
||||
newestWholeSequenceGroups
|
||||
} from './agent-session-history-page-bounds'
|
||||
|
||||
function item(index: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: String(index),
|
||||
revision: 1,
|
||||
sequence: index,
|
||||
observedAt: index,
|
||||
body: { kind: 'status', text: 'ok' }
|
||||
}
|
||||
}
|
||||
|
||||
it('visits only the retained sequence window and its boundary', () => {
|
||||
let reads = 0
|
||||
const items = Array.from({ length: 10000 }, (_, index) => ({
|
||||
...item(index),
|
||||
get sequence() {
|
||||
reads += 1
|
||||
return index
|
||||
}
|
||||
}))
|
||||
expect(newestWholeSequenceGroups(items, 100).map((entry) => entry.itemId)).toEqual(
|
||||
Array.from({ length: 100 }, (_, index) => String(9900 + index))
|
||||
)
|
||||
expect(reads).toBeLessThan(300)
|
||||
reads = 0
|
||||
expect(boundHistoryItemsByBytes(items, 'newest', new Map(), 1000).items.length).toBeGreaterThan(0)
|
||||
expect(reads).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('retains entire boundary groups and preserves oversized first-group truncation', () => {
|
||||
const items = [item(1), { ...item(2), sequence: 1 }, item(3), { ...item(4), sequence: 3 }]
|
||||
expect(newestWholeSequenceGroups(items, 1)).toEqual(items.slice(2))
|
||||
expect(newestWholeSequenceGroups(items, 3)).toEqual(items.slice(2))
|
||||
expect(boundHistoryItemsByBytes(items, 'oldest', new Map(), 1)).toMatchObject({
|
||||
dropped: 2,
|
||||
items: [{ itemId: '1' }, { itemId: '2' }]
|
||||
})
|
||||
expect(boundHistoryItemsByBytes(items, 'newest', new Map(), 1)).toMatchObject({
|
||||
dropped: 2,
|
||||
items: [{ itemId: '3' }, { itemId: '4' }]
|
||||
})
|
||||
})
|
||||
@@ -38,4 +38,5 @@ export type StructuredAgentSessionAttachContext = {
|
||||
reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
now: () => number
|
||||
publishStatus?: (sessionId: string) => void
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export class StructuredAgentSessionEventRecovery {
|
||||
sessions: Map<string, StructuredAgentSessionHostSession>
|
||||
flushLifecycle: (sessionId: string) => Promise<StructuredAgentSessionSinkBarrier>
|
||||
publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void
|
||||
publishStatus?: (sessionId: string) => void
|
||||
hasResumeCapableHolder: (sessionId: string) => boolean
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
now: () => number
|
||||
|
||||
@@ -25,6 +25,7 @@ type HostHandoffAccess = {
|
||||
flush: (sessionId: string) => Promise<void>
|
||||
serialize: (sessionId: string, task: () => Promise<void>) => Promise<void>
|
||||
subscribers: AgentSessionSubscribers
|
||||
publishStatus?: (sessionId: string) => void
|
||||
now: () => number
|
||||
}
|
||||
|
||||
@@ -82,6 +83,7 @@ export function createStructuredAgentSessionHostHandoff(
|
||||
return { state: 'live' }
|
||||
}
|
||||
host.session(sessionId).hasProviderChild = false
|
||||
host.publishStatus?.(sessionId)
|
||||
try {
|
||||
await host.flush(sessionId)
|
||||
host.eventSink(sessionId).unbind()
|
||||
@@ -243,6 +245,7 @@ export async function acquireNativeHandoffOwner(
|
||||
return rethrowAfterAgentSessionAcquisitionCleanup(deps.adapter, input.sessionId, error)
|
||||
}
|
||||
session.hasProviderChild = true
|
||||
host.publishStatus?.(input.sessionId)
|
||||
session.fence = proved.lease.runtimeFence
|
||||
session.acquisitionGeneration = acquired.acquisitionGeneration ?? null
|
||||
eventSink.bind({
|
||||
|
||||
@@ -93,13 +93,19 @@ export async function resumeStructuredAgentSessionForHold(
|
||||
export function createStructuredAgentSessionHolds(
|
||||
context: StructuredAgentSessionLifetimeContext,
|
||||
input: {
|
||||
resume: (sessionId: string) => Promise<void>
|
||||
evict: (sessionId: string) => Promise<void>
|
||||
reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
|
||||
attach: Parameters<typeof resumeHeldStructuredAgentSession>[0]['attach']
|
||||
close: (sessionId: string) => Promise<void>
|
||||
}
|
||||
): StructuredAgentSessionHolds {
|
||||
return new StructuredAgentSessionHolds({
|
||||
resume: input.resume,
|
||||
evict: input.evict,
|
||||
resume: (sessionId) =>
|
||||
resumeStructuredAgentSessionForHold(
|
||||
{ ...context, reconcileLeases: input.reconcileLeases },
|
||||
sessionId,
|
||||
input.attach
|
||||
),
|
||||
evict: input.close,
|
||||
hasProviderChild: (sessionId) => hasProviderChild(context, sessionId),
|
||||
isTurnActive: (sessionId) => {
|
||||
const session = context.sessions.get(sessionId)
|
||||
|
||||
@@ -28,7 +28,6 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach-
|
||||
import {
|
||||
createStructuredAgentSessionHolds,
|
||||
evictHeldStructuredAgentSession,
|
||||
resumeStructuredAgentSessionForHold,
|
||||
type StructuredAgentSessionLifetimeContext
|
||||
} from './structured-agent-session-host-lifetime'
|
||||
import type {
|
||||
@@ -124,16 +123,13 @@ export class StructuredAgentSessionHost {
|
||||
flush: (sessionId) => this.flushStreamedEvents(sessionId),
|
||||
serialize: (sessionId, task) => this.serialize(sessionId, task),
|
||||
subscribers: this.subscribers,
|
||||
publishStatus: (sessionId) => this.statusFeed.publish(sessionId),
|
||||
now: this.now
|
||||
})
|
||||
this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), {
|
||||
resume: (sessionId) =>
|
||||
resumeStructuredAgentSessionForHold(
|
||||
{ ...this.lifetimeContext(), reconcileLeases: this.reconcileLeases },
|
||||
sessionId,
|
||||
(params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params)
|
||||
),
|
||||
evict: (sessionId) => this.close(sessionId)
|
||||
reconcileLeases: this.reconcileLeases,
|
||||
attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params),
|
||||
close: (sessionId) => this.close(sessionId)
|
||||
})
|
||||
this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), {
|
||||
reconcile: this.reconcileLeases,
|
||||
@@ -155,6 +151,7 @@ export class StructuredAgentSessionHost {
|
||||
flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId),
|
||||
publishFence: (sessionId, session) =>
|
||||
this.subscribers.snapshot(sessionId, session.journal, session.fence),
|
||||
publishStatus: (sessionId) => this.statusFeed.publish(sessionId),
|
||||
hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId),
|
||||
serialize: (sessionId, task) => this.serialize(sessionId, task),
|
||||
now: () => this.now(),
|
||||
@@ -196,14 +193,26 @@ export class StructuredAgentSessionHost {
|
||||
subscribers: this.subscribers,
|
||||
tasks: this.tasks,
|
||||
reconcileLeases: (sessionId) => this.reconcileLeases(sessionId),
|
||||
serialize: (sessionId, task) => this.serialize(sessionId, task)
|
||||
serialize: (sessionId, task) => this.serialize(sessionId, task),
|
||||
publishStatus: (sessionId) => this.statusFeed.publish(sessionId)
|
||||
})
|
||||
private attachContext(): StructuredAgentSessionAttachContext {
|
||||
return {
|
||||
...this.lifetimeContext(),
|
||||
subscribers: this.subscribers,
|
||||
tasks: this.tasks,
|
||||
reconcileLeases: (sessionId) => this.reconcileLeases(sessionId),
|
||||
serialize: (sessionId, task) => this.serialize(sessionId, task),
|
||||
publishStatus: (sessionId) => this.statusFeed.publish(sessionId)
|
||||
}
|
||||
}
|
||||
/** Releases a session's resources without ending the conversation: the record and journal stay
|
||||
* on disk, so the same session can be attached again. */
|
||||
close(sessionId: string): Promise<void> {
|
||||
return this.serialize(sessionId, async () => {
|
||||
await this.handoffs.closeRetainedTuiOwner(sessionId)
|
||||
await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId)
|
||||
this.statusFeed.revokeLive(sessionId)
|
||||
// Whoever asked for the close, the surfaces that were holding this session are looking at a
|
||||
// session that no longer exists. A failed eviction throws above and keeps them.
|
||||
this.holds.forget(sessionId)
|
||||
@@ -215,6 +224,13 @@ export class StructuredAgentSessionHost {
|
||||
|
||||
listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions)
|
||||
|
||||
/** Last projected status for every structured session this host still holds, for non-subscribing
|
||||
* readers. The retained projections of forgotten sessions are deliberately not included. */
|
||||
readonly liveSessionStatusSummaries = () => this.statusFeed.liveSessionSummaries()
|
||||
|
||||
/** Last projected status for every structured session this host still holds. */
|
||||
readonly liveSessionStatusSummaries = () => this.statusFeed.liveSessionSummaries()
|
||||
|
||||
getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex()
|
||||
|
||||
setSessionTabVisibility = (sessionId: string, visible: boolean): Promise<void> =>
|
||||
|
||||
+80
-2
@@ -53,15 +53,24 @@ async function openJournal(sessionId = SESSION, now?: () => number) {
|
||||
})
|
||||
}
|
||||
|
||||
function indexed(session: { journal: Awaited<ReturnType<typeof openJournal>> }) {
|
||||
function indexed(session: {
|
||||
journal: Awaited<ReturnType<typeof openJournal>>
|
||||
hasProviderChild?: boolean
|
||||
}) {
|
||||
return {
|
||||
journal: session.journal,
|
||||
...(session.hasProviderChild !== undefined
|
||||
? { hasProviderChild: session.hasProviderChild }
|
||||
: {}),
|
||||
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
|
||||
}
|
||||
}
|
||||
|
||||
function feedFor(
|
||||
sessions: Map<string, { journal: Awaited<ReturnType<typeof openJournal>> }>,
|
||||
sessions: Map<
|
||||
string,
|
||||
{ journal: Awaited<ReturnType<typeof openJournal>>; hasProviderChild?: boolean }
|
||||
>,
|
||||
record: Partial<AgentSessionRecord> | null = null,
|
||||
onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged']
|
||||
) {
|
||||
@@ -88,6 +97,41 @@ function feedFor(
|
||||
}
|
||||
|
||||
describe('StructuredAgentSessionStatusFeed', () => {
|
||||
it('publishes provider ownership transitions without changing journal time', async () => {
|
||||
const journal = await openJournal()
|
||||
const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]])
|
||||
const { feed, events, dispose } = feedFor(sessions)
|
||||
events.length = 0
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({ hostExecutionOwned: true, updatedAt: expect.any(Number) })
|
||||
})
|
||||
const firstStatus = events.at(-1)
|
||||
expect(firstStatus?.type).toBe('status')
|
||||
if (firstStatus?.type !== 'status') {
|
||||
throw new Error('status publication missing')
|
||||
}
|
||||
const journalTime = firstStatus.session.updatedAt
|
||||
sessions.get(SESSION)!.hasProviderChild = false
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({ status: 'idle', updatedAt: journalTime })
|
||||
})
|
||||
const secondStatus = events.at(-1)
|
||||
expect(secondStatus?.type).toBe('status')
|
||||
if (secondStatus?.type === 'status') {
|
||||
expect(secondStatus.session).not.toHaveProperty('hostExecutionOwned')
|
||||
}
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('opens with every readable session and reports no status before a persisted turn', async () => {
|
||||
const journal = await openJournal()
|
||||
const { events } = feedFor(new Map([[SESSION, { journal }]]))
|
||||
@@ -523,3 +567,37 @@ describe('StructuredAgentSessionStatusFeed', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* `published` is a broadcast cache, not a roster. It deliberately never retracts — an evicted idle
|
||||
* session is still idle, and a reloading renderer must not lose every settled row — so enumerating
|
||||
* it lists every session this host has ever opened. Eviction's `forget-session` step deletes the
|
||||
* session from the live map and touches nothing else, so a poller has to intersect with that map.
|
||||
*/
|
||||
describe('the polling reader answers from the live sessions, not the retained cache', () => {
|
||||
it('drops an evicted session from the poll while a late subscriber still sees it', async () => {
|
||||
const journal = await openJournal()
|
||||
const sessions = new Map([[SESSION, { journal }]])
|
||||
const { feed } = feedFor(sessions)
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
feed.publish(SESSION, journal)
|
||||
expect(feed.liveSessionSummaries().map((summary) => summary.sessionId)).toEqual([SESSION])
|
||||
|
||||
// Exactly what eviction's `forget-session` step does; nothing else touches the feed.
|
||||
sessions.delete(SESSION)
|
||||
|
||||
expect(feed.liveSessionSummaries()).toEqual([])
|
||||
const late: AgentSessionStatusEvent[] = []
|
||||
feed.subscribe({ id: 'list-2', emit: (event) => late.push(event) })
|
||||
expect(late).toEqual([
|
||||
{
|
||||
type: 'snapshot',
|
||||
sessions: [expect.objectContaining({ sessionId: SESSION, status: 'idle' })]
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ export type StructuredAgentSessionStatusSubscriber = {
|
||||
type StatusFeedSession = {
|
||||
journal: AgentSessionJournal
|
||||
params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] }
|
||||
hasProviderChild?: boolean
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionStatusFeedDeps = {
|
||||
@@ -46,6 +47,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma
|
||||
a.workspaceId === b.workspaceId &&
|
||||
a.agent === b.agent &&
|
||||
a.status === b.status &&
|
||||
a.hostExecutionOwned === b.hostExecutionOwned &&
|
||||
a.rewindBlockedReason === b.rewindBlockedReason &&
|
||||
// Settled activity changes ranking; streaming active turns must stay quiet.
|
||||
(a.status !== 'idle' || a.updatedAt === b.updatedAt) &&
|
||||
@@ -76,6 +78,27 @@ export class StructuredAgentSessionStatusFeed {
|
||||
return () => this.unsubscribe(subscriber.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Summaries for the sessions this host still holds, for readers that poll instead of subscribing.
|
||||
*
|
||||
* `published` never retracts, so it is a broadcast cache and not a roster: enumerating it lists
|
||||
* every session ever opened here. A caller asking what is running gets the live intersection,
|
||||
* while the retained view a subscriber opens on stays whole.
|
||||
*
|
||||
* Deliberately does NOT re-project: a subscriber's snapshot is the live read, and re-running the
|
||||
* journal reduction per caller would make an enumerating command pay for every session it lists.
|
||||
*/
|
||||
liveSessionSummaries(): AgentSessionStatusSummary[] {
|
||||
const summaries: AgentSessionStatusSummary[] = []
|
||||
for (const [sessionId] of this.deps.sessions) {
|
||||
const summary = this.published.get(sessionId)
|
||||
if (summary) {
|
||||
summaries.push(summary)
|
||||
}
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
unsubscribe(id: string): void {
|
||||
const subscriber = this.subscribers.get(id)
|
||||
if (!subscriber) {
|
||||
@@ -89,6 +112,20 @@ export class StructuredAgentSessionStatusFeed {
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke live execution authority while retaining the last projection for reload history. */
|
||||
revokeLive(sessionId: string): void {
|
||||
const previous = this.published.get(sessionId)
|
||||
if (!previous) {
|
||||
return
|
||||
}
|
||||
const { hostExecutionOwned: _hostExecutionOwned, ...retained } = previous
|
||||
this.published.set(sessionId, retained)
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
session: retained
|
||||
})
|
||||
}
|
||||
|
||||
/** Re-projects one session after its journal changed; equal projections are not re-sent. */
|
||||
publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void {
|
||||
const session = this.deps.sessions.get(sessionId)
|
||||
@@ -126,6 +163,7 @@ export class StructuredAgentSessionStatusFeed {
|
||||
sessionId,
|
||||
workspaceId: session.params.location.workspaceId,
|
||||
agent: session.params.provider,
|
||||
...(session.hasProviderChild ? { hostExecutionOwned: true as const } : {}),
|
||||
...projectStructuredAgentSessionStatusSummary(items),
|
||||
...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded'
|
||||
? { rewindBlockedReason: 'outcome-unknown' as const }
|
||||
|
||||
@@ -32,6 +32,7 @@ export type StructuredAgentSessionUnexpectedExitContext = {
|
||||
sessions: Map<string, StructuredAgentSessionHostSession>
|
||||
flushLifecycle: (sessionId: string) => Promise<StructuredAgentSessionSinkBarrier>
|
||||
publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void
|
||||
publishStatus?: (sessionId: string) => void
|
||||
hasResumeCapableHolder: (sessionId: string) => boolean
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
now: () => number
|
||||
@@ -59,6 +60,7 @@ export async function settleUnexpectedStructuredAgentSessionExit(
|
||||
if (!record || record.lease.handoffStage !== null) {
|
||||
// The handoff coordinator owns an already-started transition.
|
||||
session.hasProviderChild = false
|
||||
context.publishStatus?.(unexpectedEvent.sessionId)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -117,6 +119,7 @@ export async function settleUnexpectedStructuredAgentSessionExit(
|
||||
context.onBarrierError?.(unexpectedEvent.sessionId, error)
|
||||
} finally {
|
||||
session.hasProviderChild = false
|
||||
context.publishStatus?.(unexpectedEvent.sessionId)
|
||||
if (released) {
|
||||
session.fence = released.lease.runtimeFence
|
||||
context.publishFence(unexpectedEvent.sessionId, session)
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function replaceClaudeRewindOwner(
|
||||
return rewindRefusal('outcome-unknown')
|
||||
}
|
||||
session.hasProviderChild = false
|
||||
context.publishStatus?.(sessionId)
|
||||
const head = agentSessionProviderHandleChainHead(
|
||||
context.deps.store.getRecord(sessionId)!.providerHandleChain
|
||||
)?.handle
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { boundSubagentEntryId, MAX_SUBAGENT_ENTRY_ID_CHARS } from './subagent-entry-id-bounds'
|
||||
|
||||
const SHARED_HEAD = 'a'.repeat(MAX_SUBAGENT_ENTRY_ID_CHARS)
|
||||
|
||||
describe('boundSubagentEntryId', () => {
|
||||
it('leaves an id that already fits untouched', () => {
|
||||
const id = 'task-1'
|
||||
expect(boundSubagentEntryId(id)).toBe(id)
|
||||
expect(boundSubagentEntryId(SHARED_HEAD)).toBe(SHARED_HEAD)
|
||||
})
|
||||
|
||||
it('keeps two ids sharing the whole cap-length head distinct', () => {
|
||||
const first = boundSubagentEntryId(`${SHARED_HEAD}-one`)
|
||||
const second = boundSubagentEntryId(`${SHARED_HEAD}-two`)
|
||||
expect(first).not.toBe(second)
|
||||
expect(first).toHaveLength(MAX_SUBAGENT_ENTRY_ID_CHARS)
|
||||
expect(second).toHaveLength(MAX_SUBAGENT_ENTRY_ID_CHARS)
|
||||
})
|
||||
|
||||
it('is deterministic and a no-op on an already bounded id', () => {
|
||||
const bounded = boundSubagentEntryId(`${SHARED_HEAD}-one`)
|
||||
expect(boundSubagentEntryId(`${SHARED_HEAD}-one`)).toBe(bounded)
|
||||
expect(boundSubagentEntryId(bounded)).toBe(bounded)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
// Bounding a subagent entry's id — a KEY, not display text.
|
||||
//
|
||||
// `NativeChatSubagentEntry.id` keys the roster, so clipping it to a fixed prefix
|
||||
// merges two distinct children whose ids agree that far and associates one's
|
||||
// state with the other. An id long enough to need bounding only reaches us from
|
||||
// an imported legacy transcript, but a bound is still required, so keep a head
|
||||
// for readability plus a digest of the WHOLE id to keep distinct ids distinct.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** Shared by every site that puts a roster entry on a wire, so a journal-bound
|
||||
* id survives the RPC and transcript bounds untouched instead of being clipped
|
||||
* a second time into a different string. */
|
||||
export const MAX_SUBAGENT_ENTRY_ID_CHARS = 512
|
||||
|
||||
const DIGEST_CHARS = 16
|
||||
|
||||
/** Returns `id` unchanged when it fits, else a head plus a digest suffix whose
|
||||
* total length is exactly the cap — so bounding a bounded id is a no-op. */
|
||||
export function boundSubagentEntryId(id: string): string {
|
||||
if (id.length <= MAX_SUBAGENT_ENTRY_ID_CHARS) {
|
||||
return id
|
||||
}
|
||||
const digest = createHash('sha256').update(id, 'utf8').digest('base64url').slice(0, DIGEST_CHARS)
|
||||
const suffix = `…#${digest}`
|
||||
return `${id.slice(0, MAX_SUBAGENT_ENTRY_ID_CHARS - suffix.length)}${suffix}`
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createLocalFileSink,
|
||||
DROPPED_RECORD_TYPE,
|
||||
type LocalFileSink
|
||||
} from './local-file-sink'
|
||||
|
||||
function parseLine(raw: string): Record<string, unknown> {
|
||||
return JSON.parse(raw) as Record<string, unknown>
|
||||
}
|
||||
|
||||
let directory: string
|
||||
let sink: LocalFileSink | undefined
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'orca-trace-memory-'))
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
sink?.close()
|
||||
sink = undefined
|
||||
vi.useRealTimers()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function retainedHeap(): number {
|
||||
if (!globalThis.gc) {
|
||||
throw new Error('Memory regression requires --expose-gc')
|
||||
}
|
||||
globalThis.gc()
|
||||
globalThis.gc()
|
||||
return process.memoryUsage().heapUsed
|
||||
}
|
||||
|
||||
describe('trace sink rejected record retention', () => {
|
||||
it('keeps small-record byte scans deferred until the batch flush', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath })
|
||||
const byteLength = vi.spyOn(Buffer, 'byteLength')
|
||||
let beforeFlush: number
|
||||
let afterFlush: number
|
||||
try {
|
||||
for (let index = 0; index < 20; index++) {
|
||||
sink.push({ index, text: '💡漢字' })
|
||||
}
|
||||
beforeFlush = byteLength.mock.calls.length
|
||||
sink.flush()
|
||||
afterFlush = byteLength.mock.calls.length
|
||||
} finally {
|
||||
byteLength.mockRestore()
|
||||
}
|
||||
expect(beforeFlush).toBe(0)
|
||||
expect(afterFlush).toBe(20)
|
||||
})
|
||||
|
||||
it('releases oversized serialized records before the pending batch flushes', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath, maxBytes: 64 * 1024, batchWindowMs: 200 })
|
||||
const before = retainedHeap()
|
||||
for (let index = 0; index < 24; index++) {
|
||||
sink.push({ index, payload: 'x'.repeat(1024 * 1024) })
|
||||
}
|
||||
const retained = retainedHeap() - before
|
||||
expect(statSync(filePath).size).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(retained).toBeLessThan(5 * 1024 * 1024)
|
||||
sink.push({ valid: true })
|
||||
vi.advanceTimersByTime(200)
|
||||
const written = readFileSync(filePath, 'utf8').split('\n').filter(Boolean).map(parseLine)
|
||||
// Each rejected record leaves a marker, so the gap is readable instead of silent.
|
||||
expect(written.filter((entry) => entry.type === DROPPED_RECORD_TYPE)).toHaveLength(24)
|
||||
expect(written.at(-1)).toEqual({ valid: true })
|
||||
})
|
||||
|
||||
it('names the dropped record in the marker instead of leaving a silent gap', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath, maxBytes: 64 * 1024, flushBufferThreshold: 1 })
|
||||
sink.push({
|
||||
type: 'effect-span',
|
||||
name: 'worktree.create',
|
||||
traceId: 'a'.repeat(32),
|
||||
payload: 'x'.repeat(1024 * 1024)
|
||||
})
|
||||
const [marker] = readFileSync(filePath, 'utf8').split('\n').filter(Boolean).map(parseLine)
|
||||
expect(marker).toMatchObject({
|
||||
type: DROPPED_RECORD_TYPE,
|
||||
reason: 'oversize',
|
||||
name: 'worktree.create',
|
||||
traceId: 'a'.repeat(32)
|
||||
})
|
||||
expect(marker.droppedChars).toBeGreaterThan(1024 * 1024)
|
||||
// Timestamped so the bundle collector's lookback filter ages markers out like any other span.
|
||||
expect(BigInt(marker.endTimeUnixNano as string)).toBeGreaterThan(0n)
|
||||
})
|
||||
|
||||
it('omits the marker when even the marker would exceed the byte cap', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath, maxBytes: 40, flushBufferThreshold: 1 })
|
||||
sink.push({ payload: 'x'.repeat(1_000) })
|
||||
sink.push({ ok: 1 })
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('{"ok":1}\n')
|
||||
})
|
||||
|
||||
it('keeps the same count-triggered flush for valid records beside rejected ones', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
sink = createLocalFileSink({ filePath, maxBytes: 32, flushBufferThreshold: 3 })
|
||||
sink.push({ valid: 1 })
|
||||
sink.push({ payload: '💡'.repeat(20) })
|
||||
expect(statSync(filePath).size).toBe(0)
|
||||
sink.push({ payload: 'x'.repeat(100) })
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('{"valid":1}\n')
|
||||
sink.push({ valid: 2 })
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('{"valid":1}\n{"valid":2}\n')
|
||||
})
|
||||
|
||||
it('accepts the exact UTF-8 byte cap and preserves rotation and close flushes', () => {
|
||||
const filePath = join(directory, 'trace.ndjson')
|
||||
const record = { text: '💡' }
|
||||
const line = `${JSON.stringify(record)}\n`
|
||||
sink = createLocalFileSink({ filePath, maxBytes: Buffer.byteLength(line), maxFiles: 2 })
|
||||
sink.push(record)
|
||||
sink.push({ text: '💡x' })
|
||||
sink.push(record)
|
||||
sink.close()
|
||||
expect(readFileSync(filePath, 'utf8')).toBe(line)
|
||||
expect(readFileSync(`${filePath}.1`, 'utf8')).toBe(line)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,9 @@ export const DEFAULT_MAX_FILES = 10
|
||||
export const DEFAULT_BATCH_WINDOW_MS = 200
|
||||
const PRIVATE_DIRECTORY_MODE = 0o700
|
||||
const PRIVATE_FILE_MODE = 0o600
|
||||
/** NDJSON `type` for the placeholder left behind when a record is too large to store. */
|
||||
export const DROPPED_RECORD_TYPE = 'trace-record-dropped'
|
||||
const MAX_MARKER_NAME_CHARS = 120
|
||||
|
||||
export type LocalFileSinkOptions = {
|
||||
readonly filePath: string
|
||||
@@ -77,7 +80,7 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
|
||||
let fd: number = openAppend(filePath)
|
||||
let currentBytes: number = safeFstatSize(fd)
|
||||
|
||||
let buffer: string[] = []
|
||||
let buffer: (string | null)[] = []
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
let closed = false
|
||||
|
||||
@@ -171,11 +174,10 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const lineBytes = Buffer.byteLength(line, 'utf8')
|
||||
if (lineBytes > maxBytes) {
|
||||
// Oversized single span would blow the maxFiles × maxBytes envelope; drop just this record.
|
||||
if (line === null) {
|
||||
continue
|
||||
}
|
||||
const lineBytes = Buffer.byteLength(line, 'utf8')
|
||||
if (pendingChunkBytes > 0 && currentBytes + pendingChunkBytes + lineBytes > maxBytes) {
|
||||
flushPendingChunk()
|
||||
}
|
||||
@@ -189,6 +191,27 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
|
||||
flushPendingChunk()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in for a record too large to store. `droppedChars` is UTF-16 units, not bytes: measuring
|
||||
* bytes is the scan this path exists to skip. Returns null when even the marker exceeds maxBytes.
|
||||
*/
|
||||
function oversizeMarker(record: unknown, droppedChars: number): string | null {
|
||||
const span =
|
||||
typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {}
|
||||
const name = typeof span.name === 'string' ? span.name.slice(0, MAX_MARKER_NAME_CHARS) : null
|
||||
const traceId = typeof span.traceId === 'string' ? span.traceId.slice(0, 32) : null
|
||||
const marker = `${JSON.stringify({
|
||||
type: DROPPED_RECORD_TYPE,
|
||||
reason: 'oversize',
|
||||
droppedChars,
|
||||
// Lets the bundle collector's lookback filter age these out like any other span.
|
||||
endTimeUnixNano: `${Date.now()}000000`,
|
||||
...(name === null ? {} : { name }),
|
||||
...(traceId === null ? {} : { traceId })
|
||||
})}\n`
|
||||
return Buffer.byteLength(marker, 'utf8') > maxBytes ? null : marker
|
||||
}
|
||||
|
||||
function ensureTimer(): void {
|
||||
if (timer || closed) {
|
||||
return
|
||||
@@ -216,7 +239,13 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink {
|
||||
// Redactor handles cycles upstream; a throw here means pre-redact data slipped in — drop rather than crash (best-effort).
|
||||
return
|
||||
}
|
||||
buffer.push(line)
|
||||
// UTF-8 uses at most three bytes per UTF-16 unit; small records need no admission scan.
|
||||
const oversized =
|
||||
line.length > maxBytes ||
|
||||
(line.length * 3 > maxBytes && Buffer.byteLength(line, 'utf8') > maxBytes)
|
||||
// Rejected records still occupy a buffer slot (preserving flush timing) but carry a marker
|
||||
// instead of their payload, so the gap they leave is readable rather than silent.
|
||||
buffer.push(oversized ? oversizeMarker(record, line.length) : line)
|
||||
if (buffer.length >= flushThreshold) {
|
||||
flushBuffer()
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import { toComparableRelaySshPtyId } from '../../../shared/ssh-pty-id'
|
||||
import { clearSshRemotePtyBindingsForLeases } from './ssh-pty-binding-cleanup'
|
||||
|
||||
function fixture(count: number) {
|
||||
const state = getDefaultPersistedState('/home/test')
|
||||
state.workspaceSession = getDefaultWorkspaceSession()
|
||||
state.workspaceSession.tabsByWorktree.wt = Array.from({ length: count }, (_, i) => ({
|
||||
id: `tab-${i}`,
|
||||
worktreeId: 'wt',
|
||||
ptyId: `pty-${i}`,
|
||||
title: '',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: i,
|
||||
createdAt: 1
|
||||
}))
|
||||
const leases: SshRemotePtyLease[] = Array.from({ length: count }, (_, i) => ({
|
||||
targetId: 'ssh-one',
|
||||
ptyId: `pty-${i}`,
|
||||
tabId: `tab-${i}`,
|
||||
worktreeId: 'wt',
|
||||
state: 'detached',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}))
|
||||
return {
|
||||
state,
|
||||
leases,
|
||||
toComparablePtyId: vi.fn((_target: string, ptyId: string) => ptyId),
|
||||
scheduleSave: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('SSH binding cleanup indexing', () => {
|
||||
it('normalizes each binding once across a large lease inventory', () => {
|
||||
const operations = fixture(1000)
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(true)
|
||||
expect(operations.toComparablePtyId).toHaveBeenCalledTimes(1000)
|
||||
expect(
|
||||
operations.state.workspaceSession!.tabsByWorktree.wt.every((tab) => tab.ptyId === null)
|
||||
).toBe(true)
|
||||
expect(operations.scheduleSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retains foreign hosts and conflicting tab/workspace leases', () => {
|
||||
const operations = fixture(4)
|
||||
operations.leases[0].targetId = 'ssh-two'
|
||||
operations.leases[1].tabId = 'other-tab'
|
||||
operations.leases[2].worktreeId = 'other-workspace'
|
||||
delete operations.leases[3].tabId
|
||||
clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)
|
||||
expect(operations.state.workspaceSession!.tabsByWorktree.wt.map((tab) => tab.ptyId)).toEqual([
|
||||
'pty-0',
|
||||
'pty-1',
|
||||
'pty-2',
|
||||
null
|
||||
])
|
||||
})
|
||||
|
||||
it('matches layout leaves against every lease for a PTY while preserving leaf conflicts', () => {
|
||||
const operations = fixture(1)
|
||||
const session = operations.state.workspaceSession!
|
||||
session.tabsByWorktree.wt[0].ptyId = null
|
||||
session.terminalLayoutsByTabId['tab-0'] = {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
matched: 'pty-0',
|
||||
protected: 'pty-0',
|
||||
wildcard: 'pty-1'
|
||||
}
|
||||
}
|
||||
const lease = operations.leases[0]
|
||||
operations.leases = [
|
||||
{ ...lease, leafId: 'wrong' },
|
||||
{ ...lease, leafId: 'matched' },
|
||||
{ ...lease, ptyId: 'pty-1' }
|
||||
]
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(true)
|
||||
expect(session.terminalLayoutsByTabId['tab-0'].ptyIdsByLeafId).toEqual({
|
||||
protected: 'pty-0'
|
||||
})
|
||||
expect(operations.toComparablePtyId).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('normalizes app-form binding ids onto the relay-form lease key', () => {
|
||||
// Leases store the relay-local id; sessions may hold the app-wide "ssh:<target>@@<id>" form.
|
||||
// The index key is the normalized form, so both spellings still name the same PTY.
|
||||
const operations = fixture(1)
|
||||
operations.toComparablePtyId = vi.fn(toComparableRelaySshPtyId)
|
||||
operations.state.workspaceSession!.tabsByWorktree.wt[0].ptyId = 'ssh:ssh-one@@pty-0'
|
||||
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(true)
|
||||
expect(operations.state.workspaceSession!.tabsByWorktree.wt[0].ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a binding whose app-form id names a different SSH target', () => {
|
||||
// Relay-local ids collide across targets ("pty-0" exists on every host). Clearing ssh-one must
|
||||
// never scrub a pane still bound to a live ssh-two shell.
|
||||
const operations = fixture(1)
|
||||
operations.toComparablePtyId = vi.fn(toComparableRelaySshPtyId)
|
||||
operations.state.workspaceSession!.tabsByWorktree.wt[0].ptyId = 'ssh:ssh-two@@pty-0'
|
||||
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(false)
|
||||
expect(operations.state.workspaceSession!.tabsByWorktree.wt[0].ptyId).toBe('ssh:ssh-two@@pty-0')
|
||||
expect(operations.scheduleSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps every binding when no lease names its PTY', () => {
|
||||
// A bucket miss must fail closed: leak a stale id rather than unbind a live pane.
|
||||
const operations = fixture(2)
|
||||
for (const lease of operations.leases) {
|
||||
lease.ptyId = `unrelated-${lease.ptyId}`
|
||||
}
|
||||
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(false)
|
||||
expect(operations.state.workspaceSession!.tabsByWorktree.wt.map((tab) => tab.ptyId)).toEqual([
|
||||
'pty-0',
|
||||
'pty-1'
|
||||
])
|
||||
expect(operations.scheduleSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not index leases when the session holds no bindings to check', () => {
|
||||
const operations = fixture(500)
|
||||
operations.state.workspaceSession!.tabsByWorktree = {}
|
||||
operations.state.workspaceSession!.terminalLayoutsByTabId = {}
|
||||
|
||||
expect(clearSshRemotePtyBindingsForLeases(operations, 'ssh-one', operations.leases)).toBe(false)
|
||||
expect(operations.toComparablePtyId).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -9,8 +9,8 @@ export type SshPtyBindingCleanupOperations = {
|
||||
scheduleSave: () => void
|
||||
}
|
||||
|
||||
/** `binding.ptyId` must already be in lease-comparable (relay) form; callers normalize it. */
|
||||
function sshRemotePtyLeaseMayReferenceBinding(
|
||||
operations: SshPtyBindingCleanupOperations,
|
||||
lease: SshRemotePtyLease,
|
||||
binding: {
|
||||
ptyId: string
|
||||
@@ -20,8 +20,7 @@ function sshRemotePtyLeaseMayReferenceBinding(
|
||||
leafId?: string
|
||||
}
|
||||
): boolean {
|
||||
const bindingPtyId = operations.toComparablePtyId(binding.targetId, binding.ptyId)
|
||||
if (lease.targetId !== binding.targetId || lease.ptyId !== bindingPtyId) {
|
||||
if (lease.targetId !== binding.targetId || lease.ptyId !== binding.ptyId) {
|
||||
return false
|
||||
}
|
||||
// Why: target removal is destructive; scrub matching bindings before deleting the lease, else removing the tombstone can revive stale PTY ids.
|
||||
@@ -50,6 +49,33 @@ export function clearSshRemotePtyBindingsForLeases(
|
||||
if (!leases?.length) {
|
||||
return false
|
||||
}
|
||||
// Keyed by the stored (relay) pty id, which is the only form a lease holds; every lookup below
|
||||
// normalizes the binding id to that form first, so a bucket miss means "no lease names this pty"
|
||||
// and the binding is KEPT. Failing closed here leaves a stale id to be retired on reattach,
|
||||
// where clearing on a bad match would strand a live remote shell behind a respawned pane.
|
||||
let leasesByPtyId: Map<string, SshRemotePtyLease[]> | undefined
|
||||
const referencesBinding = (
|
||||
binding: Parameters<typeof sshRemotePtyLeaseMayReferenceBinding>[1]
|
||||
): boolean => {
|
||||
if (!leasesByPtyId) {
|
||||
leasesByPtyId = new Map()
|
||||
for (const lease of leases) {
|
||||
if (lease.targetId !== targetId) {
|
||||
continue
|
||||
}
|
||||
const entries = leasesByPtyId.get(lease.ptyId)
|
||||
if (entries) {
|
||||
entries.push(lease)
|
||||
} else {
|
||||
leasesByPtyId.set(lease.ptyId, [lease])
|
||||
}
|
||||
}
|
||||
}
|
||||
const ptyId = operations.toComparablePtyId(binding.targetId, binding.ptyId)
|
||||
return (leasesByPtyId.get(ptyId) ?? []).some((lease) =>
|
||||
sshRemotePtyLeaseMayReferenceBinding(lease, { ...binding, ptyId })
|
||||
)
|
||||
}
|
||||
let changed = false
|
||||
const sessions = new Set(
|
||||
[
|
||||
@@ -62,14 +88,7 @@ export function clearSshRemotePtyBindingsForLeases(
|
||||
for (const tab of tabs) {
|
||||
if (
|
||||
tab.ptyId &&
|
||||
leases.some((lease) =>
|
||||
sshRemotePtyLeaseMayReferenceBinding(operations, lease, {
|
||||
ptyId: tab.ptyId!,
|
||||
worktreeId,
|
||||
targetId,
|
||||
tabId: tab.id
|
||||
})
|
||||
)
|
||||
referencesBinding({ ptyId: tab.ptyId, worktreeId, targetId, tabId: tab.id })
|
||||
) {
|
||||
tab.ptyId = null
|
||||
changed = true
|
||||
@@ -92,16 +111,7 @@ export function clearSshRemotePtyBindingsForLeases(
|
||||
const worktreeId = worktreeIdByTabId.get(tabId)
|
||||
const nextBindings = Object.fromEntries(
|
||||
Object.entries(bindings).filter(
|
||||
([leafId, ptyId]) =>
|
||||
!leases.some((lease) =>
|
||||
sshRemotePtyLeaseMayReferenceBinding(operations, lease, {
|
||||
ptyId,
|
||||
targetId,
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
)
|
||||
([leafId, ptyId]) => !referencesBinding({ ptyId, targetId, worktreeId, tabId, leafId })
|
||||
)
|
||||
)
|
||||
if (Object.keys(nextBindings).length !== Object.keys(bindings).length) {
|
||||
|
||||
@@ -168,4 +168,45 @@ describe('workspace session terminal binding replay', () => {
|
||||
[LEAF_TWO]: 'pty-b'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when one tab id is duplicated inside a single worktree list', () => {
|
||||
// Map indexing is last-wins where a linear find was first-wins; the ambiguity
|
||||
// fence must skip these ids so the two strategies can never disagree.
|
||||
const prior = session(null)
|
||||
prior.tabsByWorktree = {
|
||||
[WORKTREE_A]: [
|
||||
terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-first'),
|
||||
terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-last')
|
||||
]
|
||||
}
|
||||
const incoming = session(null)
|
||||
incoming.tabsByWorktree = {
|
||||
[WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', null)]
|
||||
}
|
||||
|
||||
preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never)
|
||||
|
||||
expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('indexes prior tabs once when replaying a large workspace snapshot', () => {
|
||||
let reads = 0
|
||||
const prior = session(null)
|
||||
prior.tabsByWorktree.worktree = Array.from({ length: 1000 }, (_, i) => ({
|
||||
...terminalTab('worktree', `tab-${i}`, `pty-${i}`),
|
||||
get id() {
|
||||
reads++
|
||||
return `tab-${i}`
|
||||
}
|
||||
}))
|
||||
const incoming = session(null)
|
||||
incoming.tabsByWorktree.worktree = Array.from({ length: 1000 }, (_, i) =>
|
||||
terminalTab('worktree', `tab-${i}`, null)
|
||||
)
|
||||
preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never)
|
||||
expect(reads).toBeLessThan(10_000)
|
||||
expect(incoming.tabsByWorktree.worktree.map((tab) => tab.ptyId)).toEqual(
|
||||
Array.from({ length: 1000 }, (_, i) => `pty-${i}`)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,6 +93,7 @@ export function preserveMissingWorkspaceSessionTerminalBindings(
|
||||
if (!priorList) {
|
||||
continue
|
||||
}
|
||||
let priorById: Map<string, (typeof priorList)[number]> | undefined
|
||||
for (const tab of tabs) {
|
||||
if (ambiguousTabIds.has(tab.id)) {
|
||||
continue
|
||||
@@ -100,7 +101,8 @@ export function preserveMissingWorkspaceSessionTerminalBindings(
|
||||
if (tab.ptyId) {
|
||||
continue
|
||||
}
|
||||
const priorTab = priorList.find((candidate) => candidate.id === tab.id)
|
||||
priorById ??= new Map(priorList.map((candidate) => [candidate.id, candidate]))
|
||||
const priorTab = priorById.get(tab.id)
|
||||
const incomingLayout = nextLayouts[tab.id]
|
||||
const priorLayout = priorLayouts[tab.id]
|
||||
const priorPtyLeafId = priorLayout
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types'
|
||||
import { legacyMigrationUnsupportedRowsToAliasEntries } from './pane-alias-normalization'
|
||||
|
||||
vi.mock('../../agent-hooks/server', () => ({ agentHookServer: {} }))
|
||||
|
||||
it('retains only the ambiguity verdict when many legacy rows name the same tab', () => {
|
||||
const row: MigrationUnsupportedPtyEntry = {
|
||||
ptyId: 'pty',
|
||||
tabId: 'tab',
|
||||
paneKey: 'tab:11111111-1111-4111-8111-111111111111',
|
||||
reason: 'legacy-numeric-pane-key',
|
||||
source: 'local',
|
||||
updatedAt: 1
|
||||
}
|
||||
const rows = Array.from({ length: 2000 }, () => row)
|
||||
const iterator = Array.prototype[Symbol.iterator]
|
||||
let copied = 0
|
||||
Array.prototype[Symbol.iterator] = function (this: unknown[]) {
|
||||
if (this[0] === row) {
|
||||
copied += this.length
|
||||
}
|
||||
return iterator.call(this)
|
||||
}
|
||||
let aliases: ReturnType<typeof legacyMigrationUnsupportedRowsToAliasEntries>
|
||||
try {
|
||||
aliases = legacyMigrationUnsupportedRowsToAliasEntries(rows)
|
||||
} finally {
|
||||
Array.prototype[Symbol.iterator] = iterator
|
||||
}
|
||||
expect(copied).toBeLessThan(10_000)
|
||||
expect(aliases).toEqual([])
|
||||
const unique = legacyMigrationUnsupportedRowsToAliasEntries([row])
|
||||
expect(unique.map((entry) => entry.legacyPaneKey)).toEqual(['tab:0', 'tab:1'])
|
||||
expect(unique.every((entry) => entry.stablePaneKey === row.paneKey)).toBe(true)
|
||||
})
|
||||
|
||||
function legacyRow(overrides: Partial<MigrationUnsupportedPtyEntry>): MigrationUnsupportedPtyEntry {
|
||||
return {
|
||||
ptyId: 'pty',
|
||||
tabId: 'tab',
|
||||
paneKey: 'tab:11111111-1111-4111-8111-111111111111',
|
||||
reason: 'legacy-numeric-pane-key',
|
||||
source: 'local',
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Ambiguity must fail closed: only an exactly-one row per tab may mint an alias.
|
||||
it.each([
|
||||
['0 rows', 0],
|
||||
['2 rows', 2],
|
||||
['3 rows', 3],
|
||||
['4 rows', 4]
|
||||
])('mints no alias for a tab named by %s', (_label, count) => {
|
||||
const rows = Array.from({ length: count }, (_, i) =>
|
||||
legacyRow({
|
||||
ptyId: `pty-${i}`,
|
||||
paneKey: `tab:1111111${i}-1111-4111-8111-111111111111`
|
||||
})
|
||||
)
|
||||
expect(legacyMigrationUnsupportedRowsToAliasEntries(rows)).toEqual([])
|
||||
})
|
||||
|
||||
it('mints both numeric aliases for a tab named by exactly 1 row', () => {
|
||||
const row = legacyRow({ ptyId: 'pty-solo' })
|
||||
expect(legacyMigrationUnsupportedRowsToAliasEntries([row])).toEqual([
|
||||
{
|
||||
ptyId: 'pty-solo',
|
||||
legacyPaneKey: 'tab:0',
|
||||
stablePaneKey: row.paneKey,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
ptyId: 'pty-solo',
|
||||
legacyPaneKey: 'tab:1',
|
||||
stablePaneKey: row.paneKey,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps unambiguous tabs in first-seen order while dropping ambiguous neighbours', () => {
|
||||
const solo = legacyRow({
|
||||
tabId: 'solo',
|
||||
ptyId: 'pty-solo',
|
||||
paneKey: 'solo:11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
const dupA = legacyRow({
|
||||
tabId: 'dup',
|
||||
ptyId: 'pty-a',
|
||||
paneKey: 'dup:22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
const dupB = legacyRow({
|
||||
tabId: 'dup',
|
||||
ptyId: 'pty-b',
|
||||
paneKey: 'dup:33333333-3333-4333-8333-333333333333'
|
||||
})
|
||||
const late = legacyRow({
|
||||
tabId: 'late',
|
||||
ptyId: 'pty-late',
|
||||
paneKey: 'late:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
// A third row for 'dup' must not resurrect it: ambiguity is sticky, not a parity toggle.
|
||||
const aliases = legacyMigrationUnsupportedRowsToAliasEntries([solo, dupA, dupB, late, dupA])
|
||||
expect(aliases.map((entry) => entry.legacyPaneKey)).toEqual([
|
||||
'solo:0',
|
||||
'solo:1',
|
||||
'late:0',
|
||||
'late:1'
|
||||
])
|
||||
expect(aliases.every((entry) => entry.ptyId !== 'pty-a' && entry.ptyId !== 'pty-b')).toBe(true)
|
||||
})
|
||||
@@ -14,21 +14,17 @@ export function legacyMigrationUnsupportedRowsToAliasEntries(
|
||||
const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter(
|
||||
(entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey)
|
||||
)
|
||||
const entriesByTabId = new Map<string, MigrationUnsupportedPtyEntry[]>()
|
||||
const entriesByTabId = new Map<string, MigrationUnsupportedPtyEntry | null>()
|
||||
for (const entry of normalizedEntries) {
|
||||
const tabId = entry.tabId
|
||||
if (!tabId) {
|
||||
continue
|
||||
}
|
||||
entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry])
|
||||
entriesByTabId.set(tabId, entriesByTabId.has(tabId) ? null : entry)
|
||||
}
|
||||
const aliasEntries: LegacyPaneKeyAliasEntry[] = []
|
||||
for (const [tabId, tabEntries] of entriesByTabId) {
|
||||
if (tabEntries.length !== 1) {
|
||||
continue
|
||||
}
|
||||
const [entry] = tabEntries
|
||||
if (!entry.paneKey) {
|
||||
for (const [tabId, entry] of entriesByTabId) {
|
||||
if (!entry?.paneKey) {
|
||||
continue
|
||||
}
|
||||
// Why: pre-stable rows lack the old numeric key; only synthesize single-pane aliases when the row is unambiguous.
|
||||
|
||||
@@ -138,7 +138,9 @@ describe('createNestedProjectGroupResolver', () => {
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'separate',
|
||||
repoPaths: ['/workspace/services/api', '/workspace/services/worker'],
|
||||
get repoPaths(): readonly string[] {
|
||||
throw new Error('separate imports must not build unused folder scopes')
|
||||
},
|
||||
createGroup: () => {
|
||||
throw new Error('should not create a group')
|
||||
}
|
||||
@@ -148,6 +150,30 @@ describe('createNestedProjectGroupResolver', () => {
|
||||
expect(resolver.getCreatedGroups()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves every separate-import repo ungrouped even when repo paths are supplied', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const repoPaths = [
|
||||
'/workspace/services/api',
|
||||
'/workspace/services/worker',
|
||||
'/workspace/platform/packages/shared'
|
||||
]
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'separate',
|
||||
repoPaths,
|
||||
createGroup
|
||||
})
|
||||
|
||||
expect(repoPaths.map((repoPath) => resolver.getGroupForRepo(repoPath))).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
expect(resolver.getRootGroup()).toBeUndefined()
|
||||
expect(groups).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves filesystem root parent paths when creating the root group', () => {
|
||||
const groups: ProjectGroup[] = []
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
|
||||
@@ -150,10 +150,12 @@ export function createNestedProjectGroupResolver(args: {
|
||||
createGroup: (input: CreateGroupInput) => ProjectGroup
|
||||
}): NestedProjectGroupResolver {
|
||||
const createdGroups: ProjectGroup[] = []
|
||||
const folderScopes = buildSparseFolderScopes({
|
||||
parentPath: args.parentPath,
|
||||
repoPaths: args.repoPaths ?? []
|
||||
})
|
||||
// Every folder-scope read sits behind ensureRootGroup, so outside group mode the scopes are
|
||||
// unreachable. One flag drives both so the skip can never drift from the guard that justifies it.
|
||||
const createsGroups = args.mode === 'group'
|
||||
const folderScopes = createsGroups
|
||||
? buildSparseFolderScopes({ parentPath: args.parentPath, repoPaths: args.repoPaths ?? [] })
|
||||
: []
|
||||
const folderScopesByRelativePath = new Map(
|
||||
folderScopes.map((scope) => [scope.relativePath, scope])
|
||||
)
|
||||
@@ -161,7 +163,7 @@ export function createNestedProjectGroupResolver(args: {
|
||||
let rootGroup: ProjectGroup | undefined
|
||||
|
||||
const ensureRootGroup = (): ProjectGroup | undefined => {
|
||||
if (args.mode !== 'group') {
|
||||
if (!createsGroups) {
|
||||
return undefined
|
||||
}
|
||||
if (rootGroup) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
|
||||
import { OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner } from './orca-runtime-structured-agent-session-recover-tui-owner'
|
||||
import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude'
|
||||
import type { RuntimeWorktreePsResult } from '../../shared/runtime-types'
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
applyRuntimeWorktreePsTerminalActivity
|
||||
} from './runtime-worktree-ps-activity'
|
||||
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { compareWorktreePs } from './runtime-worktree-status-projection'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
@@ -102,11 +104,15 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
|
||||
summaries,
|
||||
pathIndex: runtimeWorktreeSummaryPathIndex,
|
||||
missingWorktreeIds: missingRuntimeWorktreeIds,
|
||||
mirroredWorktreeIdByTabId,
|
||||
connectedPtyEvidence,
|
||||
workingTerminalEvidenceByWorktreeId,
|
||||
retainedSnapshots: this.agentRows.values(),
|
||||
hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [],
|
||||
rowSources: collectRuntimeWorktreeAgentSources({
|
||||
mirroredWorktreeIdByTabId,
|
||||
connectedPtyEvidence,
|
||||
retainedSnapshots: this.agentRows.values(),
|
||||
hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [],
|
||||
// Broadcast history outlives closed sessions; only the host roster is eligible.
|
||||
structuredSummaries: getStructuredAgentSessionHost()?.liveSessionStatusSummaries() ?? []
|
||||
}),
|
||||
orchestrationByPaneKey: this.agentOrchestrationProjection.buildByPaneKey(),
|
||||
getSummary: (summaryMap, pathIndex, missingIds, worktreeId) =>
|
||||
this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
|
||||
import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionStatusEvent,
|
||||
AgentSessionStatusSummary
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../../native-chat/agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import {
|
||||
getStructuredAgentSessionHost,
|
||||
setStructuredAgentSessionHost
|
||||
} from '../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { StructuredAgentSessionStatusFeed } from '../../native-chat/agent-session-wire/structured-agent-session-status-feed'
|
||||
|
||||
/**
|
||||
* The production wiring, not the projection. Both structured-row suites call
|
||||
* `attachRuntimeWorktreeAgentRows` directly with summaries they built themselves, so nothing
|
||||
* executed `getWorktreePs`'s own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()`
|
||||
* — and that file carries `@ts-nocheck`, so renaming the accessor stayed green in typecheck AND
|
||||
* in the suite while `orca worktree ps` and mobile's poll would throw for every user.
|
||||
*/
|
||||
|
||||
const HELD_SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
|
||||
const FORGOTTEN_SESSION = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e'
|
||||
const OBSERVED_AT = 1_757_030_400_000
|
||||
|
||||
function runningTurn(prompt: string): AgentJournalRenderItem[] {
|
||||
return [
|
||||
{
|
||||
itemId: 'user-1',
|
||||
sequence: 1,
|
||||
revision: 1,
|
||||
observedAt: OBSERVED_AT,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: prompt }] }
|
||||
},
|
||||
{
|
||||
itemId: 'turn-1',
|
||||
sequence: 2,
|
||||
revision: 1,
|
||||
observedAt: OBSERVED_AT,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Working',
|
||||
turnLifecycle: { turnId: 'turn-1', state: 'running' }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function journalWith(prompt: string): AgentSessionJournal {
|
||||
return {
|
||||
isReadOnly: false,
|
||||
lastActivityAt: () => OBSERVED_AT,
|
||||
snapshot: () => ({ items: runningTurn(prompt) })
|
||||
} as unknown as AgentSessionJournal
|
||||
}
|
||||
|
||||
/** A real feed the host still holds one session on, having forgotten the other. Its `published`
|
||||
* cache never retracts, so the two views genuinely differ. */
|
||||
function statusFeed(): StructuredAgentSessionStatusFeed {
|
||||
const session = (prompt: string) => ({
|
||||
journal: journalWith(prompt),
|
||||
params: { location: { workspaceId: TEST_WORKTREE_ID }, provider: 'claude' as const }
|
||||
})
|
||||
const sessions = new Map([
|
||||
[HELD_SESSION, session('ship the thing')],
|
||||
[FORGOTTEN_SESSION, session('rm the branch')]
|
||||
])
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
sessions,
|
||||
getRecord: () => null,
|
||||
now: () => OBSERVED_AT
|
||||
})
|
||||
feed.publish(HELD_SESSION)
|
||||
feed.publish(FORGOTTEN_SESSION)
|
||||
// `forget-session`, the last eviction step, drops the session and leaves the feed alone.
|
||||
sessions.delete(FORGOTTEN_SESSION)
|
||||
return feed
|
||||
}
|
||||
|
||||
/** Everything the feed retained, read through the snapshot a subscriber opens on. No production
|
||||
* code reads this; it is here so swapping the call site back to a whole-cache read is a one-line
|
||||
* edit that this suite must catch. */
|
||||
function retainedSummaries(feed: StructuredAgentSessionStatusFeed): AgentSessionStatusSummary[] {
|
||||
let retained: AgentSessionStatusSummary[] = []
|
||||
feed.subscribe({
|
||||
id: 'retained-probe',
|
||||
emit: (event: AgentSessionStatusEvent) => {
|
||||
if (event.type === 'snapshot') {
|
||||
retained = event.sessions
|
||||
}
|
||||
}
|
||||
})()
|
||||
return retained
|
||||
}
|
||||
|
||||
function installHost(feed: StructuredAgentSessionStatusFeed) {
|
||||
const liveSessionStatusSummaries = vi.fn(() => feed.liveSessionSummaries())
|
||||
const retainedSessionStatusSummaries = vi.fn(() => retainedSummaries(feed))
|
||||
// Typed against the real host, so renaming the accessor on the class reddens `tc` here — the
|
||||
// caller cannot, because `orca-runtime-get-worktree-ps.ts` is `@ts-nocheck`.
|
||||
const host: Pick<StructuredAgentSessionHost, 'liveSessionStatusSummaries'> & {
|
||||
retainedSessionStatusSummaries: () => AgentSessionStatusSummary[]
|
||||
} = { liveSessionStatusSummaries, retainedSessionStatusSummaries }
|
||||
setStructuredAgentSessionHost(host as unknown as StructuredAgentSessionHost)
|
||||
return { liveSessionStatusSummaries, retainedSessionStatusSummaries }
|
||||
}
|
||||
|
||||
describe('worktree ps reads the installed structured host', () => {
|
||||
afterEach(() => {
|
||||
setStructuredAgentSessionHost(null)
|
||||
})
|
||||
|
||||
it('reports the held session and asks the host for its live summaries', async () => {
|
||||
const feed = statusFeed()
|
||||
const { liveSessionStatusSummaries } = installHost(feed)
|
||||
|
||||
const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs()
|
||||
|
||||
const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID)
|
||||
expect(worktree).toBeDefined()
|
||||
// Exactly one: the forgotten session is still in the feed's retained cache, so a call site
|
||||
// that enumerated that cache instead would report two.
|
||||
expect(worktree?.agents).toHaveLength(1)
|
||||
expect(worktree?.agents[0]).toMatchObject({
|
||||
state: 'working',
|
||||
agentType: 'claude',
|
||||
prompt: 'ship the thing'
|
||||
})
|
||||
// Pins the call site to the live-intersecting accessor, not merely to some accessor.
|
||||
expect(liveSessionStatusSummaries).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('succeeds with no structured rows when no host is installed', async () => {
|
||||
// Guard the guard: these specs share one module registry, so state the premise.
|
||||
expect(getStructuredAgentSessionHost()).toBeNull()
|
||||
|
||||
const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs()
|
||||
|
||||
const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID)
|
||||
expect(worktree).toBeDefined()
|
||||
expect(worktree?.agents).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -85,6 +85,7 @@ await import('./orca-runtime-tests/mobile-summaries.spec')
|
||||
await import('./orca-runtime-tests/mobile-summaries-part-02.spec')
|
||||
await import('./orca-runtime-tests/mobile-summaries-part-03.spec')
|
||||
await import('./orca-runtime-tests/mobile-summaries-part-04.spec')
|
||||
await import('./orca-runtime-tests/worktree-ps-structured-host.spec')
|
||||
await import('./orca-runtime-tests/terminal-sleep-and-teardown.spec')
|
||||
await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec')
|
||||
await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec')
|
||||
|
||||
@@ -146,6 +146,36 @@ describe('worker transcript wire bounds', () => {
|
||||
expect(result).toMatchObject({ limited: false, warnings: [] })
|
||||
})
|
||||
|
||||
it('keeps two roster ids sharing a 512-char prefix distinct', () => {
|
||||
// The id is the roster key: a plain prefix clip would merge the two children.
|
||||
const head = 'a'.repeat(512)
|
||||
const result = boundWorkerTranscriptMessages([
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
timestamp: null,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'subagent-group',
|
||||
groupId: 'g',
|
||||
agents: [
|
||||
{ id: `${head}-one`, label: 'Audit', state: 'working' },
|
||||
{ id: `${head}-two`, label: 'Audit', state: 'working' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const block = result.messages[0]?.blocks[0]
|
||||
if (block?.type !== 'subagent-group') {
|
||||
throw new Error('expected a subagent-group block')
|
||||
}
|
||||
expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id)
|
||||
expect(block.agents[0]?.id).toHaveLength(512)
|
||||
})
|
||||
|
||||
it('keeps fallback identifiers stable without exposing the transcript path', () => {
|
||||
const transcriptPath = 'C:\\Users\\worker\\.codex\\session.jsonl'
|
||||
const message = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
NativeChatMessage,
|
||||
NativeChatSubagentState
|
||||
} from '../../../shared/native-chat-types'
|
||||
import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds'
|
||||
|
||||
export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40
|
||||
export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50
|
||||
@@ -153,7 +154,7 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native
|
||||
groupId: clipMetadata(block.groupId, state),
|
||||
agents: agents.map((agent) => ({
|
||||
...agent,
|
||||
id: clipMetadata(agent.id, state),
|
||||
id: boundEntryId(agent.id, state),
|
||||
label: clipMetadata(agent.label, state),
|
||||
state: clipSubagentState(agent.state, state)
|
||||
}))
|
||||
@@ -194,6 +195,18 @@ function isLocalFileLocator(value: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** A roster entry's id is the roster KEY, so it is redacted like other metadata
|
||||
* but bounded with a digest rather than clipped: two ids sharing a 512-char
|
||||
* head must not collapse onto one entry. */
|
||||
function boundEntryId(value: string, state: TranscriptBoundState): string {
|
||||
const redacted = redactSensitiveText(value, state.warnings)
|
||||
const bounded = boundSubagentEntryId(redacted)
|
||||
if (bounded !== redacted) {
|
||||
markClipped(state, 'Oversized transcript metadata was clipped.')
|
||||
}
|
||||
return bounded
|
||||
}
|
||||
|
||||
function clipMetadata(value: string, state: TranscriptBoundState): string {
|
||||
const redacted = redactSensitiveText(value, state.warnings)
|
||||
if (redacted.length <= MAX_WORKER_TRANSCRIPT_METADATA_CHARS) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
|
||||
import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize'
|
||||
|
||||
const SHARED_HEAD = 'a'.repeat(512)
|
||||
|
||||
function rosterBlock(ids: readonly string[]): NativeChatBlock {
|
||||
return {
|
||||
type: 'subagent-group',
|
||||
groupId: 'group-1',
|
||||
agents: ids.map((id) => ({ id, label: 'l'.repeat(900), state: 'working' as const }))
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile subagent roster bounds', () => {
|
||||
it('keeps two ids sharing a 512-char prefix distinct', () => {
|
||||
const block = sanitizeNativeChatRpcBlock(
|
||||
rosterBlock([`${SHARED_HEAD}-one`, `${SHARED_HEAD}-two`]),
|
||||
'mobile'
|
||||
)
|
||||
|
||||
if (block.type !== 'subagent-group') {
|
||||
throw new Error('expected a subagent-group block')
|
||||
}
|
||||
expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id)
|
||||
expect(block.agents[0]?.id).toHaveLength(512)
|
||||
// The label is display text and still clips.
|
||||
expect(block.agents[0]?.label).toContain('… (truncated)')
|
||||
})
|
||||
|
||||
it('leaves a short id alone', () => {
|
||||
const block = sanitizeNativeChatRpcBlock(rosterBlock(['task-1']), 'mobile')
|
||||
expect(block.type === 'subagent-group' && block.agents[0]?.id).toBe('task-1')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
normalizeSubagentState
|
||||
} from '../../../../shared/native-chat-subagent-summary'
|
||||
import type { NativeChatBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types'
|
||||
import { boundSubagentEntryId } from '../../../native-chat/subagent-entry-id-bounds'
|
||||
import type { RpcContext } from '../core'
|
||||
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
|
||||
|
||||
@@ -63,7 +64,9 @@ export function sanitizeNativeChatRpcBlock(
|
||||
groupId: clip(block.groupId, MAX_SUBAGENT_FIELD_CHARS),
|
||||
agents: block.agents.slice(0, MOBILE_SUBAGENT_CAP).map((agent) => ({
|
||||
...agent,
|
||||
id: clip(agent.id, MAX_SUBAGENT_FIELD_CHARS),
|
||||
// The id is the roster KEY: a prefix clip would merge two children, so
|
||||
// it takes the shared digest bound the other wires use.
|
||||
id: boundSubagentEntryId(agent.id),
|
||||
label: clip(agent.label, MAX_SUBAGENT_FIELD_CHARS),
|
||||
state: clipSubagentState(agent.state)
|
||||
}))
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type {
|
||||
NativeChatMessage,
|
||||
NativeChatSubagentEntry
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import type { RpcContext } from '../core'
|
||||
|
||||
// Stub the bounded tail reader so the handler returns a deterministic transcript with
|
||||
@@ -88,6 +91,7 @@ vi.mock('../../../native-chat/transcript-watch', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
import { boundSubagentEntryId } from '../../../native-chat/subagent-entry-id-bounds'
|
||||
import { NATIVE_CHAT_METHODS } from './native-chat'
|
||||
|
||||
function makeMessage(text: string): NativeChatMessage {
|
||||
@@ -191,6 +195,35 @@ describe('nativeChat.readSession clientKind truncation gating', () => {
|
||||
expect(block.text).toBe(text)
|
||||
})
|
||||
|
||||
it('bounds a subagent roster before it reaches mobile', async () => {
|
||||
const agents: NativeChatSubagentEntry[] = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `task-${index}-${'i'.repeat(600)}`,
|
||||
label: 'l'.repeat(600),
|
||||
state: 'working'
|
||||
}))
|
||||
cachedResult.value = {
|
||||
messages: [
|
||||
{
|
||||
...makeMessage(''),
|
||||
blocks: [{ type: 'subagent-group', groupId: 'g', agents }]
|
||||
}
|
||||
]
|
||||
}
|
||||
const result = await readSessionHandler()(
|
||||
{ agent: 'claude', sessionId: 's' },
|
||||
ctxWith('mobile')
|
||||
)
|
||||
const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] as {
|
||||
agents: { id: string; label: string }[]
|
||||
}
|
||||
expect(block.agents).toHaveLength(64)
|
||||
expect(block.agents[0].label).toBe(`${'l'.repeat(512)}\n… (truncated)`)
|
||||
// The id is as untrusted as the label on an imported roster, but it is the
|
||||
// roster key: it is bounded with a digest, never clipped to a bare prefix.
|
||||
expect(block.agents[0].id).toHaveLength(512)
|
||||
expect(block.agents[0].id).toBe(boundSubagentEntryId(`task-0-${'i'.repeat(600)}`))
|
||||
})
|
||||
|
||||
it('clips a pathological text block at the safety ceiling for mobile clients', async () => {
|
||||
const text = 'y'.repeat(70_000)
|
||||
cachedResult.value = { messages: [makeTextMessage(text)] }
|
||||
|
||||
@@ -80,6 +80,8 @@ export async function createStructuredWorkerSession(args: {
|
||||
worktreeId: string
|
||||
agent: 'claude' | 'codex'
|
||||
dispatchId: string
|
||||
/** The dispatch's own `--model`/`--effort`, already narrowed to the seedable string subset. */
|
||||
options?: Readonly<Record<string, string>>
|
||||
/** Retried whenever the session's journal moves, which is the structured idle edge. */
|
||||
onJournalActivity: (sessionId: string) => void
|
||||
}): Promise<{ identity: StructuredWorkerIdentity; host: StructuredAgentSessionHost }> {
|
||||
@@ -120,6 +122,8 @@ export async function createStructuredWorkerSession(args: {
|
||||
},
|
||||
worktree: `id:${args.worktreeId}`,
|
||||
agent: args.agent,
|
||||
// Absent, the host seeds the user's saved selection — the same fallback a chat gets.
|
||||
...(args.options ? { options: args.options } : {}),
|
||||
// Dispatching a worker is background work; it must not pull the surface away from the user.
|
||||
activate: false
|
||||
})
|
||||
|
||||
@@ -125,6 +125,29 @@ describe('worker-start honours the settings default', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function mockWorktreeCreation() {
|
||||
vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({
|
||||
id: 'repo::wt',
|
||||
repoId: 'repo'
|
||||
} as never)
|
||||
vi.spyOn(runtime, 'showRepo').mockResolvedValue({ id: 'repo', kind: 'git' } as never)
|
||||
vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ terminals: [] } as never)
|
||||
return vi.spyOn(runtime, 'createManagedWorktree').mockImplementation(
|
||||
async (createArgs) =>
|
||||
({
|
||||
worktree: { id: 'repo::child', repoId: 'repo' },
|
||||
...(createArgs.startupAgent
|
||||
? { startupTerminal: { spawned: true, handle: TERMINAL_HANDLE } }
|
||||
: {}),
|
||||
setupReceipt: {
|
||||
hookFound: false,
|
||||
startupPolicy: 'start-immediately',
|
||||
state: 'not_configured'
|
||||
}
|
||||
}) as never
|
||||
)
|
||||
}
|
||||
|
||||
it('starts a structured chat worker when structured native chat is the default', async () => {
|
||||
const result = await startWorker(STRUCTURED_DEFAULT)
|
||||
|
||||
@@ -172,12 +195,74 @@ describe('worker-start honours the settings default', () => {
|
||||
expect(createExistingWorktreeWorkerTerminal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back instead of refusing a launch preference the structured default cannot apply', async () => {
|
||||
it('seeds --model and --effort into the structured session instead of downgrading', async () => {
|
||||
// These two used to force a PTY worker, which is half of why orchestration never produced a
|
||||
// structured chat: choosing a model is the ordinary way to dispatch one.
|
||||
const result = await startWorker(STRUCTURED_DEFAULT, { model: 'opus', effort: 'high' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'ready',
|
||||
mode: { mode: 'terminal', preferred: 'structured', reason: 'launch_preferences' }
|
||||
mode: { mode: 'structured', preferred: 'structured', reason: 'user_default' }
|
||||
})
|
||||
expect(createExistingWorktreeWorkerTerminal).not.toHaveBeenCalled()
|
||||
expect(createStructuredWorkerSessionForWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ launchPreferences: { model: 'opus', effort: 'high' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a new worktree WITHOUT an agent terminal and gives it a structured session', async () => {
|
||||
// The other half: `createWorkerWorktree` creates agent-first, so a `--worktree new-child`
|
||||
// dispatch could only ever end up a PTY terminal worker.
|
||||
const created = mockWorktreeCreation()
|
||||
|
||||
const result = await startWorker(STRUCTURED_DEFAULT, {
|
||||
worktree: 'new-child',
|
||||
name: 'worker-child'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'ready',
|
||||
mode: { mode: 'structured', preferred: 'structured', reason: 'user_default' }
|
||||
})
|
||||
expect(created).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ startupAgent: expect.anything() })
|
||||
)
|
||||
expect(createStructuredWorkerSessionForWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: 'repo::child' })
|
||||
)
|
||||
expect(createExistingWorktreeWorkerTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still creates the new worktree agent-first when the default is a terminal worker', async () => {
|
||||
const created = mockWorktreeCreation()
|
||||
|
||||
const result = await startWorker(
|
||||
{ ...STRUCTURED_DEFAULT, experimentalStructuredNativeChat: false },
|
||||
{ worktree: 'new-child', name: 'worker-child' }
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ state: 'ready', mode: { mode: 'terminal' } })
|
||||
expect(created).toHaveBeenCalledWith(expect.objectContaining({ startupAgent: 'claude' }))
|
||||
expect(createStructuredWorkerSessionForWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a terminal agent in the worktree it just created when the host refuses', async () => {
|
||||
// The host can only answer for a workspace that exists, so a created worktree settles its mode
|
||||
// after creation — and a refusal must not fail a routine dispatch.
|
||||
mockWorktreeCreation()
|
||||
vi.mocked(runtime.getStructuredAgentSessionCreateSupport).mockResolvedValue({
|
||||
supported: false,
|
||||
reason: 'wsl'
|
||||
})
|
||||
|
||||
const result = await startWorker(STRUCTURED_DEFAULT, {
|
||||
worktree: 'new-child',
|
||||
name: 'worker-child'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'ready',
|
||||
mode: { mode: 'terminal', preferred: 'structured', reason: 'wsl_execution_runtime' }
|
||||
})
|
||||
expect(createExistingWorktreeWorkerTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(createStructuredWorkerSessionForWorktree).not.toHaveBeenCalled()
|
||||
|
||||
@@ -63,10 +63,6 @@ describe('a structured default this dispatch cannot honour', () => {
|
||||
it.each([
|
||||
['a remote --on', { on: 'server-1' }, 'remote_execution_host'],
|
||||
['an existing --terminal', { terminal: 'term_1' }, 'reused_terminal'],
|
||||
['a new-child worktree', { worktree: 'new-child' }, 'worktree_creation'],
|
||||
['a new-top-level worktree', { worktree: 'new-top-level' }, 'worktree_creation'],
|
||||
['--model', { model: 'opus' }, 'launch_preferences'],
|
||||
['--effort', { effort: 'high' }, 'launch_preferences'],
|
||||
['a non-structured agent', { agent: 'cursor' }, 'agent_without_structured_session'],
|
||||
['no agent at all', { agent: undefined }, 'agent_without_structured_session']
|
||||
])('falls back to a terminal worker for %s', (_name, params, reason) => {
|
||||
@@ -76,6 +72,22 @@ describe('a structured default this dispatch cannot honour', () => {
|
||||
expect(receipt.detail).toContain('Your default is a structured chat session')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a new-child worktree', { worktree: 'new-child' }],
|
||||
['a new-top-level worktree', { worktree: 'new-top-level' }],
|
||||
['--model', { model: 'opus' }],
|
||||
['--effort with its model', { model: 'opus', effort: 'high' }],
|
||||
['both at once', { worktree: 'new-child', model: 'opus', effort: 'high' }]
|
||||
])('no longer downgrades for %s, which is what made structured unreachable', (_name, params) => {
|
||||
// Both flags are what a routine dispatch passes, and each used to force a PTY worker, so
|
||||
// orchestration never produced a structured chat in practice.
|
||||
expect(decide({ params: { agent: 'claude', ...params } })).toMatchObject({
|
||||
mode: 'structured',
|
||||
preferred: 'structured',
|
||||
reason: 'user_default'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the current worktree structured, which is the ordinary dispatch', () => {
|
||||
expect(decide({ params: { agent: 'codex', worktree: 'current' } }).mode).toBe('structured')
|
||||
})
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
*
|
||||
* The settings default and the per-launch feasibility both come from
|
||||
* `shared/structured-native-chat-launch-route`, the same module the renderer's
|
||||
* `resolveAgentLaunchRoute` uses; only the placement options that exist solely on this command are
|
||||
* decided here.
|
||||
* `resolveAgentLaunchRoute` uses. This adapter supplies placement facts and formats the receipt;
|
||||
* it does not own a second feasibility policy.
|
||||
*/
|
||||
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
@@ -31,11 +31,10 @@ export type WorkerStartModeReason =
|
||||
| 'user_default'
|
||||
| 'remote_execution_host'
|
||||
| 'reused_terminal'
|
||||
| 'worktree_creation'
|
||||
| 'launch_preferences'
|
||||
| 'agent_without_structured_session'
|
||||
| 'tui_launch_customization'
|
||||
| 'structured_sessions_unavailable'
|
||||
| 'structured_support_unknown'
|
||||
| 'wsl_execution_runtime'
|
||||
| 'codex_on_windows'
|
||||
| 'structured_unsupported_on_host'
|
||||
@@ -55,6 +54,9 @@ type WorkerStartModeSettings = Partial<
|
||||
Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>
|
||||
>
|
||||
|
||||
/** The placement options that exist only on `worker-start`. `worktree`, `model` and `effort` are
|
||||
* listed but no longer read: a structured worker honours all three, and naming them here keeps
|
||||
* the set of options this decision has considered visible. */
|
||||
type WorkerStartModePlacement = {
|
||||
agent?: string
|
||||
on?: string
|
||||
@@ -65,14 +67,13 @@ type WorkerStartModePlacement = {
|
||||
}
|
||||
|
||||
const DOWNGRADE_DETAIL: Record<Exclude<WorkerStartModeReason, 'user_default'>, string> = {
|
||||
remote_execution_host: '--on runs the worker on a remote execution host',
|
||||
remote_execution_host: 'this worker runs on a remote execution host',
|
||||
reused_terminal: '--terminal reuses a running terminal agent',
|
||||
worktree_creation: 'a new worktree is created with its agent terminal',
|
||||
launch_preferences: '--model and --effort apply only to a terminal agent',
|
||||
agent_without_structured_session: 'this agent has no structured session',
|
||||
tui_launch_customization:
|
||||
'this agent has a custom launch command, arguments or environment that only a terminal applies',
|
||||
structured_sessions_unavailable: 'this runtime does not support structured agent sessions',
|
||||
structured_support_unknown: 'the execution host has not established structured session support',
|
||||
wsl_execution_runtime: 'this workspace runs under WSL',
|
||||
codex_on_windows: 'Codex has no structured session on Windows',
|
||||
structured_unsupported_on_host: 'the execution host cannot create one here'
|
||||
@@ -82,6 +83,7 @@ const BLOCKER_REASON: Record<
|
||||
StructuredNativeChatBlocker,
|
||||
Exclude<WorkerStartModeReason, 'user_default'>
|
||||
> = {
|
||||
'reused-terminal': 'reused_terminal',
|
||||
'agent-without-structured-session': 'agent_without_structured_session',
|
||||
'draft-prompt': 'structured_unsupported_on_host',
|
||||
'floating-workspace': 'structured_unsupported_on_host',
|
||||
@@ -89,9 +91,7 @@ const BLOCKER_REASON: Record<
|
||||
'remote-execution-host': 'remote_execution_host',
|
||||
'project-runtime': 'wsl_execution_runtime',
|
||||
'runtime-capability': 'structured_sessions_unavailable',
|
||||
// Orchestration passes its own host's list, so this is unreachable there; the map is
|
||||
// exhaustive by type and must still name it.
|
||||
'runtime-capability-unknown': 'structured_sessions_unavailable'
|
||||
'runtime-capability-unknown': 'structured_support_unknown'
|
||||
}
|
||||
|
||||
/** The host's own create-support verdict (`agentSession.createSupport`) in this vocabulary. */
|
||||
@@ -117,15 +117,11 @@ export function decideWorkerStartMode(args: {
|
||||
detail: 'Started a terminal agent worker, the default for new agent tabs in your settings.'
|
||||
}
|
||||
}
|
||||
const placementReason = resolvePlacementReason(params)
|
||||
if (placementReason) {
|
||||
return downgraded(placementReason)
|
||||
}
|
||||
const agent = params.agent as TuiAgent
|
||||
const support = resolveStructuredNativeChatSupport({
|
||||
agent,
|
||||
// Set only by --on, which the placement check above already turned into a fallback.
|
||||
executionHostId: 'local',
|
||||
executionHostId: params.on ? `runtime:${params.on}` : 'local',
|
||||
reusesTerminal: Boolean(params.terminal),
|
||||
hostCapabilities: RUNTIME_CAPABILITIES,
|
||||
// Orchestration resolves a managed worktree or folder workspace; a floating terminal is never
|
||||
// a worker placement. WSL is left to the executing host's own create-support probe, which
|
||||
@@ -169,14 +165,14 @@ async function readStructuredCreateSupport(
|
||||
runtime: Pick<OrcaRuntimeService, 'getStructuredAgentSessionCreateSupport'>,
|
||||
worktreeId: string,
|
||||
agent: TuiAgent | undefined
|
||||
): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> {
|
||||
): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null> {
|
||||
if (agent !== 'claude' && agent !== 'codex') {
|
||||
return { supported: false, reason: 'agent' }
|
||||
}
|
||||
try {
|
||||
return await runtime.getStructuredAgentSessionCreateSupport(`id:${worktreeId}`, agent)
|
||||
} catch {
|
||||
return { supported: false }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,34 +182,19 @@ async function readStructuredCreateSupport(
|
||||
*/
|
||||
export function downgradeWorkerStartModeForHost(
|
||||
receipt: WorkerStartModeReceipt,
|
||||
support: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }
|
||||
support: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null
|
||||
): WorkerStartModeReceipt {
|
||||
if (receipt.mode !== 'structured' || support.supported) {
|
||||
if (receipt.mode !== 'structured' || support?.supported) {
|
||||
return receipt
|
||||
}
|
||||
if (support === null) {
|
||||
return downgraded(BLOCKER_REASON['runtime-capability-unknown'])
|
||||
}
|
||||
return downgraded(
|
||||
support.reason ? HOST_SUPPORT_REASON[support.reason] : 'structured_unsupported_on_host'
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePlacementReason(
|
||||
params: WorkerStartModePlacement
|
||||
): Exclude<WorkerStartModeReason, 'user_default'> | null {
|
||||
if (params.on) {
|
||||
return 'remote_execution_host'
|
||||
}
|
||||
if (params.terminal) {
|
||||
return 'reused_terminal'
|
||||
}
|
||||
if (params.worktree === 'new-child' || params.worktree === 'new-top-level') {
|
||||
return 'worktree_creation'
|
||||
}
|
||||
if (params.model || params.effort) {
|
||||
return 'launch_preferences'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function downgraded(
|
||||
reason: Exclude<WorkerStartModeReason, 'user_default'>
|
||||
): WorkerStartModeReceipt {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
decideWorkerStartMode,
|
||||
resolveWorkerStartModeOnHost
|
||||
} from './orchestration-worker-start-mode'
|
||||
|
||||
const mode = decideWorkerStartMode({
|
||||
params: { agent: 'claude' },
|
||||
settings: {
|
||||
experimentalNativeChat: true,
|
||||
experimentalStructuredNativeChat: true,
|
||||
openAgentTabsInChatByDefault: true
|
||||
}
|
||||
})
|
||||
|
||||
describe('host support evidence', () => {
|
||||
it('distinguishes an unanswered host from an explicit refusal without creating a session', async () => {
|
||||
const getStructuredAgentSessionCreateSupport = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('disconnected'))
|
||||
const runtime = { getStructuredAgentSessionCreateSupport }
|
||||
const unknown = await resolveWorkerStartModeOnHost(runtime, mode, 'workspace-1', 'claude')
|
||||
expect(unknown).toMatchObject({
|
||||
mode: 'terminal',
|
||||
preferred: 'structured',
|
||||
reason: 'structured_support_unknown'
|
||||
})
|
||||
expect(unknown.detail).toContain('has not established')
|
||||
expect(getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith('id:workspace-1', 'claude')
|
||||
getStructuredAgentSessionCreateSupport.mockResolvedValue({ supported: false })
|
||||
const refusal = await resolveWorkerStartModeOnHost(runtime, mode, 'workspace-1', 'claude')
|
||||
expect(refusal.reason).toBe('structured_unsupported_on_host')
|
||||
expect(refusal.detail).toContain('cannot create')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TuiAgent } from '../../../../../../shared/tui-agent'
|
||||
import type { OrcaRuntimeService } from '../../../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../../../orchestration/db'
|
||||
import type { RunRow, TaskRow } from '../../../../orchestration/types'
|
||||
@@ -8,6 +7,8 @@ import {
|
||||
resolveWorkerStartModeOnHost,
|
||||
type WorkerStartModeReceipt
|
||||
} from '../../orchestration-worker-start-mode'
|
||||
import { EXISTING_WORKTREE_SETUP, placeWorkerAgent } from './worker-start-agent-placement'
|
||||
import { awaitStructuredWorkerSetupGate } from './worker-start-structured-setup-gate'
|
||||
import { assertOrchestrationWorktreeCreationSupported } from './folder-worktree-placement'
|
||||
import type { WorkerStartInput } from './worker-start-schema'
|
||||
import {
|
||||
@@ -21,15 +22,7 @@ import { assertExplicitWorkerTerminalUsable } from './explicit-worker-terminal-v
|
||||
import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble'
|
||||
import { recordCreatedWorkerTerminalCustody } from './created-worker-terminal-custody'
|
||||
import { tearDownFailedWorkerStart } from './failed-worker-start-teardown'
|
||||
import {
|
||||
createExistingWorktreeWorkerTerminal,
|
||||
createStructuredWorkerSessionForWorktree,
|
||||
createWorkerWorktree,
|
||||
monitorWorkerSetup,
|
||||
requireWorkerAuthority,
|
||||
type WorkerEffect,
|
||||
type WorkerSetupReceipt
|
||||
} from './worker-topology'
|
||||
import { monitorWorkerSetup, requireWorkerAuthority, type WorkerEffect } from './worker-topology'
|
||||
import { prepareLocalWorkerStart } from './worker-start-validation'
|
||||
|
||||
type WorkerStartMutation = {
|
||||
@@ -80,7 +73,7 @@ export async function startLocalWorker(args: {
|
||||
resolvedWorktreeId: resolvedWorktree?.id
|
||||
})
|
||||
}
|
||||
const mode = await resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent)
|
||||
let mode = await resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent)
|
||||
|
||||
const startOptions = {
|
||||
worktree: requestedWorktree,
|
||||
@@ -128,74 +121,33 @@ export async function startLocalWorker(args: {
|
||||
)
|
||||
}
|
||||
let terminalHandle = params.terminal
|
||||
let structuredSession: Awaited<
|
||||
ReturnType<typeof createStructuredWorkerSessionForWorktree>
|
||||
> | null = null
|
||||
let terminalRevealWarning: string | undefined
|
||||
let placed: Awaited<ReturnType<typeof placeWorkerAgent>> | undefined
|
||||
let failedStage = 'terminal_create'
|
||||
let setupReceipt: WorkerSetupReceipt = {
|
||||
requested: 'not_applicable',
|
||||
effective: 'not_applicable',
|
||||
source: 'existing_worktree',
|
||||
hookFound: false,
|
||||
startupPolicy: 'start-immediately',
|
||||
state: 'not_applicable'
|
||||
}
|
||||
try {
|
||||
if (creationWorktree) {
|
||||
failedStage = 'worktree_create'
|
||||
const created = await createWorkerWorktree({
|
||||
runtime,
|
||||
db,
|
||||
dispatchId: started.dispatch.id,
|
||||
requestedWorktree,
|
||||
coordinatorWorktree: creationWorktree,
|
||||
params,
|
||||
agent: agent as TuiAgent,
|
||||
launchPreferences: launch.preferences,
|
||||
effects
|
||||
})
|
||||
resolvedWorktree = created.worktree
|
||||
terminalHandle = created.terminalHandle
|
||||
setupReceipt = created.setupReceipt
|
||||
} else if (!terminalHandle && mode.mode === 'structured') {
|
||||
db.recordWorkerStage({
|
||||
dispatchId: started.dispatch.id,
|
||||
stage: 'terminal_creating',
|
||||
worktreeId: resolvedWorktree!.id,
|
||||
effects
|
||||
})
|
||||
structuredSession = await createStructuredWorkerSessionForWorktree({
|
||||
runtime,
|
||||
worktreeId: resolvedWorktree!.id,
|
||||
agent: agent as TuiAgent,
|
||||
dispatchId: started.dispatch.id,
|
||||
effects
|
||||
})
|
||||
terminalHandle = structuredSession.identity.handle
|
||||
} else if (!terminalHandle) {
|
||||
db.recordWorkerStage({
|
||||
dispatchId: started.dispatch.id,
|
||||
stage: 'terminal_creating',
|
||||
worktreeId: resolvedWorktree!.id,
|
||||
effects
|
||||
})
|
||||
const terminal = await createExistingWorktreeWorkerTerminal({
|
||||
runtime,
|
||||
worktreeId: resolvedWorktree!.id,
|
||||
agent: agent as TuiAgent,
|
||||
launchPreferences: launch.preferences,
|
||||
taskId: task.id,
|
||||
effects
|
||||
})
|
||||
terminalHandle = terminal.handle
|
||||
terminalRevealWarning = terminal.warning
|
||||
} else {
|
||||
effects.push({ kind: 'terminal', role: 'agent', action: 'reused', id: terminalHandle })
|
||||
}
|
||||
if (!resolvedWorktree || !terminalHandle) {
|
||||
throw new Error('Worker topology did not resolve an agent terminal and worktree.')
|
||||
}
|
||||
placed = await placeWorkerAgent({
|
||||
runtime,
|
||||
db,
|
||||
dispatchId: started.dispatch.id,
|
||||
taskId: task.id,
|
||||
params,
|
||||
requestedWorktree,
|
||||
creationWorktree,
|
||||
resolvedWorktree,
|
||||
mode,
|
||||
agent,
|
||||
launchPreferences: launch.preferences,
|
||||
effects,
|
||||
onStage: (stage) => {
|
||||
failedStage = stage
|
||||
}
|
||||
})
|
||||
// A created worktree settles its mode only once the host can be asked about it, so the
|
||||
// receipt the caller decided is not always the one that ran.
|
||||
mode = placed.mode
|
||||
resolvedWorktree = placed.worktree
|
||||
terminalHandle = placed.terminalHandle
|
||||
const structuredSession = placed.structuredSession
|
||||
const setupReceipt = placed.setupReceipt
|
||||
const setupStage = {
|
||||
db,
|
||||
dispatchId: started.dispatch.id,
|
||||
@@ -213,12 +165,20 @@ export async function startLocalWorker(args: {
|
||||
|
||||
failedStage = 'agent_readiness'
|
||||
// A structured session is ready the moment its attach returns ok: there is no boot-to-idle
|
||||
// gap and no terminal title to read an idle edge from.
|
||||
if (!structuredSession) {
|
||||
const wait = await runtime.waitForTerminal(terminalHandle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: params.timeoutMs ?? 60_000
|
||||
})
|
||||
// gap and no terminal title to read an idle edge from. Only the repo's wait-for-setup policy
|
||||
// still holds it back, and that gate has to be waited on explicitly here.
|
||||
const wait = structuredSession
|
||||
? await awaitStructuredWorkerSetupGate({
|
||||
runtime,
|
||||
setup: setupReceipt,
|
||||
effects,
|
||||
timeoutMs: params.timeoutMs ?? 60_000
|
||||
})
|
||||
: await runtime.waitForTerminal(terminalHandle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: params.timeoutMs ?? 60_000
|
||||
})
|
||||
if (wait) {
|
||||
persistWorkerSetupWaitOutcome({ ...setupStage, wait })
|
||||
if (!wait.satisfied) {
|
||||
if (setupReceipt.state === 'failed') {
|
||||
@@ -227,7 +187,9 @@ export async function startLocalWorker(args: {
|
||||
throw new Error(
|
||||
wait.blockedReason
|
||||
? `Agent startup blocked: ${wait.blockedReason}`
|
||||
: `Agent did not become ready (${wait.status}).`
|
||||
: structuredSession
|
||||
? `Setup did not finish before the structured worker started (${wait.status}).`
|
||||
: `Agent did not become ready (${wait.status}).`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -284,12 +246,12 @@ export async function startLocalWorker(args: {
|
||||
effects,
|
||||
...(promptDelivery ? { prompt: promptDelivery } : {}),
|
||||
residualResources: [],
|
||||
...(terminalRevealWarning ? { warning: terminalRevealWarning } : {})
|
||||
...(placed.warning ? { warning: placed.warning } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
await tearDownFailedWorkerStart({
|
||||
runtime,
|
||||
structuredSession,
|
||||
structuredSession: placed?.structuredSession ?? null,
|
||||
dispatchId: started.dispatch.id
|
||||
})
|
||||
return failWorkerStartWithReceipt({
|
||||
@@ -299,7 +261,7 @@ export async function startLocalWorker(args: {
|
||||
dispatchId: started.dispatch.id,
|
||||
failedStage,
|
||||
error,
|
||||
setup: setupReceipt,
|
||||
setup: placed?.setupReceipt ?? EXISTING_WORKTREE_SETUP,
|
||||
launch: launch.receipt,
|
||||
mode
|
||||
})
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* `--model`/`--effort` used to downgrade a structured-preferring worker to a PTY terminal because
|
||||
* "launch preferences apply only to a terminal agent". They no longer do: the same two ids a saved
|
||||
* selection seeds a chat with are seeded into the worker's own session here.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const createStructuredWorkerSession = vi.fn(async (_args: Record<string, unknown>) => ({
|
||||
identity: { handle: 'structworker_1', sessionId: 'sess_1' },
|
||||
host: {}
|
||||
}))
|
||||
|
||||
vi.mock('../../orchestration-structured-worker-session', () => ({
|
||||
createStructuredWorkerSession: (args: never) => createStructuredWorkerSession(args)
|
||||
}))
|
||||
|
||||
const { createStructuredWorkerSessionForWorktree } = await import('./worker-topology')
|
||||
const { prepareStructuredAgentSessionCreateForWorktree } =
|
||||
await import('../../structured-agent-session-create')
|
||||
|
||||
async function createWith(launchPreferences?: Record<string, string>) {
|
||||
createStructuredWorkerSession.mockClear()
|
||||
await createStructuredWorkerSessionForWorktree({
|
||||
runtime: {} as never,
|
||||
worktreeId: 'repo::wt',
|
||||
agent: 'codex',
|
||||
dispatchId: 'ctx_1',
|
||||
...(launchPreferences ? { launchPreferences } : {}),
|
||||
effects: []
|
||||
})
|
||||
return createStructuredWorkerSession.mock.calls[0]?.[0] ?? {}
|
||||
}
|
||||
|
||||
describe('a structured worker seeds the dispatch launch preferences', () => {
|
||||
it('carries --model and --effort into the session create', async () => {
|
||||
expect(await createWith({ model: 'gpt-5.6-sol', effort: 'high' })).toMatchObject({
|
||||
options: { model: 'gpt-5.6-sol', effort: 'high' }
|
||||
})
|
||||
})
|
||||
|
||||
it('carries only the model when no --effort was asked for', async () => {
|
||||
expect((await createWith({ model: 'gpt-5.6-sol' })).options).toEqual({ model: 'gpt-5.6-sol' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no preferences at all', undefined],
|
||||
['an option set that narrows to nothing', { model: ' ' }]
|
||||
])('omits options entirely for %s, never sending {}', async (_name, preferences) => {
|
||||
// `{}` fails the durable record's bounded-string guard, and `agent_session_options_invalid` is
|
||||
// not a wire refusal code — the throw strands the launch with no fallback. Omitted, the host
|
||||
// seeds the user's own saved selection instead, which is what a chat would get.
|
||||
expect(await createWith(preferences)).not.toHaveProperty('options')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the create the seed options land in', () => {
|
||||
const settingsResolved = {
|
||||
location: { executionHostId: 'local', wslDistro: null, workspaceId: 'repo::wt' },
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
|
||||
runtimeKind: 'native',
|
||||
options: { model: 'saved-model', effort: 'low' }
|
||||
}
|
||||
|
||||
async function prepare(options?: Record<string, string>) {
|
||||
const prepared = await prepareStructuredAgentSessionCreateForWorktree({
|
||||
runtime: {
|
||||
resolveStructuredAgentSessionCreateIntent: async () => settingsResolved
|
||||
} as never,
|
||||
ensureHost: async () => ({}) as never,
|
||||
envelope: {
|
||||
sessionId: 'sess_1',
|
||||
clientOperationId: 'op_1',
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
},
|
||||
worktree: 'id:repo::wt',
|
||||
agent: 'codex',
|
||||
caller: { callerKey: 'orchestration:dispatch:ctx_1' },
|
||||
...(options ? { options } : {})
|
||||
})
|
||||
return prepared.attachParams
|
||||
}
|
||||
|
||||
it("replaces the saved selection the host resolved with the dispatch's own", async () => {
|
||||
expect((await prepare({ model: 'gpt-5.6-sol', effort: 'high' })).options).toEqual({
|
||||
model: 'gpt-5.6-sol',
|
||||
effort: 'high'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the saved selection when the dispatch named none', async () => {
|
||||
expect((await prepare()).options).toEqual({ model: 'saved-model', effort: 'low' })
|
||||
})
|
||||
|
||||
it('does not let the seed options move the attach fingerprint', async () => {
|
||||
// Options are the session's initial state, not its identity: a retry re-resolves them and must
|
||||
// replay rather than conflict.
|
||||
const [seeded, unseeded] = await Promise.all([prepare({ model: 'gpt-5.6-sol' }), prepare()])
|
||||
expect(seeded.envelope.payloadFingerprint).toBe(unseeded.envelope.payloadFingerprint)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Where a worker's agent comes from: a worktree this start creates, a structured session, a new
|
||||
* terminal in an existing worktree, or the terminal the caller passed.
|
||||
*
|
||||
* A structured worker never takes the agent-first worktree path. `createWorkerWorktree` used to be
|
||||
* the only way a new worktree was made, and it creates one WITH its startup agent terminal, which
|
||||
* left the structured branch below it unreachable for every `--worktree new-child` dispatch. Here
|
||||
* the worktree is created without a startup agent and the structured session is created for it
|
||||
* afterwards — the same order the renderer's own structured worktree create uses.
|
||||
*
|
||||
* That reorder is also why the host verdict lands here: `agentSession.createSupport` can only be
|
||||
* asked about a workspace that exists, so for a created worktree it cannot run before the start.
|
||||
* A refusal becomes a terminal agent in the worktree that was just created, never a failed start.
|
||||
*/
|
||||
|
||||
import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority'
|
||||
import type { TuiAgent } from '../../../../../../shared/tui-agent'
|
||||
import type { OrcaRuntimeService } from '../../../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../../../orchestration/db'
|
||||
import {
|
||||
resolveWorkerStartModeOnHost,
|
||||
type WorkerStartModeReceipt
|
||||
} from '../../orchestration-worker-start-mode'
|
||||
import type { WorkerStartInput } from './worker-start-schema'
|
||||
import {
|
||||
createExistingWorktreeWorkerTerminal,
|
||||
createStructuredWorkerSessionForWorktree,
|
||||
type WorkerEffect,
|
||||
type WorkerSetupReceipt
|
||||
} from './worker-topology'
|
||||
import { createWorkerWorktree } from './worker-worktree-creation'
|
||||
|
||||
/** Only what the placement itself reads. The runtime's own worktree accessors are untyped, so
|
||||
* naming the two fields keeps `any` out of this module's unions. */
|
||||
type PlacedWorktree = { id: string; repoId: string }
|
||||
type WorkerStructuredSession = Awaited<ReturnType<typeof createStructuredWorkerSessionForWorktree>>
|
||||
|
||||
export type WorkerAgentPlacement = {
|
||||
/** The mode that actually ran; a created worktree can settle it later than the caller could. */
|
||||
mode: WorkerStartModeReceipt
|
||||
worktree: PlacedWorktree
|
||||
terminalHandle: string
|
||||
structuredSession: WorkerStructuredSession | null
|
||||
setupReceipt: WorkerSetupReceipt
|
||||
warning?: string
|
||||
}
|
||||
|
||||
type WorkerAgentPlacementArgs = {
|
||||
runtime: OrcaRuntimeService
|
||||
db: OrchestrationDb
|
||||
dispatchId: string
|
||||
taskId: string
|
||||
params: WorkerStartInput
|
||||
requestedWorktree: string
|
||||
/** The coordinator's worktree, present only when this start creates one. */
|
||||
creationWorktree: PlacedWorktree | undefined
|
||||
/** The already-resolved placement, present only when this start does not create one. */
|
||||
resolvedWorktree: PlacedWorktree | undefined
|
||||
mode: WorkerStartModeReceipt
|
||||
agent: TuiAgent | undefined
|
||||
launchPreferences: AgentLaunchPreferences | undefined
|
||||
effects: WorkerEffect[]
|
||||
/** Attributes a throw to the step that was running, the way the caller's own stages do. */
|
||||
onStage: (stage: string) => void
|
||||
}
|
||||
|
||||
/** The setup receipt for a placement that creates no worktree, and the one a start reports if it
|
||||
* fails before a placement exists. */
|
||||
export const EXISTING_WORKTREE_SETUP: WorkerSetupReceipt = {
|
||||
requested: 'not_applicable',
|
||||
effective: 'not_applicable',
|
||||
source: 'existing_worktree',
|
||||
hookFound: false,
|
||||
startupPolicy: 'start-immediately',
|
||||
state: 'not_applicable'
|
||||
}
|
||||
|
||||
export async function placeWorkerAgent(
|
||||
args: WorkerAgentPlacementArgs
|
||||
): Promise<WorkerAgentPlacement> {
|
||||
if (args.creationWorktree) {
|
||||
return placeInCreatedWorktree(args, args.creationWorktree)
|
||||
}
|
||||
const worktree = requireWorktree(args.resolvedWorktree)
|
||||
if (args.params.terminal) {
|
||||
args.effects.push({
|
||||
kind: 'terminal',
|
||||
role: 'agent',
|
||||
action: 'reused',
|
||||
id: args.params.terminal
|
||||
})
|
||||
return {
|
||||
mode: args.mode,
|
||||
worktree,
|
||||
terminalHandle: args.params.terminal,
|
||||
structuredSession: null,
|
||||
setupReceipt: EXISTING_WORKTREE_SETUP
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: args.mode,
|
||||
worktree,
|
||||
...(await createWorkerAgentSurface(args, worktree.id, args.mode)),
|
||||
setupReceipt: EXISTING_WORKTREE_SETUP
|
||||
}
|
||||
}
|
||||
|
||||
async function placeInCreatedWorktree(
|
||||
args: WorkerAgentPlacementArgs,
|
||||
coordinatorWorktree: PlacedWorktree
|
||||
): Promise<WorkerAgentPlacement> {
|
||||
args.onStage('worktree_create')
|
||||
const created = await createWorkerWorktree({
|
||||
runtime: args.runtime,
|
||||
db: args.db,
|
||||
dispatchId: args.dispatchId,
|
||||
requestedWorktree: args.requestedWorktree,
|
||||
coordinatorWorktree,
|
||||
params: args.params,
|
||||
agent: args.agent as TuiAgent,
|
||||
withAgentTerminal: args.mode.mode !== 'structured',
|
||||
...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}),
|
||||
effects: args.effects
|
||||
})
|
||||
const worktree = requireWorktree(created.worktree)
|
||||
if (args.mode.mode !== 'structured') {
|
||||
return {
|
||||
mode: args.mode,
|
||||
worktree,
|
||||
terminalHandle: requireTerminal(created.terminalHandle),
|
||||
structuredSession: null,
|
||||
setupReceipt: created.setupReceipt
|
||||
}
|
||||
}
|
||||
args.onStage('terminal_create')
|
||||
const mode = await resolveWorkerStartModeOnHost(args.runtime, args.mode, worktree.id, args.agent)
|
||||
return {
|
||||
mode,
|
||||
worktree,
|
||||
...(await createWorkerAgentSurface(args, worktree.id, mode)),
|
||||
setupReceipt: created.setupReceipt
|
||||
}
|
||||
}
|
||||
|
||||
/** The agent surface for a worktree that exists; the settled mode picks which one. */
|
||||
async function createWorkerAgentSurface(
|
||||
args: WorkerAgentPlacementArgs,
|
||||
worktreeId: string,
|
||||
mode: WorkerStartModeReceipt
|
||||
): Promise<Pick<WorkerAgentPlacement, 'terminalHandle' | 'structuredSession' | 'warning'>> {
|
||||
args.db.recordWorkerStage({
|
||||
dispatchId: args.dispatchId,
|
||||
stage: 'terminal_creating',
|
||||
worktreeId,
|
||||
effects: args.effects
|
||||
})
|
||||
if (mode.mode === 'structured') {
|
||||
const structuredSession = await createStructuredWorkerSessionForWorktree({
|
||||
runtime: args.runtime,
|
||||
worktreeId,
|
||||
agent: args.agent as TuiAgent,
|
||||
dispatchId: args.dispatchId,
|
||||
...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}),
|
||||
effects: args.effects
|
||||
})
|
||||
return { terminalHandle: structuredSession.identity.handle, structuredSession }
|
||||
}
|
||||
const terminal = await createExistingWorktreeWorkerTerminal({
|
||||
runtime: args.runtime,
|
||||
worktreeId,
|
||||
agent: args.agent as TuiAgent,
|
||||
...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}),
|
||||
taskId: args.taskId,
|
||||
effects: args.effects
|
||||
})
|
||||
return {
|
||||
terminalHandle: terminal.handle,
|
||||
structuredSession: null,
|
||||
...(terminal.warning ? { warning: terminal.warning } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function requireWorktree(worktree: PlacedWorktree | undefined): PlacedWorktree {
|
||||
if (!worktree) {
|
||||
throw new Error('Worker topology did not resolve a worktree.')
|
||||
}
|
||||
return worktree
|
||||
}
|
||||
|
||||
function requireTerminal(terminalHandle: string | undefined): string {
|
||||
if (!terminalHandle) {
|
||||
throw new Error('Worker topology did not resolve an agent terminal.')
|
||||
}
|
||||
return terminalHandle
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The `wait-for-setup` gate for a structured worker on a worktree this start created.
|
||||
*
|
||||
* A PTY worker gets the gate for free: agent-first creation sequences the agent's startup command
|
||||
* behind the setup runner, so `tui-idle` cannot arrive until setup exits, and the worker start
|
||||
* reads the gate's outcome off that wait. A structured session has no startup command to sequence,
|
||||
* so without this the worker would take its dispatch preamble while `install` was still running,
|
||||
* and the repo's wait-for-setup policy would record no evidence at all.
|
||||
*
|
||||
* Bounded by the start's own timeout, and deliberately forgiving: a wait that cannot be taken —
|
||||
* an in-process hook with no setup terminal, or a setup pty already gone — yields no verdict
|
||||
* rather than a failure, because a worker start must not fail on missing evidence.
|
||||
*
|
||||
* "No verdict" is never silent, though. A wait that could not be TAKEN is an absent precondition
|
||||
* and needs no receipt; a wait that was taken and then threw is a LOST observation, and that one
|
||||
* is recorded as a `wait_unevaluated` effect so the start's receipt still says the gate went
|
||||
* unevaluated and why.
|
||||
*/
|
||||
|
||||
import type { OrcaRuntimeService } from '../../../../orca-runtime'
|
||||
import type { WorkerEffect, WorkerSetupReceipt } from './worker-topology'
|
||||
|
||||
export type StructuredWorkerSetupGate = {
|
||||
satisfied: boolean
|
||||
status: string
|
||||
/** A setup gate has no agent prompt to block on; declared so the wait union stays property-typed. */
|
||||
blockedReason?: undefined
|
||||
}
|
||||
|
||||
export async function awaitStructuredWorkerSetupGate(args: {
|
||||
runtime: Pick<OrcaRuntimeService, 'waitForSetupTerminalCompletion'>
|
||||
setup: WorkerSetupReceipt
|
||||
effects: WorkerEffect[]
|
||||
timeoutMs: number
|
||||
}): Promise<StructuredWorkerSetupGate | null> {
|
||||
if (args.setup.startupPolicy !== 'wait-for-setup' || args.setup.state !== 'running') {
|
||||
return null
|
||||
}
|
||||
const setupTerminal = args.effects.find((effect) => effect.kind === 'setup')?.terminalId
|
||||
if (!setupTerminal) {
|
||||
return null
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
args.runtime.waitForSetupTerminalCompletion(setupTerminal).then((completion) => ({
|
||||
satisfied: completion.exitCode === 0,
|
||||
status: 'exited'
|
||||
})),
|
||||
new Promise<StructuredWorkerSetupGate>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ satisfied: false, status: 'timeout' }), args.timeoutMs)
|
||||
})
|
||||
])
|
||||
} catch (error) {
|
||||
// A wait that was TAKEN and then threw is not the same as one that could not be taken. Both
|
||||
// yield no verdict — a start must not fail on missing evidence — but only this one is a lost
|
||||
// observation, so it is recorded rather than silently flattened into "not applicable".
|
||||
args.effects.push({
|
||||
kind: 'setup',
|
||||
action: 'wait_unevaluated',
|
||||
state: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority'
|
||||
import { narrowStructuredLaunchSeedOptions } from '../../../../../../shared/native-chat-session-option-defaults'
|
||||
import type { TuiAgent } from '../../../../../../shared/tui-agent'
|
||||
import type { OrcaRuntimeService } from '../../../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../../../orchestration/db'
|
||||
@@ -96,6 +97,8 @@ export async function createStructuredWorkerSessionForWorktree(args: {
|
||||
worktreeId: string
|
||||
agent: TuiAgent
|
||||
dispatchId: string
|
||||
/** `--model`/`--effort`; the session seeds them exactly as a saved selection is seeded. */
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
effects: WorkerEffect[]
|
||||
}): Promise<Awaited<ReturnType<typeof createStructuredWorkerSession>>> {
|
||||
if (args.agent !== 'claude' && args.agent !== 'codex') {
|
||||
@@ -104,11 +107,13 @@ export async function createStructuredWorkerSessionForWorktree(args: {
|
||||
`Structured workers support claude and codex; ${args.agent} has no structured session.`
|
||||
)
|
||||
}
|
||||
const options = narrowStructuredLaunchSeedOptions(args.launchPreferences)
|
||||
const created = await createStructuredWorkerSession({
|
||||
runtime: args.runtime,
|
||||
worktreeId: args.worktreeId,
|
||||
agent: args.agent,
|
||||
dispatchId: args.dispatchId,
|
||||
...(options ? { options } : {}),
|
||||
onJournalActivity: (sessionId) =>
|
||||
args.runtime.notifyStructuredSessionJournalActivity?.(sessionId)
|
||||
})
|
||||
@@ -143,118 +148,6 @@ export function applyWaitForSetupOutcome(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkerWorktree(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
db: OrchestrationDb
|
||||
dispatchId: string
|
||||
requestedWorktree: string
|
||||
coordinatorWorktree: Awaited<ReturnType<OrcaRuntimeService['showManagedWorktree']>>
|
||||
params: {
|
||||
repo?: string
|
||||
name?: string
|
||||
baseBranch?: string
|
||||
displayName?: string
|
||||
comment?: string
|
||||
setup?: 'run' | 'skip' | 'inherit'
|
||||
from: string
|
||||
}
|
||||
agent: TuiAgent
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
effects: WorkerEffect[]
|
||||
}): Promise<{
|
||||
worktree: Awaited<ReturnType<OrcaRuntimeService['showManagedWorktree']>>
|
||||
terminalHandle: string
|
||||
setupReceipt: WorkerSetupReceipt
|
||||
}> {
|
||||
const { runtime, db, dispatchId, requestedWorktree, coordinatorWorktree, params, effects } = args
|
||||
const setupDecision = params.setup ?? 'run'
|
||||
db.recordWorkerStage({ dispatchId, stage: 'worktree_creating', effects })
|
||||
const created = await runtime.createManagedWorktree({
|
||||
repoSelector: params.repo ?? coordinatorWorktree.repoId,
|
||||
name: params.name as string,
|
||||
baseBranch: params.baseBranch,
|
||||
displayName: params.displayName,
|
||||
...(params.displayName !== undefined ? { displayNameKind: 'user' as const } : {}),
|
||||
comment: params.comment,
|
||||
// setupDecision runs setup without the legacy runHooks activation side effect.
|
||||
runHooks: false,
|
||||
setupDecision,
|
||||
awaitTerminalProvisioning: true,
|
||||
observeSetupCompletion: true,
|
||||
createdWithAgent: args.agent,
|
||||
startupAgent: args.agent,
|
||||
...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}),
|
||||
activate: false,
|
||||
lineage: {
|
||||
parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined,
|
||||
noParent: requestedWorktree === 'new-top-level',
|
||||
callerTerminalHandle: params.from
|
||||
}
|
||||
})
|
||||
const terminalHandle = created.startupTerminal?.handle
|
||||
effects.push({
|
||||
kind: 'worktree',
|
||||
action: requestedWorktree === 'new-child' ? 'created_child' : 'created_top_level',
|
||||
id: created.worktree.id
|
||||
})
|
||||
db.recordWorkerStage({
|
||||
dispatchId,
|
||||
stage: 'worktree_created',
|
||||
worktreeId: created.worktree.id,
|
||||
effects,
|
||||
residualResources: effects
|
||||
})
|
||||
const setupReceipt = {
|
||||
requested: setupDecision,
|
||||
effective: setupDecision,
|
||||
source: params.setup ? 'explicit_request' : 'orchestration_default',
|
||||
hookFound: created.setupReceipt?.hookFound ?? false,
|
||||
startupPolicy: created.setupReceipt?.startupPolicy ?? 'start-immediately',
|
||||
state: created.setupReceipt?.state ?? 'not_configured'
|
||||
}
|
||||
if (!terminalHandle) {
|
||||
throw new Error(created.warning ?? 'Agent-first worktree creation returned no terminal.')
|
||||
}
|
||||
const listed = await runtime.listTerminals(`id:${created.worktree.id}`, undefined, {
|
||||
includeVisualLayouts: false
|
||||
})
|
||||
const setupTerminalHandle = created.setupReceipt?.terminalHandle
|
||||
for (const terminal of listed.terminals) {
|
||||
effects.push({
|
||||
kind: 'terminal',
|
||||
role:
|
||||
terminal.handle === terminalHandle
|
||||
? 'agent'
|
||||
: terminal.handle === setupTerminalHandle
|
||||
? 'setup'
|
||||
: 'configured_tab',
|
||||
action: terminal.handle === terminalHandle ? 'reused_agent_terminal' : 'created',
|
||||
id: terminal.handle,
|
||||
tabId: terminal.tabId,
|
||||
leafId: terminal.leafId
|
||||
})
|
||||
}
|
||||
const setupTerminal = effects.find(
|
||||
(effect) => effect.kind === 'terminal' && effect.role === 'setup'
|
||||
)
|
||||
effects.push({
|
||||
kind: 'setup',
|
||||
action: setupDecision,
|
||||
requested: setupReceipt.requested,
|
||||
effective: setupReceipt.effective,
|
||||
source: setupReceipt.source,
|
||||
hookFound: setupReceipt.hookFound,
|
||||
startupPolicy: setupReceipt.startupPolicy,
|
||||
state: setupReceipt.state,
|
||||
terminalId: setupTerminalHandle ?? setupTerminal?.id
|
||||
})
|
||||
return {
|
||||
worktree: created.worktree as Awaited<ReturnType<OrcaRuntimeService['showManagedWorktree']>>,
|
||||
terminalHandle,
|
||||
setupReceipt
|
||||
}
|
||||
}
|
||||
|
||||
export function monitorWorkerSetup(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
db: OrchestrationDb
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Creating the worktree a `--worktree new-child` / `new-top-level` dispatch asks for.
|
||||
*
|
||||
* `withAgentTerminal` is the whole difference between the two worker modes: a PTY worker's
|
||||
* worktree is created agent-first, so the startup terminal IS the worker, while a structured
|
||||
* worker's worktree is created with no agent at all and its session is created for the worktree
|
||||
* afterwards. Setup, default tabs and lineage are identical either way.
|
||||
*/
|
||||
|
||||
import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority'
|
||||
import type { TuiAgent } from '../../../../../../shared/tui-agent'
|
||||
import type { OrcaRuntimeService } from '../../../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../../../orchestration/db'
|
||||
import type { WorkerEffect, WorkerSetupReceipt } from './worker-topology'
|
||||
|
||||
export async function createWorkerWorktree(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
db: OrchestrationDb
|
||||
dispatchId: string
|
||||
requestedWorktree: string
|
||||
coordinatorWorktree: Awaited<ReturnType<OrcaRuntimeService['showManagedWorktree']>>
|
||||
params: {
|
||||
repo?: string
|
||||
name?: string
|
||||
baseBranch?: string
|
||||
displayName?: string
|
||||
comment?: string
|
||||
setup?: 'run' | 'skip' | 'inherit'
|
||||
from: string
|
||||
}
|
||||
agent: TuiAgent
|
||||
/** False for a structured worker: the session is created for the worktree afterwards, so the
|
||||
* worktree must not be created agent-first. Setup and default tabs still run; what is skipped
|
||||
* is the startup agent terminal and, with it, the TUI trust write — which a structured session
|
||||
* does not need, since the trust preset exists so a PTY agent's menu does not eat the first
|
||||
* bracketed paste. The renderer's own structured worktree create skips both the same way. */
|
||||
withAgentTerminal: boolean
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
effects: WorkerEffect[]
|
||||
}): Promise<{
|
||||
worktree: Awaited<ReturnType<OrcaRuntimeService['showManagedWorktree']>>
|
||||
terminalHandle: string | undefined
|
||||
setupReceipt: WorkerSetupReceipt
|
||||
}> {
|
||||
const { runtime, db, dispatchId, requestedWorktree, coordinatorWorktree, params, effects } = args
|
||||
const setupDecision = params.setup ?? 'run'
|
||||
db.recordWorkerStage({ dispatchId, stage: 'worktree_creating', effects })
|
||||
const created = await runtime.createManagedWorktree({
|
||||
repoSelector: params.repo ?? coordinatorWorktree.repoId,
|
||||
name: params.name as string,
|
||||
baseBranch: params.baseBranch,
|
||||
displayName: params.displayName,
|
||||
...(params.displayName !== undefined ? { displayNameKind: 'user' as const } : {}),
|
||||
comment: params.comment,
|
||||
// setupDecision runs setup without the legacy runHooks activation side effect.
|
||||
runHooks: false,
|
||||
setupDecision,
|
||||
awaitTerminalProvisioning: true,
|
||||
observeSetupCompletion: true,
|
||||
createdWithAgent: args.agent,
|
||||
...(args.withAgentTerminal
|
||||
? {
|
||||
startupAgent: args.agent,
|
||||
...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {})
|
||||
}
|
||||
: {}),
|
||||
activate: false,
|
||||
lineage: {
|
||||
parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined,
|
||||
noParent: requestedWorktree === 'new-top-level',
|
||||
callerTerminalHandle: params.from
|
||||
}
|
||||
})
|
||||
const terminalHandle = created.startupTerminal?.handle
|
||||
effects.push({
|
||||
kind: 'worktree',
|
||||
action: requestedWorktree === 'new-child' ? 'created_child' : 'created_top_level',
|
||||
id: created.worktree.id
|
||||
})
|
||||
db.recordWorkerStage({
|
||||
dispatchId,
|
||||
stage: 'worktree_created',
|
||||
worktreeId: created.worktree.id,
|
||||
effects,
|
||||
residualResources: effects
|
||||
})
|
||||
const setupReceipt = {
|
||||
requested: setupDecision,
|
||||
effective: setupDecision,
|
||||
source: params.setup ? 'explicit_request' : 'orchestration_default',
|
||||
hookFound: created.setupReceipt?.hookFound ?? false,
|
||||
startupPolicy: created.setupReceipt?.startupPolicy ?? 'start-immediately',
|
||||
state: created.setupReceipt?.state ?? 'not_configured'
|
||||
}
|
||||
if (args.withAgentTerminal && !terminalHandle) {
|
||||
throw new Error(created.warning ?? 'Agent-first worktree creation returned no terminal.')
|
||||
}
|
||||
const listed = await runtime.listTerminals(`id:${created.worktree.id}`, undefined, {
|
||||
includeVisualLayouts: false
|
||||
})
|
||||
const setupTerminalHandle = created.setupReceipt?.terminalHandle
|
||||
for (const terminal of listed.terminals) {
|
||||
effects.push({
|
||||
kind: 'terminal',
|
||||
role:
|
||||
terminal.handle === terminalHandle
|
||||
? 'agent'
|
||||
: terminal.handle === setupTerminalHandle
|
||||
? 'setup'
|
||||
: 'configured_tab',
|
||||
action: terminal.handle === terminalHandle ? 'reused_agent_terminal' : 'created',
|
||||
id: terminal.handle,
|
||||
tabId: terminal.tabId,
|
||||
leafId: terminal.leafId
|
||||
})
|
||||
}
|
||||
const setupTerminal = effects.find(
|
||||
(effect) => effect.kind === 'terminal' && effect.role === 'setup'
|
||||
)
|
||||
effects.push({
|
||||
kind: 'setup',
|
||||
action: setupDecision,
|
||||
requested: setupReceipt.requested,
|
||||
effective: setupReceipt.effective,
|
||||
source: setupReceipt.source,
|
||||
hookFound: setupReceipt.hookFound,
|
||||
startupPolicy: setupReceipt.startupPolicy,
|
||||
state: setupReceipt.state,
|
||||
terminalId: setupTerminalHandle ?? setupTerminal?.id
|
||||
})
|
||||
return {
|
||||
worktree: created.worktree as Awaited<ReturnType<OrcaRuntimeService['showManagedWorktree']>>,
|
||||
terminalHandle,
|
||||
setupReceipt
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,10 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: {
|
||||
caller: StructuredAgentSessionCaller
|
||||
forkFrom?: AgentSessionForkSource
|
||||
resumeFrom?: StructuredAgentSessionResumeSource
|
||||
/** Replaces the seed options the host resolves from settings. Orchestration passes the
|
||||
* `--model`/`--effort` the dispatch asked for; a chat the user opened passes nothing and keeps
|
||||
* the saved selection. Narrowed by the caller, so `{}` never reaches the reservation. */
|
||||
options?: Readonly<Record<string, string>>
|
||||
}): Promise<PreparedStructuredAgentSessionCreate> {
|
||||
if (args.forkFrom && args.resumeFrom) {
|
||||
throw new Error('agent_session_operation_invalid')
|
||||
@@ -77,6 +81,10 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: {
|
||||
...(args.forkFrom ? { forkFrom: args.forkFrom } : {}),
|
||||
attachParams: {
|
||||
...resolvedAttach,
|
||||
// After the fingerprint, deliberately: `attachFingerprintFields` excludes options because
|
||||
// they are the session's initial state, not its identity, so a retry that re-resolves them
|
||||
// must replay rather than conflict.
|
||||
...(args.options ? { options: args.options } : {}),
|
||||
provider: resolved.provider as 'claude' | 'codex',
|
||||
agent: resolved.agent as 'claude' | 'codex',
|
||||
envelope: { ...args.envelope, payloadFingerprint: hostFingerprint }
|
||||
@@ -130,6 +138,7 @@ export async function createStructuredAgentSessionForWorktree(args: {
|
||||
worktree: string
|
||||
agent: 'claude' | 'codex'
|
||||
activate: boolean
|
||||
options?: Readonly<Record<string, string>>
|
||||
}): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
|
||||
const prepared: PreparedStructuredAgentSessionCreate | StructuredCreateRefused =
|
||||
await resolveUncommittedStructuredCreate(() =>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { RuntimeTerminalOrphanAdoptionRequest } from '../../shared/runtime-types'
|
||||
import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation'
|
||||
|
||||
function fixture(count: number): RuntimeTerminalOrphanAdoptionRequest {
|
||||
const ids = Array.from({ length: count }, (_, index) => `tab-${index}`)
|
||||
return {
|
||||
worktree: 'folder-workspace',
|
||||
expectedTopologyRevision: 1,
|
||||
claims: ids.map((tabId) => ({
|
||||
tabId,
|
||||
leafId: tabId,
|
||||
terminal: tabId,
|
||||
ptyId: tabId,
|
||||
incarnationId: tabId
|
||||
})) as RuntimeTerminalOrphanAdoptionRequest['claims'],
|
||||
topology: {
|
||||
tabs: ids.map((tabId) => ({
|
||||
tabId,
|
||||
root: { type: 'leaf', leafId: tabId },
|
||||
activeLeafId: tabId,
|
||||
expandedLeafId: null
|
||||
})),
|
||||
groups: [{ id: 'g', activeTabId: ids[0], tabOrder: ids, recentTabIds: ids.toReversed() }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('validates large restored MRU lists with linear tab-order reads', () => {
|
||||
const request = fixture(1000)
|
||||
let reads = 0
|
||||
const group = request.topology!.groups[0]
|
||||
group.tabOrder = new Proxy(group.tabOrder, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
||||
reads += 1
|
||||
}
|
||||
return Reflect.get(target, key, receiver)
|
||||
}
|
||||
})
|
||||
expect(
|
||||
validateRuntimeTerminalOrphanTopology(
|
||||
request,
|
||||
request.claims.map((claim) => ({ claim }))
|
||||
).topologyTabsById.size
|
||||
).toBe(1000)
|
||||
expect(reads).toBeLessThanOrEqual(3000)
|
||||
})
|
||||
|
||||
it.each(['duplicate', 'foreign-recent', 'foreign-active'])(
|
||||
'rejects %s group membership',
|
||||
(kind) => {
|
||||
const request = fixture(2)
|
||||
const group = request.topology!.groups[0]
|
||||
if (kind === 'duplicate') {
|
||||
group.tabOrder.push(group.tabOrder[0])
|
||||
}
|
||||
if (kind === 'foreign-recent') {
|
||||
group.recentTabIds = ['foreign']
|
||||
}
|
||||
if (kind === 'foreign-active') {
|
||||
group.activeTabId = 'foreign'
|
||||
}
|
||||
expect(() =>
|
||||
validateRuntimeTerminalOrphanTopology(
|
||||
request,
|
||||
request.claims.map((claim) => ({ claim }))
|
||||
)
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
}
|
||||
)
|
||||
|
||||
function validate(request: RuntimeTerminalOrphanAdoptionRequest) {
|
||||
return validateRuntimeTerminalOrphanTopology(
|
||||
request,
|
||||
request.claims.map((claim) => ({ claim }))
|
||||
)
|
||||
}
|
||||
|
||||
type Groups = NonNullable<RuntimeTerminalOrphanAdoptionRequest['topology']>['groups']
|
||||
|
||||
function withGroups(count: number, groups: Groups): RuntimeTerminalOrphanAdoptionRequest {
|
||||
const request = fixture(count)
|
||||
request.topology!.groups = groups
|
||||
return request
|
||||
}
|
||||
|
||||
// Membership is per-group, but the no-tab-in-two-groups rule is global. Replacing that rule with
|
||||
// the per-group set would let one pane be adopted into two groups and cross-wire the session.
|
||||
it('rejects a tab claimed by two different groups', () => {
|
||||
expect(() =>
|
||||
validate(
|
||||
withGroups(2, [
|
||||
{ id: 'g1', activeTabId: 'tab-0', tabOrder: ['tab-0', 'tab-1'], recentTabIds: [] },
|
||||
{ id: 'g2', activeTabId: 'tab-1', tabOrder: ['tab-1'], recentTabIds: [] }
|
||||
])
|
||||
)
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
})
|
||||
|
||||
it('rejects a duplicated group id', () => {
|
||||
expect(() =>
|
||||
validate(
|
||||
withGroups(2, [
|
||||
{ id: 'g', activeTabId: 'tab-0', tabOrder: ['tab-0'], recentTabIds: [] },
|
||||
{ id: 'g', activeTabId: 'tab-1', tabOrder: ['tab-1'], recentTabIds: [] }
|
||||
])
|
||||
)
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
})
|
||||
|
||||
// Cardinality 0: an empty tab order can never hold the active tab, so adoption must fail closed
|
||||
// rather than fall through to an arbitrary pane.
|
||||
it('rejects an empty tab order', () => {
|
||||
expect(() =>
|
||||
validate(withGroups(2, [{ id: 'g', activeTabId: 'tab-0', tabOrder: [], recentTabIds: [] }]))
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
})
|
||||
|
||||
it('rejects a claimed tab that no group lists', () => {
|
||||
expect(() =>
|
||||
validate(
|
||||
withGroups(2, [{ id: 'g', activeTabId: 'tab-0', tabOrder: ['tab-0'], recentTabIds: [] }])
|
||||
)
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1 tab per group', [['tab-0'], ['tab-1']]],
|
||||
['both tabs in one group', [['tab-0', 'tab-1']]]
|
||||
])('accepts an exactly-covering split with %s', (_label, tabOrders) => {
|
||||
const groups: Groups = tabOrders.map((tabOrder, i) => ({
|
||||
id: `g${i}`,
|
||||
activeTabId: tabOrder[0],
|
||||
tabOrder,
|
||||
// Reverse MRU: every entry must still resolve inside its own group.
|
||||
recentTabIds: tabOrder.toReversed()
|
||||
}))
|
||||
expect(validate(withGroups(2, groups)).topologyTabsById.size).toBe(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['omitted', undefined],
|
||||
['empty', [] as string[]]
|
||||
])('accepts %s recentTabIds', (_label, recentTabIds) => {
|
||||
expect(
|
||||
validate(
|
||||
withGroups(2, [{ id: 'g', activeTabId: 'tab-0', tabOrder: ['tab-0', 'tab-1'], recentTabIds }])
|
||||
).topologyTabsById.size
|
||||
).toBe(2)
|
||||
})
|
||||
@@ -54,7 +54,8 @@ export function validateRuntimeTerminalOrphanTopology(
|
||||
const seenGroupIds = new Set<string>()
|
||||
const groupedTabIds = new Set<string>()
|
||||
for (const group of topologyGroups) {
|
||||
if (seenGroupIds.has(group.id) || !group.tabOrder.includes(group.activeTabId)) {
|
||||
const groupTabIds = new Set(group.tabOrder)
|
||||
if (seenGroupIds.has(group.id) || !groupTabIds.has(group.activeTabId)) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
seenGroupIds.add(group.id)
|
||||
@@ -64,7 +65,7 @@ export function validateRuntimeTerminalOrphanTopology(
|
||||
}
|
||||
groupedTabIds.add(tabId)
|
||||
}
|
||||
if (group.recentTabIds?.some((tabId) => !group.tabOrder.includes(tabId))) {
|
||||
if (group.recentTabIds?.some((tabId) => !groupTabIds.has(tabId))) {
|
||||
throw new Error('terminal_orphan_topology_invalid')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionTabId
|
||||
} from '../../shared/structured-agent-session-projection'
|
||||
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
||||
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
|
||||
/**
|
||||
* A structured session has no PTY, so it reaches none of the hook or retained snapshots that every
|
||||
* other row comes from. Before this, `worktree ps` reported a worktree running one as idle while
|
||||
* the sidebar showed it working — the CLI, which is the agent-facing surface, was the blind one.
|
||||
*/
|
||||
const WORKTREE_ID = 'repo-1::/workspace/app'
|
||||
|
||||
function summary(over: Partial<AgentSessionStatusSummary> = {}): AgentSessionStatusSummary {
|
||||
return {
|
||||
sessionId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
workspaceId: WORKTREE_ID,
|
||||
agent: 'claude',
|
||||
status: 'working',
|
||||
latestPrompt: 'ship the thing',
|
||||
updatedAt: 1_757_030_400_000,
|
||||
hostExecutionOwned: true,
|
||||
...over
|
||||
} as AgentSessionStatusSummary
|
||||
}
|
||||
|
||||
function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummary {
|
||||
const row = {
|
||||
worktreeId: WORKTREE_ID,
|
||||
status: 'inactive',
|
||||
hasHostSidebarActivity: false,
|
||||
agents: []
|
||||
} as unknown as RuntimeWorktreePsSummary
|
||||
const summariesById = new Map<string, RuntimeWorktreePsSummary>([[WORKTREE_ID, row]])
|
||||
attachRuntimeWorktreeAgentRows({
|
||||
summaries: summariesById,
|
||||
pathIndex: { byPath: new Map(), byRealPath: new Map() } as never,
|
||||
missingWorktreeIds: new Set(),
|
||||
workingTerminalEvidenceByWorktreeId: new Map(),
|
||||
rowSources: collectRuntimeWorktreeAgentSources({
|
||||
mirroredWorktreeIdByTabId: new Map(),
|
||||
connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() },
|
||||
retainedSnapshots: [],
|
||||
hookSnapshots: [],
|
||||
structuredSummaries: summaries
|
||||
}),
|
||||
orchestrationByPaneKey: null,
|
||||
getSummary: (map, _p, _m, id) => map.get(id) ?? null
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
describe('worktree ps reports structured sessions', () => {
|
||||
it('a busy structured session is not reported idle', () => {
|
||||
const row = attach([summary()])
|
||||
expect(row.agents).toHaveLength(1)
|
||||
expect(row.agents[0]?.state).toBe('working')
|
||||
expect(row.agents[0]?.agentType).toBe('claude')
|
||||
expect(row.agents[0]?.prompt).toBe('ship the thing')
|
||||
})
|
||||
|
||||
// The same projection the sidebar applies, so the two surfaces cannot disagree about one session.
|
||||
it('maps attention to blocked and idle to done', () => {
|
||||
expect(attach([summary({ status: 'attention' })]).agents[0]?.state).toBe('blocked')
|
||||
expect(attach([summary({ status: 'idle' })]).agents[0]?.state).toBe('done')
|
||||
})
|
||||
|
||||
it('does not turn a completed host-held session into permission', () => {
|
||||
const row = attach([summary({ status: 'idle' })])
|
||||
expect(row.status).toBe('inactive')
|
||||
expect(row.hasHostSidebarActivity).toBe(false)
|
||||
})
|
||||
|
||||
it('reports the DERIVED pane key, never an orchestration credential', () => {
|
||||
const row = attach([summary()])
|
||||
const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
|
||||
expect(row.agents[0]?.paneKey).toBe(
|
||||
structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId)
|
||||
)
|
||||
})
|
||||
|
||||
// Null status means no turn has been persisted; the chat itself shows nothing, so neither does this.
|
||||
it('omits a session with no projected status', () => {
|
||||
expect(attach([summary({ status: null })]).agents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The deliberate non-goal. Adding structured rows to `terminal list` was investigated and rejected:
|
||||
* mobile mounts a terminal WebView per row that can never receive a frame, a `connected`-keyed
|
||||
* refresh check goes permanently true and pins shipped clients to a fast cadence with no exit, and
|
||||
* the plugin projection has no field that can carry `writable: false`. Every SAFE consumer of a
|
||||
* terminal summary checks `ptyId`; the breaking ones key off `connected` or mere row presence,
|
||||
* which no added field can qualify. A separate change publishes an honest partial-listing count
|
||||
* there instead. This pins that only `worktree ps` gained the enumerator.
|
||||
*/
|
||||
describe('terminal listing is deliberately left alone', () => {
|
||||
it('only worktree ps consumes the structured status summaries', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
// orca-runtime-subscribe-to-terminal-resize.ts owns listTerminals.
|
||||
const listing = await readFile(
|
||||
new URL('./orca-runtime-subscribe-to-terminal-resize.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
// Guard the guard: an empty read would make every assertion below vacuously true.
|
||||
expect(listing).toContain('async listTerminals(')
|
||||
expect(listing).not.toContain('liveSessionStatusSummaries')
|
||||
expect(listing).not.toContain('structuredSummaries')
|
||||
|
||||
const worktreePs = await readFile(
|
||||
new URL('./orca-runtime-get-worktree-ps.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
expect(worktreePs).toContain('liveSessionStatusSummaries')
|
||||
})
|
||||
})
|
||||
@@ -1,34 +1,10 @@
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
isFreshNonDoneAgentStatus,
|
||||
pickParsedAgentStatusPayload,
|
||||
type AgentStatusIpcPayload,
|
||||
type ParsedAgentStatusPayload
|
||||
} from '../../shared/agent-status-types'
|
||||
import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence'
|
||||
import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-types'
|
||||
import type { RuntimeWorktreeAgentRow, RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
|
||||
import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection'
|
||||
import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
|
||||
import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity'
|
||||
|
||||
export type RuntimeAgentRowSnapshot = {
|
||||
paneKey: string
|
||||
ptyId: string
|
||||
worktreeId?: string
|
||||
tabId?: string
|
||||
connectionId: string | null
|
||||
payload: ParsedAgentStatusPayload
|
||||
stateStartedAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type ConnectedPtyEvidence = {
|
||||
tabIds: ReadonlySet<string>
|
||||
paneKeys: ReadonlySet<string>
|
||||
ptyIds: ReadonlySet<string>
|
||||
}
|
||||
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
|
||||
export type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources'
|
||||
|
||||
type OrchestrationDisplay = {
|
||||
taskTitle?: string | null
|
||||
@@ -36,38 +12,15 @@ type OrchestrationDisplay = {
|
||||
parentPaneKey?: string | null
|
||||
}
|
||||
|
||||
type RuntimeWorktreeAgentSource = {
|
||||
paneKey: string
|
||||
ptyId?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
connectionId: string | null
|
||||
payload: ParsedAgentStatusPayload
|
||||
state: ParsedAgentStatusPayload['state']
|
||||
workingMode?: ParsedAgentStatusPayload['workingMode']
|
||||
agentType: string | null
|
||||
prompt: string
|
||||
lastAssistantMessage: string | null
|
||||
toolName: string | null
|
||||
toolInput: string | null
|
||||
interrupted: boolean
|
||||
stateStartedAt: number
|
||||
updatedAt: number
|
||||
restoredUnconfirmed?: boolean
|
||||
}
|
||||
|
||||
export function attachRuntimeWorktreeAgentRows(args: {
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex
|
||||
missingWorktreeIds: Set<string>
|
||||
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
|
||||
connectedPtyEvidence: ConnectedPtyEvidence
|
||||
rowSources: ReadonlyMap<string, RuntimeWorktreeAgentSource>
|
||||
workingTerminalEvidenceByWorktreeId: ReadonlyMap<
|
||||
string,
|
||||
readonly RuntimeWorkingTerminalEvidence[]
|
||||
>
|
||||
retainedSnapshots: Iterable<RuntimeAgentRowSnapshot>
|
||||
hookSnapshots: readonly AgentStatusIpcPayload[]
|
||||
orchestrationByPaneKey: Record<string, OrchestrationDisplay> | null | undefined
|
||||
getSummary: (
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>,
|
||||
@@ -76,88 +29,11 @@ export function attachRuntimeWorktreeAgentRows(args: {
|
||||
worktreeId: string
|
||||
) => RuntimeWorktreePsSummary | null
|
||||
}): void {
|
||||
const rowSources = new Map<string, RuntimeWorktreeAgentSource>()
|
||||
const { rowSources } = args
|
||||
const now = Date.now()
|
||||
for (const snapshot of args.retainedSnapshots) {
|
||||
const { payload } = snapshot
|
||||
rowSources.set(snapshot.paneKey, {
|
||||
paneKey: snapshot.paneKey,
|
||||
ptyId: snapshot.ptyId,
|
||||
tabId: snapshot.tabId,
|
||||
worktreeId: snapshot.worktreeId,
|
||||
connectionId: snapshot.connectionId,
|
||||
payload,
|
||||
state: payload.state,
|
||||
...(payload.workingMode ? { workingMode: payload.workingMode } : {}),
|
||||
agentType: payload.agentType ?? null,
|
||||
prompt: payload.prompt,
|
||||
lastAssistantMessage: payload.lastAssistantMessage ?? null,
|
||||
toolName: payload.toolName ?? null,
|
||||
toolInput: payload.toolInput ?? null,
|
||||
interrupted: payload.interrupted ?? false,
|
||||
stateStartedAt: snapshot.stateStartedAt,
|
||||
updatedAt: snapshot.updatedAt
|
||||
})
|
||||
}
|
||||
for (const entry of args.hookSnapshots) {
|
||||
if (entry.restoredUnconfirmed === true) {
|
||||
continue
|
||||
}
|
||||
const existing = rowSources.get(entry.paneKey)
|
||||
const hookPayload = pickParsedAgentStatusPayload(entry)
|
||||
if (existing && existing.updatedAt > entry.receivedAt) {
|
||||
if (
|
||||
entry.workingMode === 'monitoring' &&
|
||||
now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS &&
|
||||
terminalStatusPayloadMatchesHook(hookPayload, existing.payload)
|
||||
) {
|
||||
existing.workingMode = 'monitoring'
|
||||
if (existing.payload.workingMode === undefined) {
|
||||
existing.payload = { ...existing.payload, workingMode: 'monitoring' }
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
rowSources.set(entry.paneKey, {
|
||||
paneKey: entry.paneKey,
|
||||
ptyId: existing?.ptyId,
|
||||
tabId: entry.tabId,
|
||||
worktreeId: entry.worktreeId,
|
||||
connectionId: entry.connectionId,
|
||||
payload: hookPayload,
|
||||
state: entry.state,
|
||||
...(entry.workingMode ? { workingMode: entry.workingMode } : {}),
|
||||
agentType: entry.agentType ?? null,
|
||||
prompt: entry.prompt,
|
||||
lastAssistantMessage: entry.lastAssistantMessage ?? null,
|
||||
toolName: entry.toolName ?? null,
|
||||
toolInput: entry.toolInput ?? null,
|
||||
interrupted: entry.interrupted ?? false,
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
updatedAt: entry.receivedAt
|
||||
})
|
||||
}
|
||||
if (rowSources.size === 0) {
|
||||
return
|
||||
}
|
||||
const rowsByWorktree = new Map<string, RuntimeWorktreeAgentRow[]>()
|
||||
for (const source of rowSources.values()) {
|
||||
const tabId =
|
||||
source.tabId ??
|
||||
parsePaneKey(source.paneKey)?.tabId ??
|
||||
parseLegacyNumericPaneKey(source.paneKey)?.tabId
|
||||
const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined
|
||||
if (
|
||||
tabId !== undefined &&
|
||||
mirroredWorktreeId === undefined &&
|
||||
(source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) &&
|
||||
!args.connectedPtyEvidence.tabIds.has(tabId) &&
|
||||
!args.connectedPtyEvidence.paneKeys.has(source.paneKey) &&
|
||||
(source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = mirroredWorktreeId ?? source.worktreeId
|
||||
const { worktreeId } = source
|
||||
if (!worktreeId) {
|
||||
continue
|
||||
}
|
||||
@@ -204,13 +80,15 @@ export function attachRuntimeWorktreeAgentRows(args: {
|
||||
let hasForegroundWorkingAgent = false
|
||||
const monitoringSources: RuntimeWorktreeAgentSource[] = []
|
||||
for (const row of rows) {
|
||||
if (!isFreshNonDoneAgentStatus(row, now)) {
|
||||
const source = rowSources.get(row.paneKey)
|
||||
const hostHeldStructuredSession =
|
||||
source?.authority === 'structured-host' && row.state !== 'done'
|
||||
if (!hostHeldStructuredSession && !isFreshNonDoneAgentStatus(row, now)) {
|
||||
continue
|
||||
}
|
||||
summary.hasHostSidebarActivity = true
|
||||
if (row.state === 'working') {
|
||||
if (row.workingMode === 'monitoring') {
|
||||
const source = rowSources.get(row.paneKey)
|
||||
if (source) {
|
||||
monitoringSources.push(source)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
|
||||
export type RuntimeWorktreeAgentSource = {
|
||||
paneKey: string
|
||||
ptyId?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
connectionId: string | null
|
||||
state: ParsedAgentStatusPayload['state']
|
||||
workingMode?: ParsedAgentStatusPayload['workingMode']
|
||||
agentType: string | null
|
||||
prompt: string
|
||||
lastAssistantMessage: string | null
|
||||
toolName: string | null
|
||||
toolInput: string | null
|
||||
interrupted: boolean
|
||||
stateStartedAt: number
|
||||
updatedAt: number
|
||||
/** Structured host projections remain authoritative after PTY freshness expiry. */
|
||||
authority?: 'structured-host'
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
|
||||
import type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
|
||||
const paneKey = 'worktree:tab:0'
|
||||
const now = Date.now()
|
||||
const retained: RuntimeAgentRowSnapshot = {
|
||||
paneKey,
|
||||
ptyId: 'pty',
|
||||
tabId: 'tab',
|
||||
worktreeId: 'worktree',
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt: 'implement', agentType: 'codex' },
|
||||
stateStartedAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
const base = {
|
||||
retainedSnapshots: [retained],
|
||||
hookSnapshots: [] as AgentStatusIpcPayload[],
|
||||
structuredSummaries: [],
|
||||
mirroredWorktreeIdByTabId: new Map<string, string>(),
|
||||
connectedPtyEvidence: {
|
||||
tabIds: new Set<string>(),
|
||||
paneKeys: new Set<string>(),
|
||||
ptyIds: new Set<string>()
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree agent source admission', () => {
|
||||
it('rejects a disconnected local terminal before row assembly', () => {
|
||||
expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0)
|
||||
const connected = {
|
||||
...base,
|
||||
connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) }
|
||||
}
|
||||
expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.state).toBe('working')
|
||||
})
|
||||
|
||||
it('keeps remote evidence and resolves mirrored workspace ownership', () => {
|
||||
const remote = { ...retained, connectionId: 'ssh-connection' }
|
||||
expect(collectRuntimeWorktreeAgentSources({ ...base, retainedSnapshots: [remote] }).size).toBe(
|
||||
1
|
||||
)
|
||||
const sources = collectRuntimeWorktreeAgentSources({
|
||||
...base,
|
||||
mirroredWorktreeIdByTabId: new Map([['tab', 'remote-worktree']])
|
||||
})
|
||||
expect(sources.get(paneKey)?.worktreeId).toBe('remote-worktree')
|
||||
})
|
||||
|
||||
it('preserves fresh monitoring enrichment on a newer retained report', () => {
|
||||
const hook: AgentStatusIpcPayload = {
|
||||
...retained.payload,
|
||||
paneKey,
|
||||
tabId: 'tab',
|
||||
worktreeId: 'worktree',
|
||||
connectionId: null,
|
||||
stateStartedAt: now - 1,
|
||||
receivedAt: now - 1,
|
||||
workingMode: 'monitoring'
|
||||
}
|
||||
const sources = collectRuntimeWorktreeAgentSources({
|
||||
...base,
|
||||
hookSnapshots: [hook],
|
||||
connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) }
|
||||
})
|
||||
expect(sources.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
||||
import { collectRuntimeWorktreePtyAgentSources } from './runtime-worktree-pty-agent-sources'
|
||||
import { structuredRuntimeWorktreeAgentSources } from './runtime-worktree-structured-agent-rows'
|
||||
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
|
||||
|
||||
/** One admitted roster for row and worktree-status projection. */
|
||||
export function collectRuntimeWorktreeAgentSources(
|
||||
args: Parameters<typeof collectRuntimeWorktreePtyAgentSources>[0] & {
|
||||
structuredSummaries: readonly AgentSessionStatusSummary[]
|
||||
}
|
||||
): ReadonlyMap<string, RuntimeWorktreeAgentSource> {
|
||||
const sources = new Map<string, RuntimeWorktreeAgentSource>()
|
||||
for (const source of collectRuntimeWorktreePtyAgentSources(args)) {
|
||||
sources.set(source.paneKey, source)
|
||||
}
|
||||
for (const source of structuredRuntimeWorktreeAgentSources(args.structuredSummaries)) {
|
||||
if (!sources.has(source.paneKey)) {
|
||||
sources.set(source.paneKey, source)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
pickParsedAgentStatusPayload,
|
||||
type AgentStatusIpcPayload,
|
||||
type ParsedAgentStatusPayload
|
||||
} from '../../shared/agent-status-types'
|
||||
import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
|
||||
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
|
||||
|
||||
export type RuntimeAgentRowSnapshot = {
|
||||
paneKey: string
|
||||
ptyId: string
|
||||
worktreeId?: string
|
||||
tabId?: string
|
||||
connectionId: string | null
|
||||
payload: ParsedAgentStatusPayload
|
||||
stateStartedAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type ConnectedPtyEvidence = {
|
||||
tabIds: ReadonlySet<string>
|
||||
paneKeys: ReadonlySet<string>
|
||||
ptyIds: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/** Reconcile terminal status, then admit rows using their execution-host evidence. */
|
||||
export function collectRuntimeWorktreePtyAgentSources(args: {
|
||||
retainedSnapshots: Iterable<RuntimeAgentRowSnapshot>
|
||||
hookSnapshots: readonly AgentStatusIpcPayload[]
|
||||
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
|
||||
connectedPtyEvidence: ConnectedPtyEvidence
|
||||
}): RuntimeWorktreeAgentSource[] {
|
||||
const rowSources = new Map<
|
||||
string,
|
||||
RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload }
|
||||
>()
|
||||
const now = Date.now()
|
||||
for (const snapshot of args.retainedSnapshots) {
|
||||
const { payload } = snapshot
|
||||
rowSources.set(snapshot.paneKey, {
|
||||
paneKey: snapshot.paneKey,
|
||||
ptyId: snapshot.ptyId,
|
||||
tabId: snapshot.tabId,
|
||||
worktreeId: snapshot.worktreeId,
|
||||
connectionId: snapshot.connectionId,
|
||||
payload,
|
||||
state: payload.state,
|
||||
...(payload.workingMode ? { workingMode: payload.workingMode } : {}),
|
||||
agentType: payload.agentType ?? null,
|
||||
prompt: payload.prompt,
|
||||
lastAssistantMessage: payload.lastAssistantMessage ?? null,
|
||||
toolName: payload.toolName ?? null,
|
||||
toolInput: payload.toolInput ?? null,
|
||||
interrupted: payload.interrupted ?? false,
|
||||
stateStartedAt: snapshot.stateStartedAt,
|
||||
updatedAt: snapshot.updatedAt
|
||||
})
|
||||
}
|
||||
for (const entry of args.hookSnapshots) {
|
||||
if (entry.restoredUnconfirmed === true) {
|
||||
continue
|
||||
}
|
||||
const existing = rowSources.get(entry.paneKey)
|
||||
const hookPayload = pickParsedAgentStatusPayload(entry)
|
||||
if (existing && existing.updatedAt > entry.receivedAt) {
|
||||
if (
|
||||
entry.workingMode === 'monitoring' &&
|
||||
now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS &&
|
||||
terminalStatusPayloadMatchesHook(hookPayload, existing.payload)
|
||||
) {
|
||||
existing.workingMode = 'monitoring'
|
||||
if (existing.payload.workingMode === undefined) {
|
||||
existing.payload = { ...existing.payload, workingMode: 'monitoring' }
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
rowSources.set(entry.paneKey, {
|
||||
paneKey: entry.paneKey,
|
||||
ptyId: existing?.ptyId,
|
||||
tabId: entry.tabId,
|
||||
worktreeId: entry.worktreeId,
|
||||
connectionId: entry.connectionId,
|
||||
payload: hookPayload,
|
||||
state: entry.state,
|
||||
...(entry.workingMode ? { workingMode: entry.workingMode } : {}),
|
||||
agentType: entry.agentType ?? null,
|
||||
prompt: entry.prompt,
|
||||
lastAssistantMessage: entry.lastAssistantMessage ?? null,
|
||||
toolName: entry.toolName ?? null,
|
||||
toolInput: entry.toolInput ?? null,
|
||||
interrupted: entry.interrupted ?? false,
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
updatedAt: entry.receivedAt
|
||||
})
|
||||
}
|
||||
const sources: RuntimeWorktreeAgentSource[] = []
|
||||
for (const source of rowSources.values()) {
|
||||
const tabId =
|
||||
source.tabId ??
|
||||
parsePaneKey(source.paneKey)?.tabId ??
|
||||
parseLegacyNumericPaneKey(source.paneKey)?.tabId
|
||||
const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined
|
||||
if (
|
||||
tabId !== undefined &&
|
||||
mirroredWorktreeId === undefined &&
|
||||
(source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) &&
|
||||
!args.connectedPtyEvidence.tabIds.has(tabId) &&
|
||||
!args.connectedPtyEvidence.paneKeys.has(source.paneKey) &&
|
||||
(source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = mirroredWorktreeId ?? source.worktreeId
|
||||
sources.push({ ...source, tabId, worktreeId })
|
||||
}
|
||||
return sources
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
|
||||
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open'
|
||||
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
|
||||
|
||||
/**
|
||||
* The whole chain `worktree ps` walks: journal -> status feed -> agent rows -> worktree status.
|
||||
*
|
||||
* The feed's `published` map never retracts, so reading it as a roster reports every session the
|
||||
* app has ever opened. A closed chat that was waiting on an approval is the sharp edge: deliberate
|
||||
* close does not settle a pending prompt, so the retained summary stays `attention`, which maps to
|
||||
* a `blocked` row and merges the worktree to `permission` for the 30-minute freshness window.
|
||||
*/
|
||||
const WORKTREE_ID = 'repo-1::/workspace/app'
|
||||
const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
|
||||
const IDENTITY = {
|
||||
provider: 'codex',
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
ordinal: 0
|
||||
} as const
|
||||
|
||||
let root: string
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-structured-ps-liveness-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A session parked on an approval nobody answered — the state a deliberate close leaves behind. */
|
||||
async function awaitingApproval() {
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: WORKTREE_ID,
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
},
|
||||
journalDir: join(root, SESSION)
|
||||
})
|
||||
await journal.appendItem(
|
||||
{ ...IDENTITY, ordinal: 1 },
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
await journal.appendItem(
|
||||
{ ...IDENTITY, ordinal: 2 },
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Run the command?',
|
||||
detail: null,
|
||||
options: [{ id: 'allow', label: 'Allow' }],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
},
|
||||
{ fence: 1 }
|
||||
)
|
||||
const sessions = new Map([
|
||||
[
|
||||
SESSION,
|
||||
{ journal, params: { location: { workspaceId: WORKTREE_ID }, provider: 'codex' as const } }
|
||||
]
|
||||
])
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
sessions,
|
||||
getRecord: () => null,
|
||||
now: () => Date.now()
|
||||
})
|
||||
feed.publish(SESSION, journal)
|
||||
return { feed, sessions }
|
||||
}
|
||||
|
||||
function worktreeFor(
|
||||
feed: StructuredAgentSessionStatusFeed,
|
||||
summaries = feed.liveSessionSummaries()
|
||||
): RuntimeWorktreePsSummary {
|
||||
const row = {
|
||||
worktreeId: WORKTREE_ID,
|
||||
status: 'inactive',
|
||||
agents: []
|
||||
} as unknown as RuntimeWorktreePsSummary
|
||||
attachRuntimeWorktreeAgentRows({
|
||||
summaries: new Map([[WORKTREE_ID, row]]),
|
||||
pathIndex: { byPath: new Map(), byRealPath: new Map() } as never,
|
||||
missingWorktreeIds: new Set(),
|
||||
workingTerminalEvidenceByWorktreeId: new Map(),
|
||||
rowSources: collectRuntimeWorktreeAgentSources({
|
||||
mirroredWorktreeIdByTabId: new Map(),
|
||||
connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() },
|
||||
retainedSnapshots: [],
|
||||
hookSnapshots: [],
|
||||
structuredSummaries: summaries
|
||||
}),
|
||||
orchestrationByPaneKey: null,
|
||||
getSummary: (map, _paths, _missing, id) => map.get(id) ?? null
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
describe('worktree ps and a closed structured chat', () => {
|
||||
it('reports the blocked row while the session is still held', async () => {
|
||||
const { feed } = await awaitingApproval()
|
||||
const row = worktreeFor(feed)
|
||||
expect(row.agents).toHaveLength(1)
|
||||
expect(row.agents[0]?.state).toBe('blocked')
|
||||
expect(row.status).toBe('permission')
|
||||
})
|
||||
|
||||
it('stops reporting it once eviction forgets the session', async () => {
|
||||
const { feed, sessions } = await awaitingApproval()
|
||||
// `forget-session`, the last eviction step, does exactly this and nothing to the feed.
|
||||
sessions.delete(SESSION)
|
||||
|
||||
const row = worktreeFor(feed)
|
||||
expect(row.agents).toHaveLength(0)
|
||||
expect(row.status).toBe('inactive')
|
||||
})
|
||||
|
||||
it('keeps an aged host-held working state authoritative', async () => {
|
||||
const { feed } = await awaitingApproval()
|
||||
const aged = feed.liveSessionSummaries().map((summary) => ({
|
||||
...summary,
|
||||
hostExecutionOwned: true as const,
|
||||
updatedAt: Date.now() - 30 * 60 * 1000 - 1,
|
||||
status: 'working' as const
|
||||
}))
|
||||
const row = worktreeFor(feed, aged)
|
||||
expect(row.agents).toHaveLength(1)
|
||||
expect(row.agents[0]?.state).toBe('working')
|
||||
expect(row.status).toBe('working')
|
||||
expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt)
|
||||
})
|
||||
|
||||
it('keeps an aged host-held approval state authoritative', async () => {
|
||||
const { feed } = await awaitingApproval()
|
||||
const aged = feed.liveSessionSummaries().map((summary) => ({
|
||||
...summary,
|
||||
hostExecutionOwned: true as const,
|
||||
updatedAt: Date.now() - 30 * 60 * 1000 - 1
|
||||
}))
|
||||
const row = worktreeFor(feed, aged)
|
||||
expect(row.agents).toHaveLength(1)
|
||||
expect(row.agents[0]?.state).toBe('blocked')
|
||||
expect(row.status).toBe('permission')
|
||||
expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionStatusState,
|
||||
structuredAgentSessionTabId
|
||||
} from '../../shared/structured-agent-session-projection'
|
||||
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
|
||||
|
||||
/**
|
||||
* Row sources for the structured (non-PTY) sessions a host still holds.
|
||||
*
|
||||
* A structured session reaches none of the hook or retained snapshots every other row comes from,
|
||||
* so `worktree ps` projects it from the host's status feed instead. The feed's retained
|
||||
* projections are not a roster — the caller passes only sessions the host still holds.
|
||||
*/
|
||||
export function structuredRuntimeWorktreeAgentSources(
|
||||
summaries: readonly AgentSessionStatusSummary[]
|
||||
): RuntimeWorktreeAgentSource[] {
|
||||
const sources: RuntimeWorktreeAgentSource[] = []
|
||||
for (const summary of summaries) {
|
||||
// No turn has been persisted yet, so there is nothing to report - the same read the chat shows.
|
||||
if (!summary.status) {
|
||||
continue
|
||||
}
|
||||
const tabId = structuredAgentSessionTabId(summary.sessionId)
|
||||
// The DERIVED pane key the renderer already publishes, never the orchestration bearer handle
|
||||
// or the minted worker pane key: both of those are credentials.
|
||||
sources.push({
|
||||
paneKey: structuredAgentSessionPaneKey(tabId, summary.sessionId),
|
||||
tabId,
|
||||
worktreeId: summary.workspaceId,
|
||||
connectionId: null,
|
||||
// The shared mapping the sidebar applies, so the CLI and the GUI cannot disagree about one
|
||||
// session. No hook payload: nothing reads one off a structured row.
|
||||
state: structuredAgentSessionStatusState(summary.status),
|
||||
agentType: summary.agent,
|
||||
prompt: summary.latestPrompt,
|
||||
lastAssistantMessage: summary.lastAssistantMessage ?? null,
|
||||
toolName: summary.toolName ?? null,
|
||||
toolInput: summary.toolInput ?? null,
|
||||
interrupted: false,
|
||||
stateStartedAt: summary.updatedAt,
|
||||
updatedAt: summary.updatedAt,
|
||||
...(summary.hostExecutionOwned ? { authority: 'structured-host' as const } : {})
|
||||
})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { TabGroup } from '../../shared/tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority'
|
||||
|
||||
it('rebases a large group without rescanning tab order for every recent tab', () => {
|
||||
const ids = Array.from({ length: 1000 }, (_, index) => `tab-${index}`)
|
||||
const session: WorkspaceSessionState = {
|
||||
activeRepoId: 'repo',
|
||||
activeWorktreeId: 'repo::/workspace',
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
'repo::/workspace': ids.map((id, index) => ({
|
||||
id,
|
||||
worktreeId: 'repo::/workspace',
|
||||
ptyId: null,
|
||||
title: id,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: 0
|
||||
}))
|
||||
},
|
||||
terminalLayoutsByTabId: {},
|
||||
terminalTopologyRevisionByRepoId: { repo: 1 },
|
||||
tabGroups: {
|
||||
'repo::/workspace': [
|
||||
{
|
||||
id: 'group',
|
||||
worktreeId: 'repo::/workspace',
|
||||
activeTabId: 'missing',
|
||||
tabOrder: [...ids, 'missing'],
|
||||
recentTabIds: [...ids, 'missing']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const includes = vi.spyOn(Array.prototype, 'includes')
|
||||
let result: WorkspaceSessionState
|
||||
let probes: number
|
||||
try {
|
||||
result = rebaseWorkspaceSessionTerminalMembership(session, session)
|
||||
probes = includes.mock.calls.length
|
||||
} finally {
|
||||
includes.mockRestore()
|
||||
}
|
||||
expect(probes).toBeLessThan(10)
|
||||
expect(result.tabGroups?.['repo::/workspace'][0]).toMatchObject({
|
||||
tabOrder: ids,
|
||||
recentTabIds: ids,
|
||||
activeTabId: ids[0]
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the rebased session is the host-authoritative one, so a tab id the host no
|
||||
// longer has must not survive into it. `activeTabId` already failed closed; the
|
||||
// filtered `recentTabIds` used to be dropped when it emptied, letting the `...group`
|
||||
// spread put the unfiltered array back.
|
||||
it('drops tab ids the host no longer has from group membership, failing closed', () => {
|
||||
const buildSession = (
|
||||
activeTabId: string | null,
|
||||
recentTabIds: string[] | undefined
|
||||
): WorkspaceSessionState =>
|
||||
({
|
||||
activeRepoId: 'repo',
|
||||
activeWorktreeId: 'repo::/workspace',
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
'repo::/workspace': ['kept-a', 'kept-b'].map((id, index) => ({
|
||||
id,
|
||||
worktreeId: 'repo::/workspace',
|
||||
ptyId: null,
|
||||
title: id,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: 0
|
||||
}))
|
||||
},
|
||||
terminalLayoutsByTabId: {},
|
||||
terminalTopologyRevisionByRepoId: { repo: 1 },
|
||||
tabGroups: {
|
||||
'repo::/workspace': [
|
||||
{
|
||||
id: 'group',
|
||||
worktreeId: 'repo::/workspace',
|
||||
activeTabId,
|
||||
tabOrder: ['kept-a', 'closed-on-host', 'kept-b'],
|
||||
...(recentTabIds ? { recentTabIds } : {})
|
||||
}
|
||||
]
|
||||
}
|
||||
}) as WorkspaceSessionState
|
||||
|
||||
const rebase = (
|
||||
activeTabId: string | null,
|
||||
recentTabIds: string[] | undefined
|
||||
): TabGroup | undefined => {
|
||||
const session = buildSession(activeTabId, recentTabIds)
|
||||
return rebaseWorkspaceSessionTerminalMembership(session, session).tabGroups?.[
|
||||
'repo::/workspace'
|
||||
][0]
|
||||
}
|
||||
|
||||
// Every recent id is stale: the array must empty, not revert to the stale one.
|
||||
expect(rebase('kept-b', ['closed-on-host'])).toMatchObject({
|
||||
tabOrder: ['kept-a', 'kept-b'],
|
||||
activeTabId: 'kept-b',
|
||||
recentTabIds: []
|
||||
})
|
||||
// Mixed: only the host-known ids survive, in order.
|
||||
expect(rebase('kept-a', ['closed-on-host', 'kept-b', 'closed-on-host', 'kept-a'])).toMatchObject({
|
||||
recentTabIds: ['kept-b', 'kept-a']
|
||||
})
|
||||
// A stale active tab falls back to the first surviving tab, never to the stale id.
|
||||
expect(rebase('closed-on-host', ['kept-a'])).toMatchObject({
|
||||
activeTabId: 'kept-a',
|
||||
recentTabIds: ['kept-a']
|
||||
})
|
||||
expect(rebase(null, undefined)).toMatchObject({ activeTabId: 'kept-a' })
|
||||
// A group that never carried recentTabIds must not gain the key.
|
||||
expect(rebase('kept-a', undefined)).not.toHaveProperty('recentTabIds')
|
||||
})
|
||||
@@ -93,17 +93,18 @@ function rebaseTabGroups(
|
||||
if (tabOrder.length === 0) {
|
||||
return []
|
||||
}
|
||||
const tabIds = new Set(tabOrder)
|
||||
const activeTabId =
|
||||
group.activeTabId && tabOrder.includes(group.activeTabId)
|
||||
? group.activeTabId
|
||||
: (tabOrder[0] ?? null)
|
||||
const recentTabIds = group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId))
|
||||
group.activeTabId && tabIds.has(group.activeTabId) ? group.activeTabId : (tabOrder[0] ?? null)
|
||||
const recentTabIds = group.recentTabIds?.filter((tabId) => tabIds.has(tabId))
|
||||
return [
|
||||
{
|
||||
...group,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {})
|
||||
// Why assigned even when it filters to empty: omitting the key lets `...group`
|
||||
// re-introduce the unfiltered array, persisting ids for tabs the host dropped.
|
||||
...(group.recentTabIds ? { recentTabIds: recentTabIds ?? [] } : {})
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { DiscoveredSkill } from '../../shared/skills'
|
||||
import {
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
|
||||
AgentSkillSharingError
|
||||
} from '../../shared/agent-skill-sharing-contract'
|
||||
import { selectDiscoveredSkills } from './agent-skill-selection'
|
||||
|
||||
@@ -50,3 +51,62 @@ describe('agent skill selection', () => {
|
||||
).toThrow(expect.objectContaining({ code: AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE }))
|
||||
})
|
||||
})
|
||||
|
||||
it('indexes a batch of selectors without rescanning discovery', () => {
|
||||
let reads = 0
|
||||
const skills = Array.from({ length: 1000 }, (_, index) => ({
|
||||
...skill(`id-${index}`, `name-${index}`),
|
||||
get id() {
|
||||
reads++
|
||||
return `id-${index}`
|
||||
}
|
||||
}))
|
||||
const selected = selectDiscoveredSkills(
|
||||
skills,
|
||||
skills.map((_, index) => `id-${index}`)
|
||||
)
|
||||
expect(selected).toHaveLength(1000)
|
||||
expect(selected[999]).toBe(skills[999])
|
||||
expect(reads).toBeLessThan(10000)
|
||||
})
|
||||
|
||||
it('indexes only the requested selectors, not every discovered skill', () => {
|
||||
let nameReads = 0
|
||||
const skills = Array.from({ length: 1000 }, (_, index) => ({
|
||||
...skill(`id-${index}`, `name-${index}`),
|
||||
get name() {
|
||||
nameReads++
|
||||
return `name-${index}`
|
||||
}
|
||||
}))
|
||||
expect(selectDiscoveredSkills(skills, ['id-900'])).toEqual([skills[900]])
|
||||
// One membership probe per discovered skill, plus reads for the single match's
|
||||
// own bucket and the trailing collision check. Indexing every name would need
|
||||
// three reads apiece.
|
||||
expect(nameReads).toBeLessThanOrEqual(1_100)
|
||||
})
|
||||
|
||||
// `matchingIds` is what the CLI prints so the user can disambiguate, so the
|
||||
// index must report every match in discovery order, exactly like the old filter.
|
||||
it('reports every ambiguous match in discovery order', () => {
|
||||
let thrown: unknown
|
||||
try {
|
||||
selectDiscoveredSkills(
|
||||
[skill('one', 'same'), skill('unrelated', 'other'), skill('two', 'same')],
|
||||
['same']
|
||||
)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AgentSkillSharingError)
|
||||
const error = thrown as AgentSkillSharingError
|
||||
expect(error.code).toBe(AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE)
|
||||
expect(error.data).toEqual({ selector: 'same', matchingIds: ['one', 'two'] })
|
||||
})
|
||||
|
||||
it('retains first duplicate ID authority and exact ID precedence over names', () => {
|
||||
const first = skill('id', 'first')
|
||||
expect(
|
||||
selectDiscoveredSkills([first, skill('id', 'second'), skill('other', 'id')], ['id'])
|
||||
).toEqual([first])
|
||||
})
|
||||
|
||||
@@ -10,13 +10,35 @@ export function selectDiscoveredSkills(
|
||||
selectors: readonly string[]
|
||||
): DiscoveredSkill[] {
|
||||
const selected = new Map<string, DiscoveredSkill>()
|
||||
// Indexed only for the selectors actually asked for: a share request carries at
|
||||
// most 512 of them while discovery can return every skill on the machine, and
|
||||
// indexing the whole set costs more than the scans it replaces for the
|
||||
// one-or-two-selector requests agents actually send.
|
||||
const requested = new Set(selectors)
|
||||
const byId = new Map<string, DiscoveredSkill>()
|
||||
const discoveredByName = new Map<string, DiscoveredSkill[]>()
|
||||
for (const skill of skills) {
|
||||
// First writer wins, matching the `find` this replaces.
|
||||
if (requested.has(skill.id) && !byId.has(skill.id)) {
|
||||
byId.set(skill.id, skill)
|
||||
}
|
||||
if (!requested.has(skill.name)) {
|
||||
continue
|
||||
}
|
||||
const named = discoveredByName.get(skill.name)
|
||||
if (named) {
|
||||
named.push(skill)
|
||||
} else {
|
||||
discoveredByName.set(skill.name, [skill])
|
||||
}
|
||||
}
|
||||
for (const selector of selectors) {
|
||||
const exactId = skills.find((skill) => skill.id === selector)
|
||||
const exactId = byId.get(selector)
|
||||
if (exactId) {
|
||||
selected.set(exactId.id, exactId)
|
||||
continue
|
||||
}
|
||||
const named = skills.filter((skill) => skill.name === selector)
|
||||
const named = discoveredByName.get(selector) ?? []
|
||||
if (named.length === 0) {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
|
||||
@@ -36,7 +58,12 @@ export function selectDiscoveredSkills(
|
||||
const values = [...selected.values()]
|
||||
const byName = new Map<string, DiscoveredSkill[]>()
|
||||
for (const skill of values) {
|
||||
byName.set(skill.name, [...(byName.get(skill.name) ?? []), skill])
|
||||
const named = byName.get(skill.name)
|
||||
if (named) {
|
||||
named.push(skill)
|
||||
} else {
|
||||
byName.set(skill.name, [skill])
|
||||
}
|
||||
}
|
||||
const collision = [...byName.entries()].find(([, named]) => named.length > 1)
|
||||
if (collision) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
} from '../../shared/skills'
|
||||
import {
|
||||
buildSkillDiscoverySources,
|
||||
compareSkills,
|
||||
sortDiscoveredSkills,
|
||||
sortSkillDiscoverySources,
|
||||
sourceKindForSkill,
|
||||
sourceLabelForSkill,
|
||||
stablePathId,
|
||||
@@ -292,7 +293,7 @@ export async function discoverSkills(args: {
|
||||
mergeScannedSkill(seen, skill)
|
||||
}
|
||||
}
|
||||
const skills = Array.from(seen.values()).sort(compareSkills)
|
||||
const skills = sortDiscoveredSkills(Array.from(seen.values()))
|
||||
// Why: root *ids* — a repo/plugin id is already a hash, while its label carries
|
||||
// the repo or plugin name and its path carries the user's directory names. A
|
||||
// fully cached scan did no filesystem work, so it stays silent rather than
|
||||
@@ -309,9 +310,7 @@ export async function discoverSkills(args: {
|
||||
}
|
||||
return {
|
||||
skills,
|
||||
sources: sources.sort((a, b) =>
|
||||
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|
||||
),
|
||||
sources: sortSkillDiscoverySources(sources),
|
||||
scannedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,3 +133,89 @@ describe('installSkillCloudGrant', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// The failure report is keyed by user-visible skill IDs, so switching the
|
||||
// membership test from `includes` to a Set must not change which entries appear,
|
||||
// how often, or in what order.
|
||||
it('reports selected manifest entries in manifest order, duplicates and all', async () => {
|
||||
const skills = ['b', 'a', 'dupe', 'dupe', 'unselected'].map((id) => ({
|
||||
id,
|
||||
name: `name-${id}`,
|
||||
digest: 'a'.repeat(64),
|
||||
files: []
|
||||
}))
|
||||
const bundleGrant = {
|
||||
...grant,
|
||||
version: { ...grant.version, manifest: { skills, bundleDigest: 'c'.repeat(64) } }
|
||||
} as unknown as SkillCloudDownloadGrant
|
||||
const runtime = {
|
||||
installSharedSkillBundleRequest: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('skill-install-filesystem-failed'))
|
||||
} as unknown as OrcaRuntimeService
|
||||
const result = await installSkillBundleCloudGrant(runtime, bundleGrant, {
|
||||
operationId: 'op',
|
||||
// Repeated and unknown selections must be inert, exactly as with `includes`.
|
||||
selectedSkillIds: ['dupe', 'a', 'a', 'b', 'never-in-manifest'],
|
||||
destination: { scope: 'global' }
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
if (result.status === 'ok') {
|
||||
expect(result.value.skills.map((skill) => skill.skillId)).toEqual(['b', 'a', 'dupe', 'dupe'])
|
||||
}
|
||||
})
|
||||
|
||||
it('reports no skills when nothing was selected', async () => {
|
||||
const skills = [{ id: 'a', name: 'a', digest: 'a'.repeat(64), files: [] }]
|
||||
const bundleGrant = {
|
||||
...grant,
|
||||
version: { ...grant.version, manifest: { skills, bundleDigest: 'c'.repeat(64) } }
|
||||
} as unknown as SkillCloudDownloadGrant
|
||||
const runtime = {
|
||||
installSharedSkillBundleRequest: vi.fn().mockRejectedValue(new Error('skill-install-cancelled'))
|
||||
} as unknown as OrcaRuntimeService
|
||||
const result = await installSkillBundleCloudGrant(runtime, bundleGrant, {
|
||||
operationId: 'op',
|
||||
selectedSkillIds: [],
|
||||
destination: { scope: 'global' }
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
if (result.status === 'ok') {
|
||||
expect(result.value.skills).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['skill-install-cancelled', 'skill-install-filesystem-failed'])(
|
||||
'indexes selected IDs when reporting %s',
|
||||
async (code) => {
|
||||
let reads = 0
|
||||
const ids = Array.from({ length: 1000 }, (_, index) => `skill-${index}`)
|
||||
const selectedSkillIds = new Proxy(ids, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
||||
reads += 1
|
||||
}
|
||||
return Reflect.get(target, key, receiver)
|
||||
}
|
||||
})
|
||||
const skills = ids.map((id) => ({ id, name: id, digest: 'a'.repeat(64), files: [] }))
|
||||
const bundleGrant = {
|
||||
...grant,
|
||||
version: { ...grant.version, manifest: { skills, bundleDigest: 'c'.repeat(64) } }
|
||||
} as unknown as SkillCloudDownloadGrant
|
||||
const runtime = {
|
||||
installSharedSkillBundleRequest: vi.fn().mockRejectedValue(new Error(code))
|
||||
} as unknown as OrcaRuntimeService
|
||||
const result = await installSkillBundleCloudGrant(runtime, bundleGrant, {
|
||||
operationId: 'op',
|
||||
selectedSkillIds,
|
||||
destination: { scope: 'global' }
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
if (result.status === 'ok') {
|
||||
expect(result.value.skills.map((skill) => skill.skillId)).toEqual(ids)
|
||||
expect(result.value.status).toBe(code.includes('cancelled') ? 'cancelled' : 'failed')
|
||||
}
|
||||
expect(reads).toBeLessThanOrEqual(2000)
|
||||
}
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user