diff --git a/AGENTS.md b/AGENTS.md index aa1230783..0cad229a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,9 +176,10 @@ git add devlog && git commit -m "chore: update devlog ref" && git push ### Architecture Docs Sync -- Native event foundation: `src/shared/runtime-contract.ts` + `src/agent/runtime/*` own canonical Codex/Pi projections and optional explicit outcomes. `agent_runtime`/`agent_runtime_gap` publish directly to SSE, bypassing messaging listeners. Native compatibility terminals carry only finality/status and existing trace identity; no public partial/outcome object. Preserve legacy final selection when outcome is absent, and interrupted MESSAGE salvage before exit settlement. See `structure/runtime-integration.md` and `structure/stream-events.md`; Activity layout/default settings are separate follow-on work. +- Native event foundation: `src/shared/runtime-contract.ts` + `src/agent/runtime/*` own canonical Codex/Pi projections and optional explicit outcomes. `agent_runtime`/`agent_runtime_gap` publish directly to SSE, bypassing messaging listeners. Native compatibility terminals carry only finality/status and existing trace identity; no public partial/outcome object. Preserve legacy final selection when outcome is absent, and interrupted MESSAGE salvage before exit settlement. See `structure/runtime-integration.md` and `structure/stream-events.md`; display defaults and durable history use `shared/presentation.ts` and `trace/activity-journal.ts`, with client layout handled separately. - Runtime selection: only Cursor/Grok/Claude accept `perCli..transport`; existing absence stays print before defaults merge. Native switchable keys prefix the whole legacy bucket with `native-v1:` and never overwrite the print singleton. Capture transport/bucket once, forward through lifecycle persistence/compact, and keep scoped resets exact. Unsupported main/worker native adapters fail before print/fallback work; compiled support in `/api/cli-status` is separate from cached auth/binary readiness. Codex App/Pi keys remain unchanged. +- Cursor native main: explicit native/auto only; restrictive permissions and unsupported workers fail before `regenerateB`, bucket/bootstrap/snapshot, detection or pool work. `AcpRuntimeSession` keeps raw final/partial independent of bounded Activity and claims an immutable logical result before lifecycle; passive finalization survives main-map removal. Native run cleanup holds the exact lease through application settlement and uses captured exit-barrier identity. Server-explicit scope/chat bindings survive multi-session off; private identity-only I/O liveness keeps the owning collector alive without messaging content. Preserve legacy/manual compact while excluding print-era automatic compact/count heuristics from explicit native outcomes. - `structure/` is the current architecture-doc hub; do not point new docs at `devlog/structure/`. - Keep `README.md`, root `AGENTS.md`, root `CLAUDE.md`, and `structure/AGENTS.md` synchronized when command/API/orchestration surfaces change. - Recent non-strict hotspots: explicit `/continue`, workflow helper slash commands (`/plan` as PABCD P compatibility guide, `/interview`, `/deliberate`, `/planaudit`, `/review`, `/search`, `/goal`, `/goalplan`, `/team`, `/task`, `/fork`, `/gd`; forward PABCD transitions require `cli-jaw orchestrate --attest '{"from","to","did",...}'`), pre-prompt context hooks (`context-hooks.json`, `cli-jaw hooks`), bounded local search contract (narrow-path Grep/rg; external search via active search skill), Telegram Hub P0–P4 (`structure/telegram.md`, `/api/dashboard/telegram-hub`), goal pause gate continuation suppression (`goal_pause_gate_pending`), `tests/run.mts` programmatic test driver, `/goal plan` and `/goalplan` store user direction as `planHint` and require `/goal refine` before checkpoints; agent pause first-tap state is exposed as derived `pauseGate` on status/API surfaces while persisted status remains `active`; bounded automation is `/goal run ...`, not top-level `/autopilot`), Codex App clean-install default with opt-in migration for existing settings, bounded child-backed nullable CLI status, read-only OpenCodex root-URL/live-health diagnostics, Pi top-level `pi --mode rpc` runtime with isolated `PI_CODING_AGENT_DIR` profiles, AGY `-p` print-mode runtime with capability-probed optional `--model` (observed in AGY 1.0.12), Grok weekly quota via `~/.grok/auth.json` + Grok Build billing gRPC-web before legacy monthly fallback, SSE-first `GET /api/events` event channel with WebSocket fallback, bounded tool-log sanitizer, worker progress query/watch, canonical `/api/channel/send`, heartbeat `every`/`cron` schedules, heartbeat Slack mention watch inside `runHeartbeatJob` (`mentionWatch`, bot-token `conversations.history` scan instead of user-token-only `search.messages`, frontier/resume/round-robin/429 stop/60-channel cap, per-item busy yield, server-owned thread send then seen receipt, at-least-once delivery; the ledger is keyed by `(jobId, workspaceId, userId)` with the workspace id taken from a per-token `auth.test` rather than `settings.slack.teamId`, pre-v2 rows hold a job in a durable SQLite quarantine cleared only by `POST /api/heartbeat/:jobId/mention-watch-fresh-start` with a new `since`, and a duplicate job id in one PUT is a 400; the answer turn carries the answered thread's `chatSessionId` but runs in a dedicated `mention-watch:` scope so inbound Slack cannot steer it, with a per-conversation guard of `getState(remoteKey) === 'IDLE'` + `hasChatSessionWork` + non-blocking `sessionLanes.hasPending`, and the thread session is minted only on admission) @@ -191,6 +192,16 @@ git add devlog && git commit -m "chore: update devlog ref" && git push Canonical: dev-pabcd §10, dev-testing §9.5; injected via orchestration template and goal continuation. - `structure/` reading map: start at `structure/INDEX.md`; depth — `telegram.md` (Hub), `prompt_flow.md` (attest/hooks/bounded search), `stream-events.md` (pause gate/SSE), `infra.md` (test scripts), `commands.md` + `server_api.md` (slash/API surfaces). Concurrent inbound gateway docs: `structure/INDEX.md` §gateway, `structure/infra.md` §`src/messaging/`, `structure/telegram.md` §common messaging layer; legacy `settings.channel` is a deprecated read-only alias for one major version. +### Native decisions + +Print Activity observes accepted legacy parser data and closes from the existing lifecycle-selected application-final. Copilot/ordinary print own observers; native Pi/Codex do not create a second one. Captured `activityIdentity` supplies existing sessionId/scope wire fields even with multi-session disabled. Tool merges use run+ref/seq identity, terminal non-regression and one omission marker; safe trace-tool recovery must not throw into provider parsing. Spawn error/stale-retry bypasses close the observer and trace independently while preserving existing send/resolve/retry order. + +Activity journal and raw trace routes share exact chat ownership for every owned row, including historical backfills. Forked message pointers do not grant access. Runtime rows are immutable and bounded; whole-prefix retention reports loss, protects active owners and cannot be disabled by one corrupt control. Journal failure must not interrupt final delivery or MESSAGE salvage. See `structure/runtime-integration.md` and `structure/server_api.md`. + +Activity display is selected by `presentation.mode` (`activity` default, explicit `legacy` retained), separately from provider transport. Snapshot `GET /api/orchestrate/snapshot?session=...` supplies captured `activityIdentity={sessionId,scope}`; clients validate it before semantic admission. Presentation-only settings writes must not reset fallback state or synchronize execution configuration. Existing instance auth and disabled multi-session resolver policy remain. + +`src/agent/runtime/requests.ts` and `acp/callbacks.ts` own bounded pending decisions, opaque native-option mapping and cancellation latches. `GET /api/runtime/requests?sessionId=...` and `POST /api/runtime/requests/:id` use the existing instance auth policy (including loopback/LAN bypass), never a current-session fallback. Match run/session/scope/turn and current ownership before answering. Canonical sanitization and the32KiB event preflight precede insertion; global128/120s and per-connection32 bounds apply. An admitted selected write cannot be retracted: cancellation during dispatch retires the connection. Provider activation, approval UI and messaging changes are not implied. Sync `structure/runtime-integration.md` and `structure/server_api.md`. + ### Concurrent inbound gateway (M1) - Settings schema v4 replaces top-level `channel` with `messaging.enabledChannels` (array) and `messaging.homeChannel`. New installs start with an empty enabled set; existing v3 `channel` is migrated to a one-element enabled set and matching home channel, then deleted from persisted settings. @@ -201,8 +212,9 @@ git add devlog && git commit -m "chore: update devlog ref" && git push ### Mid-run steer (기본 정책) -- `multiSession.midRunPolicy` 기본값은 `'steer'`다 (기본값·마이그레이션·검증 모두 steer로 정규화). 런타임이 in-band steer를 지원하면(jwc 항상, codex-app은 steer 가능한 turn 진행 중에 `MainRunState.steerTurnInBand` 훅으로) kill 없이 같은 턴에 주입된다. codex-app은 app-server `turn/steer`를 사용하므로 이전 맥락 손실이 없다. **in-band 미지원 런타임도 큐가 아니라 kill-steer다** — 진행 턴을 kill하고 새 run을 시작하되 salvage가 맥락을 이어준다. 큐로 물러나는 것은 in-band 시도의 race(턴 종료)·review/compact 거부뿐이며, 큐 대기를 원하면 `'followup'`/`'collect'` 정책을 쓴다. -- 명시적 `/steer`·`/queue steer`는 in-band 불가 런타임에서 kill-path를 쓰되, 중단된 턴의 부분 출력이 exit-settle 배리어(`armExitSettle`/`settleExit`/`waitForExitSettled`)와 pre-kill MAX(id) 스냅샷으로 salvage되어 `withSteerContext` 블록으로 follow-up 프롬프트에 주입된다. 정책 표와 결정 순서는 `structure/prompt_flow.md` §Mid-run 메시지 정책 참고. Manager 설정(Agent 페이지)에서 정책을 바꿀 수 있다. +- `multiSession.midRunPolicy` 기본값은 `'steer'`다. JWC와 steer 가능한 Codex App turn은 in-band 입력을 받는다. Native Cursor는 `replaceTurn` 훅으로 원래 prompt 취소 응답·업데이트·callback을 모두 처리한 뒤 같은 native session에 다시 요청한다. 이는 `cancel-reprompt`이며 native-input이 아니다. 원래 요청·수락된 추가 지시·제한된 부분 출력은 읽기 전용 문맥으로, 현재 운영 지침은 활성 지침으로 복원한다. +- Native Cursor는 전송 완료 뒤에도 main 객체·세대·정규 소유권을 재검사하고 입력을 한 번만 기록한다. 진행 중인 replacement의 후속 입력은 큐로 갈 수 있지만, 취소·전송·기록 실패는 자동 재시도하지 않는다. 나머지 런타임의 기존 in-band/kill-steer 동작과 `followup`/`collect` 대기는 유지한다. +- `/steer`는 런타임 훅을 사용한다. 별도 `/queue steer `은 기존 항목을 중단 후 우선 실행하는 동작을 유지한다. Kill 경로의 제한된 부분 출력은 pre-kill MAX(id)와 정확한 exit-settle 배리어 뒤 `withSteerContext`로 복원한다. 전체 과거 맥락 보존을 보장하지 않는다. 정책 표는 `structure/prompt_flow.md`를 참고한다. ### Korean Content Skill Routing diff --git a/CLAUDE.md b/CLAUDE.md index 62c63cb3e..4aa138541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,12 @@ This repository is a Node.js ESM orchestration runtime for boss/employee dispatc ## Documentation Map +- Print Activity is an observation path: `runtime/print-projection.ts` and `print-activity.ts` reuse canonical projection bounds and gap handling. Legacy accepted-event hooks run before message resets; selected application-final comes only from lifecycle. `merge-tool-log.ts` keys by run plus stable ref/seq and preserves terminal status, identityless tools and one omission marker. Trace-only failures cannot roll back a MESSAGE or introduce another send. + +- Activity journal reuses immutable runtime trace rows and nullable admission owners. Replay and raw owned trace reads require exact chat ownership; forks do not inherit access. Whole-prefix retention preserves explicit loss while active owners remain protected. Journal failure cannot gate final delivery or interrupted MESSAGE salvage. + +- Activity uses `presentation.mode` (`activity` by default, reversible `legacy`) independently of provider transport. `GET /api/orchestrate/snapshot?session=...` returns server-owned `activityIdentity`; validate it before semantic admission. Display-only writes preserve runtime selection and delivery. See `structure/runtime-integration.md`. + - Start at `structure/INDEX.md` for the current architecture map. - Keep `README.md`, `AGENTS.md`, this file, and `structure/AGENTS.md` aligned when command/API/orchestration behavior changes. Concurrent inbound gateway changes belong in `structure/INDEX.md`, `structure/infra.md`, `structure/telegram.md`, and the messaging runtime docs. - Do not use the old `devlog/structure/` path for architecture docs; the active folder is `structure/`. @@ -14,6 +20,8 @@ This repository is a Node.js ESM orchestration runtime for boss/employee dispatc ## Current Runtime Notes +- Native decision APIs are `GET /api/runtime/requests?sessionId=...` and `POST /api/runtime/requests/:id`. Existing instance auth, exact run/session/scope/turn and current-owner checks apply; this is not a tenant ACL or a new loopback credential policy. The registry sanitizes and preflights the32KiB event before insertion, keeps native option IDs in private mappings, and caps128 requests/120s; ACP callbacks cap32 and retain cancellation latches through reply completion. An unflushed selected reply forces connection retirement on cancellation. Provider activation and Activity controls remain separate; no messaging contract changes. + - New installations prefer Codex App when capability and auth are ready; existing saved runtimes change only through the one-time accept/keep Settings action. CLI status is served from a nullable stale-while-revalidate cache whose probes run in a bounded child. OpenCodex diagnostics compare the read-only Codex root URL with the live runtime fingerprint and never modify Codex config. - PABCD entry is explicit: `jaw orchestrate`, `/orchestrate`, or `/pabcd`. Resume is explicit `/continue`; natural-language “continue/계속/이어서” remains a normal prompt. - Workflow helper slash commands are `/plan`, `/interview`, `/deliberate`, `/planaudit`, `/review`, `/search`, `/goal`, `/goalplan`, `/team`, `/task`, `/fork`, and `/gd`. Dynamic `/skill:` injects an active skill on CLI/Web. `/plan` is a compatibility guide for users expecting a plan command; it maps to PABCD P and does not create another planning mode. `/planaudit` is the canonical remote-safe spelling; `/plan-audit` is not registered. `/search ` forces the active search skill policy, rewrites focused queries, discovers candidate URLs, and uses browser commands only for evidence verification after candidates exist. Bounded automation is a `/goal run ...` subcommand family, not a separate top-level `/autopilot` command; current `/goal run` controls are tracking-oriented runtime gates. @@ -48,6 +56,8 @@ This repository is a Node.js ESM orchestration runtime for boss/employee dispatc - Tool logs are capped by `src/shared/tool-log-sanitize.ts` before SSE/WebSocket, `agent_done`, and orchestration snapshot delivery. Web UI delivery is SSE-first through `GET /api/events`, with WebSocket as the legacy fallback dispatcher. - Canonical Codex/Pi events use `src/agent/runtime/*` and the shared runtime contract. Trace-first `agent_runtime` bypasses messaging listeners; a per-run `agent_runtime_gap` cannot suppress ordinary final delivery. Optional native outcomes preserve absent/empty finals and separate partial salvage; compatibility terminals expose finality/status, never the full outcome. Existing untagged behavior remains. See `structure/runtime-integration.md` before extending an adapter. - Cursor/Grok/Claude transport choice is independent of Activity display. Preserve missing existing settings as print before defaults merge; keep native-v1 session keys and the print singleton isolated. Forward the captured mode/bucket through both saves and all compact paths, use exact scoped reset keys, and guard unsupported main/worker native modes before any print/fallback side effect. Status implementation flags are not binary/auth readiness; builtin Codex App/Pi identities remain unchanged. +- Cursor main native ACP is explicit auto-only until restrictive policy is verified; reject other modes before prompt-file/session preparation. Native final/partial are independent of Activity previews and use claim-before-lifecycle/passive-finalize. Keep collector liveness private and text-free, preserve explicit server execution binding even when multi-session is off, fence old live/lease/barrier cleanup, and never rerun lifecycle or inference after partial failure. +- Cursor cancel-reprompt uses the provider-neutral replacement controller and exact local-dispatch commit barrier, not `steerTurnInBand`. Restore bounded original/accepted/partial context separately from active operational rules. Recheck captured main and canonical ownership before input recording; no-start may queue, fatal never retries. `/steer` recognizes the hook; `/queue steer` remains forced interrupt/new-run. Post-B anonymous packet provenance relies on ACP v1 ordering, not local epochs. - Employee worker progress is query-first via `jaw worker status [agent]`, watchable via `jaw worker watch [agent]` or `jaw dispatch --watch`, memory-only for current plus previous completed run, and safe-summary only with thinking detail hidden. - `jaw employee list [--json]` lists DB and static employees, including Control. `jaw dispatch` reads response bodies defensively and reports stale/missing server routes when an old manager returns HTML instead of JSON. - `npm run build` is a pure backend build/link operation and must not signal, kill, or restart live manager processes. diff --git a/README.md b/README.md index 0bc8f2ef2..90e68ac64 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,15 @@ ## Install +Activity-capable clients use `presentation.mode`: `activity` by default, or `legacy` +for the previous transcript view. This preference is independent of provider transport. +Activity clients obtain their chat identity from the server snapshot before subscribing +to semantic updates; see [runtime integration](structure/runtime-integration.md). +Activity history is retained in the bounded trace journal and replayed with a fixed +cursor. Owned raw trace requests also carry their captured chat session. +Print runtimes project accepted messages and tool updates into the same journal; +the existing lifecycle still selects the final answer, including when Activity fails. +
Safe install — for existing users who want minimal changes @@ -407,6 +416,12 @@ Codex app-server and Pi RPC also record versioned, redacted runtime events indep Cursor, Grok and Claude retain `print` compatibility mode. Their optional `perCli..transport` setting is validated independently of display preferences; selecting an unimplemented native adapter gives an explicit error, never silently launches print. Existing settings without this field stay print, and native/print session storage is isolated. `/api/cli-status` reports compiled native main/worker support separately from cached binary/authentication readiness. +Cursor main turns support the native ACP path with explicit `transport: "native"` and literal `permissions: "auto"`; restrictive native permissions and workers are rejected before prompt-file or session preparation. Model/effort use native advertised choices (Composer models may require unset effort). Canonical tool/commentary activity is separate from the full final answer; interrupted text remains available for steer salvage. Native I/O refreshes only its own collector through a private, text-free callback. Display defaults and owned history UI remain separate rollout layers. + +Native Cursor redirects use **cancel-reprompt**, not in-band input: the original prompt's cancelled response and pending updates finish before the replacement is sent in the same native session. cli-jaw restores the original request, accepted redirects and bounded incomplete output as context while keeping current instructions active. `/steer` uses this path; another pending redirect may queue, but a failed or indeterminate dispatch is not automatically retried. `/queue steer ` retains its separate interrupt-and-run-now behavior. + +Native decision APIs are available at `GET /api/runtime/requests?sessionId=...` and `POST /api/runtime/requests/:id`. They use the existing instance authentication policy, exact run/session/scope/turn matching and opaque choice handles. Decisions expire after two minutes; accepting a response records a choice, not tool completion. Provider activation and Activity approval controls remain separate follow-on layers; messaging behavior is unchanged. + ### Instance Manager See every running AI instance — start, stop, restart with one click. Preview live Web UIs directly in the dashboard. @@ -563,7 +578,7 @@ The quota/status panel keeps the same runtime keyset as the registry. Cold statu > Switch engines live: `/cli codex`. Switch models: `/model gpt-5.5`. Works from Web, Terminal, Telegram, Discord, or Slack. -**Mid-run steering**: sending a message while the agent is working steers the running turn by default (`multiSession.midRunPolicy: 'steer'`). On the JWC and Codex App runtimes the message joins the in-flight turn itself — the model keeps its full context. On every other runtime the current turn is redirected (kill-steer) with the interrupted turn's partial output carried into the follow-up run, so nothing the agent was doing is lost. Prefer waiting in line? Set the policy to `followup` or `collect` in Settings → Agent. +**Mid-run steering**: the default policy is `multiSession.midRunPolicy: 'steer'`. JWC and Codex App accept in-flight input; native Cursor cancels and drains the active prompt, then re-prompts in the same session with application-restored context. Other paths interrupt the current run and carry bounded partial output into a new run; this is not a guarantee of complete history retention. Prefer waiting in line? Choose `followup` or `collect` in Settings → Agent. --- diff --git a/devlog b/devlog index df4a17a87..e0ff8c903 160000 --- a/devlog +++ b/devlog @@ -1 +1 @@ -Subproject commit df4a17a87bf56a4557cf8f722f4d1b6175dada9b +Subproject commit e0ff8c9033c76c3b69823c259eddc81c86a7da65 diff --git a/public/js/features/process-block.ts b/public/js/features/process-block.ts index 8f7900b0c..886e957ae 100644 --- a/public/js/features/process-block.ts +++ b/public/js/features/process-block.ts @@ -1,5 +1,8 @@ import { escapeHtml } from '../render.js'; import { ICONS } from '../icons.js'; +import { api } from '../api.js'; +import { parseActivityIdentity } from '../../../src/shared/presentation.js'; +import { withCurrentSessionQuery } from './session-hub.js'; import { displayShellCommand, displayShellCommandDetail, @@ -51,6 +54,7 @@ const PROCESS_DETAIL_COLLAPSE_CLEAR_CHARS = 1000; const PROCESS_BLOCK_MAX_RENDERED_STEPS = 80; const PROCESS_BLOCK_HEAD_STEPS = 24; const PROCESS_BLOCK_TAIL_STEPS = 24; +let traceOpenIntent = 0; export interface StoredProcessStepMeta { id: string; @@ -421,7 +425,15 @@ export function bindProcessBlockInteractions(root: HTMLElement): void { event.stopPropagation(); const runId = traceTrigger.dataset['traceRunId'] || ''; const seq = Number(traceTrigger.dataset['traceSeq'] || 0); - import('./trace-drawer.js').then(m => m.openTraceDrawer(runId, seq)) + const intent = ++traceOpenIntent; + // Capture the selected query before the lazy import; the server resolves + // disabled/default sessions too. A failed read never invents an owner. + const snapshot = api<{ activityIdentity?: unknown }>(withCurrentSessionQuery('/api/orchestrate/snapshot')); + Promise.all([import('./trace-drawer.js'), snapshot]) + .then(([m, data]) => { + if (intent !== traceOpenIntent || !traceTrigger.isConnected) return; + return m.openTraceDrawer(runId, seq, parseActivityIdentity(data?.activityIdentity)?.sessionId ?? null); + }) .catch(error => console.warn('[trace-drawer] open failed:', error)); return; } diff --git a/public/js/features/trace-drawer.ts b/public/js/features/trace-drawer.ts index 9b3f553fe..621e3e9fc 100644 --- a/public/js/features/trace-drawer.ts +++ b/public/js/features/trace-drawer.ts @@ -20,15 +20,18 @@ let totalCount = 0; let loading = false; let openRequestId = 0; let selectedSeq: number | null = null; +let traceSession: string | null = null; +let traceController: AbortController | null = null; +let returnFocus: HTMLElement | null = null; function eventTypeOf(event: TraceEventListItem): string { return event.eventType || event.event_type || 'event'; } function isCurrentRequest(requestId: number, runId = currentRunId): boolean { return requestId === openRequestId && runId === currentRunId; } -function requestedOffset(seq?: number): number { - if (!seq || !Number.isInteger(seq) || seq < 1) return 0; - return Math.floor((seq - 1) / PAGE_SIZE) * PAGE_SIZE; +function tracePath(path: string): string { + if (!traceSession) return path; + return path + (path.includes('?') ? '&' : '?') + new URLSearchParams({session:traceSession}); } function ensureDrawer(): HTMLElement { @@ -74,7 +77,14 @@ function setRaw(text: string): void { const raw = document.getElementById('traceEventRaw'); if (raw) raw.textContent = text; } -function closeTraceDrawer(): void { document.getElementById('traceDrawerOverlay')?.classList.remove('open'); } +export function closeTraceDrawer(): void { + ++openRequestId; + traceController?.abort(); traceController = null; + loading = false; + document.getElementById('traceDrawerOverlay')?.classList.remove('open'); + if (returnFocus?.isConnected) returnFocus.focus({preventScroll:true}); + returnFocus = null; +} function renderSummary(summary: TraceSummary): void { const title = document.getElementById('traceDrawerTitle'); @@ -121,9 +131,9 @@ function renderEventRows(events: TraceEventListItem[], runId: string): void { async function loadNextPage(requestId = openRequestId, runId = currentRunId, offset = loadedCount): Promise { if (!runId || loading || (loadedCount >= totalCount && totalCount > 0 && offset >= loadedCount)) return; loading = true; - const page = await api(`/api/traces/${encodeURIComponent(runId)}/events?offset=${offset}&limit=${PAGE_SIZE}`); - loading = false; + const page = await api(tracePath(`/api/traces/${encodeURIComponent(runId)}/events?offset=${offset}&limit=${PAGE_SIZE}`), {signal:traceController?.signal ?? null}); if (!isCurrentRequest(requestId, runId)) return; + loading = false; if (!page) { if (!selectedSeq) setRaw('Trace events could not be loaded.'); return; @@ -139,15 +149,19 @@ async function loadEventDetail(runId: string, seq: number, requestId = openReque if (!runId || !Number.isInteger(seq) || seq < 1) return; if (!isCurrentRequest(requestId, runId)) return; setRaw('Loading event...'); - const detail = await api(`/api/traces/${encodeURIComponent(runId)}/events/${seq}`); + const detail = await api(tracePath(`/api/traces/${encodeURIComponent(runId)}/events/${seq}`), {signal:traceController?.signal ?? null}); if (!isCurrentRequest(requestId, runId) || selectedSeq !== seq) return; setRaw(detail?.raw || (detail ? '(empty trace event)' : 'Trace event could not be loaded.')); } -export async function openTraceDrawer(runId: string, seq?: number): Promise { +export async function openTraceDrawer(runId: string, seq?: number, sessionId: string | null = null): Promise { const overlay = ensureDrawer(); const requestId = ++openRequestId; - const startOffset = requestedOffset(seq); + // Sequence is a sparse trace identity, not an ordinal in the retained row list. + const startOffset = 0; + traceController?.abort(); traceController = new AbortController(); + traceSession = sessionId; + returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; currentRunId = runId; loadedCount = startOffset; totalCount = 0; @@ -157,7 +171,8 @@ export async function openTraceDrawer(runId: string, seq?: number): Promise(`/api/traces/${encodeURIComponent(runId)}`); + overlay.querySelector('.trace-drawer-close')?.focus({preventScroll:true}); + const summary = await api(tracePath(`/api/traces/${encodeURIComponent(runId)}`), {signal:traceController.signal}); if (!isCurrentRequest(requestId, runId)) return; if (!summary) { setRaw('Trace is unavailable or internal-only.'); return; } renderSummary(summary); diff --git a/server.ts b/server.ts index f052f9663..398447dd4 100644 --- a/server.ts +++ b/server.ts @@ -13,6 +13,7 @@ import fs from 'fs'; import { registerBrowserRoutes } from './src/routes/browser.js'; import { registerCodeRoutes } from './src/routes/code.js'; +import { registerRuntimeRequestRoutes } from './src/routes/runtime-requests.js'; import { registerEmployeeRoutes } from './src/routes/employees.js'; import { registerHeartbeatRoutes } from './src/routes/heartbeat.js'; import { registerSkillRoutes } from './src/routes/skills.js'; @@ -473,6 +474,7 @@ app.use('/api/dashboard/schedule', requireAuth, createDashboardScheduleRouter()) registerBrowserRoutes(app, requireAuth); // ─── Code mode API (jwc resident ACP host) — see src/routes/code.js registerCodeRoutes(app, requireAuth); +registerRuntimeRequestRoutes(app, requireAuth); registerI18nRoutes(app, requireAuth, projectRoot); diff --git a/src/agent/events/claude.ts b/src/agent/events/claude.ts index 134d54ccd..059078006 100644 --- a/src/agent/events/claude.ts +++ b/src/agent/events/claude.ts @@ -2,7 +2,7 @@ import { appendBoundedFullText } from './fulltext-bound.js'; import { fieldString } from '../../types/cli-events.js'; -import { updateTraceToolRow, getTraceEvent } from '../../trace/store.js'; +import { updateTraceToolRow, getTraceToolEntry } from '../../trace/store.js'; import type { CliEventRecord } from './types.js'; import type { SpawnContext, ToolEntry } from './types.js'; import { @@ -147,6 +147,7 @@ function appendClaudeISnapshotText(ctx: SpawnContext, event: CliEventRecord): st const previous = ctx.claudeILastAssistantText || ''; ctx.claudeILastAssistantText = text; if (text === previous || previous.startsWith(text)) return ''; + ctx.printActivity?.message(text, 'replace', 'unknown'); if (text.startsWith(previous)) { const delta = text.slice(previous.length); { @@ -176,6 +177,7 @@ function appendClaudeISnapshotText(ctx: SpawnContext, event: CliEventRecord): st if (messageId) ctx.claudeILastAssistantId = messageId; else delete ctx.claudeILastAssistantId; ctx.claudeILastAssistantText = text; + ctx.printActivity?.message(text, 'replace', 'unknown'); return appendAssistantTextSegment(ctx, text); } @@ -192,6 +194,7 @@ function appendClaudeISnapshotText(ctx: SpawnContext, event: CliEventRecord): st * (spawn.ts gates that to kiro-plain/agy/pi), and this mirrors the same defensive * check the reconcile path already carries. */ export function resetClaudeDurableMessage(ctx: SpawnContext): void { + ctx.printActivity?.nextMessage(); ctx.fullText = ''; if (ctx.liveOutputText !== undefined) ctx.liveOutputText = ''; ctx.outputTextStarted = false; @@ -264,6 +267,7 @@ export function handleClaudeEvent( const hasCanonicalText = evt.message.content.some( (block) => block.type === 'text' && block.text, ); + if (hasCanonicalText) ctx.printActivity?.message(extractAssistantText(evt), 'replace', 'unknown'); if (ctx.claudeStreamedTextStart !== undefined && hasCanonicalText) { const useLive = ctx.liveOutputText !== undefined; const target = useLive ? ctx.liveOutputText! : ctx.fullText; @@ -297,6 +301,7 @@ export function handleClaudeEvent( if (fallbackId) ctx.claudeILastAssistantId = fallbackId; for (const block of evt.message.content) { if (block.type === 'text') { + if (block.text) ctx.printActivity?.message(block.text, 'append', 'unknown'); const segment = appendAssistantTextSegment(ctx, block.text); ctx.pendingOutputChunk = (ctx.pendingOutputChunk || '') + segment; } @@ -339,16 +344,13 @@ export function handleClaudeEvent( // status (WP4, devlog 260703 doc 12 item 3). const pointer = ctx.toolTraceIndex?.get(`claude:tooluse:${block.tool_use_id}`); if (pointer) { - let base: Partial = {}; - const row = getTraceEvent(pointer.traceRunId, pointer.traceSeq); - if (row?.raw) { - try { base = JSON.parse(row.raw) as Partial; } catch { /* keep minimal base */ } - } + const base = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq); const resultText = extractText(block.content); const merged: ToolEntry = { toolType: 'tool', label: 'tool', ...base, + stepRef: `claude:tooluse:${block.tool_use_id}`, icon: block["is_error"] ? '❌' : '✅', status: block["is_error"] ? 'error' : 'done', traceRunId: pointer.traceRunId, @@ -360,6 +362,7 @@ export function handleClaudeEvent( : resultText; } updateTraceToolRow(merged); + ctx.printActivity?.tool(merged); } } } diff --git a/src/agent/events/codex.ts b/src/agent/events/codex.ts index 3edaac540..d934fb62f 100644 --- a/src/agent/events/codex.ts +++ b/src/agent/events/codex.ts @@ -25,6 +25,9 @@ export function handleCodexEvent( if (evt.item?.type === 'agent_message') { const text = String(evt.item.text || ''); const channel = evt.item?.['channel'] || (evt.item?.['annotations'] as Record | undefined)?.['channel']; + ctx.printActivity?.nextMessage(); + ctx.printActivity?.message(text, 'replace', + channel === 'final' ? 'final' : channel === 'commentary' ? 'commentary' : 'unknown'); // Commentary-channel messages are transient progress updates — do NOT // persist them in fullText so they stay out of agent_done and therefore // out of Slack/Telegram/Discord delivery. diff --git a/src/agent/events/cursor.ts b/src/agent/events/cursor.ts index 5eaefc99c..1e3adefc5 100644 --- a/src/agent/events/cursor.ts +++ b/src/agent/events/cursor.ts @@ -1,6 +1,7 @@ // Cursor CLI stream-json adapter. import { stripUndefined } from '../../core/strip-undefined.js'; +import { getTraceToolEntry, updateTraceToolRow } from '../../trace/store.js'; import { asCliEventRecord, fieldNumber, fieldString } from '../../types/cli-events.js'; import type { CliEventRecord, SpawnContext, ToolEntry } from './types.js'; import { @@ -68,6 +69,7 @@ function appendCursorAssistantText(ctx: SpawnContext, event: CliEventRecord): st // observed failures are all the first shape. Cumulative snapshot growth (the // prefix case) is unaffected. if (cursorStartsNewAssistantMessage(ctx, event, text, isDelta)) { + ctx.printActivity?.nextMessage(); ctx.fullText = ''; ctx.outputTextStarted = false; // Drop the dedupe baseline too. It describes the message being replaced, and @@ -88,6 +90,7 @@ function appendCursorAssistantText(ctx: SpawnContext, event: CliEventRecord): st : (text.startsWith(previous) ? text.slice(previous.length) : text); if (!segmentText) return ''; + ctx.printActivity?.message(segmentText, 'append', 'unknown'); ctx.cursorAssistantText = isDelta ? `${previous}${text}` : text; return appendAssistantTextSegment(ctx, segmentText); } @@ -185,14 +188,50 @@ function emitCursorTool( tool: ToolEntry, ): void { const key = [tool.icon, tool.label, tool.stepRef || '', tool.status || ''].join(':'); - if (ctx.seenToolKeys?.has(key)) return; - ctx.seenToolKeys?.add(key); - const existingIdx = tool.stepRef && (tool.status === 'done' || tool.status === 'error') - ? ctx.toolLog.findIndex((entry) => entry.stepRef === tool.stepRef && entry.status === 'running') + const existingIdx = tool.stepRef + ? ctx.toolLog.findIndex((entry) => entry.stepRef === tool.stepRef) : -1; + let prior = ctx.toolLog[existingIdx]; + const pointer = tool.stepRef ? ctx.toolTraceIndex?.get(tool.stepRef) : undefined; + if (!prior && pointer) { + prior = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq) ?? undefined; + } + // Late start snapshots must not reopen a completed tool, even with changed detail. + if (['done', 'error', 'stopped'].includes(prior?.status || '') + && !['done', 'error', 'stopped'].includes(tool.status || '')) return; + if (ctx.seenToolKeys?.has(key) && (!prior || prior.detail === tool.detail)) return; + ctx.seenToolKeys?.add(key); + // Admission precedes all text/message-boundary effects as well as tool writes. + // LAST-WINS across tool boundaries: assistant text that arrived BEFORE + // a tool ran is planning narration ("경계를 먼저 확인한 뒤 ..."), not part + // of the final answer. Cursor stream-json has no channel tags, so the + // tool boundary is the only reliable seam — discard the durable + // accumulation when a NEW tool starts and keep only post-last-tool text. + // Only on 'running' (tool start): a late completion update arriving + // after the answer began must not wipe answer text. The delta/snapshot + // dedupe state (cursorAssistantText) is deliberately NOT reset, so a + // cumulative end-of-turn snapshot still dedupes to nothing instead of + // re-ingesting the discarded narration. Live UI keeps the narration via + // pendingOutputChunk/agent_output; only fullText (=agent_done → external + // channels) is affected. + if (tool.status === 'running' && (ctx.fullText || ctx.outputTextStarted)) { + ctx.printActivity?.nextMessage(); + ctx.fullText = ''; + ctx.outputTextStarted = false; + } + const traceRunId = pointer?.traceRunId ?? prior?.traceRunId; + const traceSeq = pointer?.traceSeq ?? prior?.traceSeq; + if (traceRunId && traceSeq) { + tool.traceRunId = traceRunId; + tool.traceSeq = traceSeq; + tool.detailAvailable = ctx.traceAudience !== 'internal'; + if (ctx.traceAudience === 'internal') tool.rawRetentionStatus = 'internal'; + else if (prior?.rawRetentionStatus !== undefined) tool.rawRetentionStatus = prior.rawRetentionStatus; + } if (existingIdx >= 0) ctx.toolLog[existingIdx] = tool; else ctx.toolLog.push(tool); syncLiveTools(ctx); + updateTraceToolRow(tool); emitAgentTool(ctx, agentLabel, tool, empTag); } @@ -221,22 +260,6 @@ export function handleCursorEvent( } if (event.type === 'tool_call') { - // LAST-WINS across tool boundaries: assistant text that arrived BEFORE - // a tool ran is planning narration ("경계를 먼저 확인한 뒤 ..."), not part - // of the final answer. Cursor stream-json has no channel tags, so the - // tool boundary is the only reliable seam — discard the durable - // accumulation when a NEW tool starts and keep only post-last-tool text. - // Only on 'running' (tool start): a late completion update arriving - // after the answer began must not wipe answer text. The delta/snapshot - // dedupe state (cursorAssistantText) is deliberately NOT reset, so a - // cumulative end-of-turn snapshot still dedupes to nothing instead of - // re-ingesting the discarded narration. Live UI keeps the narration via - // pendingOutputChunk/agent_output; only fullText (=agent_done → external - // channels) is affected. - if (cursorToolStatus(event) === 'running' && (ctx.fullText || ctx.outputTextStarted)) { - ctx.fullText = ''; - ctx.outputTextStarted = false; - } emitCursorTool(ctx, agentLabel, empTag, cursorToolLabel(event)); } @@ -258,7 +281,9 @@ export function handleCursorEvent( status: 'error', }); } else if (!ctx.fullText && typeof event["result"] === 'string') { - const segment = appendAssistantTextSegment(ctx, normalizeAssistantDisplayText(event["result"])); + const text = normalizeAssistantDisplayText(event["result"]); + ctx.printActivity?.message(text, 'append', 'unknown'); + const segment = appendAssistantTextSegment(ctx, text); ctx.pendingOutputChunk = (ctx.pendingOutputChunk || '') + segment; } } diff --git a/src/agent/events/grok.ts b/src/agent/events/grok.ts index 999b743d5..4e01a5f97 100644 --- a/src/agent/events/grok.ts +++ b/src/agent/events/grok.ts @@ -1,6 +1,7 @@ // Grok CLI event adapter import { appendBoundedFullText } from './fulltext-bound.js'; +import { getTraceToolEntry, updateTraceToolRow } from '../../trace/store.js'; import { asCliEventRecord, fieldString, @@ -21,13 +22,24 @@ const GROK_THINKING_UPDATE_MIN_MS = 750; const GROK_THINKING_UPDATE_MIN_CHARS = 240; const GROK_THOUGHT_BUFFER_MAX = 102_400; +function findGrokTool(ctx: SpawnContext, ref: string): ToolEntry | undefined { + const existing = [...ctx.toolLog].reverse().find(t => t.stepRef === ref); + if (existing) return existing; + const pointer = ctx.toolTraceIndex?.get(ref); + if (!pointer) return undefined; + const base = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq); + return { icon: '🔧', label: 'tool', toolType: 'tool', ...base, stepRef: ref, + traceRunId: pointer.traceRunId, traceSeq: pointer.traceSeq, + detailAvailable: ctx.traceAudience !== 'internal', + ...(ctx.traceAudience === 'internal' ? { rawRetentionStatus: 'internal' as const } : {}), + }; +} + function findGrokThinkingTool(ctx: SpawnContext): ToolEntry | undefined { const currentRef = ctx.grokCurrentThoughtRef; if (currentRef) { - const current = [...ctx.toolLog].reverse().find( - (t: ToolEntry) => t.stepRef === currentRef && (!t.status || t.status === 'running') - ); - if (current) return current; + const current = findGrokTool(ctx, currentRef); + if (current && (!current.status || current.status === 'running')) return current; } return [...ctx.toolLog].reverse().find( (t: ToolEntry) => t.stepRef?.startsWith(GROK_THINKING_STEP_REF) && (!t.status || t.status === 'running') @@ -69,6 +81,7 @@ function ensureGrokThinkingProgress( if (existing) { existing.label = label; if (trimmed) existing.detail = trimmed; + updateTraceToolRow(existing); if (!shouldEmitGrokThinkingUpdate(ctx, trimmed)) return; syncLiveTools(ctx); emitAgentTool(ctx, agentLabel, existing, empTag); @@ -107,6 +120,7 @@ function finalizeGrokThinkingProgress( existing.detail = trimmed; } syncLiveTools(ctx); + updateTraceToolRow(existing); emitAgentTool(ctx, agentLabel, existing, empTag); delete ctx.grokCurrentThoughtRef; delete ctx.grokLastThoughtEmitAt; @@ -126,6 +140,7 @@ function finalizeAllGrokThinkingProgress( for (const thought of runningThoughts) { thought.status = 'done'; syncLiveTools(ctx); + updateTraceToolRow(thought); emitAgentTool(ctx, agentLabel, thought, empTag); } delete ctx.grokCurrentThoughtRef; @@ -238,14 +253,18 @@ function handleGrokToolEvent( ); if (startsTool && !endsTool) { - const existing = [...ctx.toolLog].reverse().find((t: ToolEntry) => t.stepRef === ref); + const existing = findGrokTool(ctx, ref); if (existing) { + // Includes rows recovered after RAM eviction; reject before mutation. + if (['done', 'error', 'stopped'].includes(existing.status || '')) return true; existing.icon = '🔧'; existing.label = buildPreview(name, 80) || existing.label || 'tool'; existing.toolType = 'tool'; existing.status = 'running'; if (detail) existing.detail = detail; + if (!ctx.toolLog.includes(existing)) ctx.toolLog.push(existing); syncLiveTools(ctx); + updateTraceToolRow(existing); emitAgentTool(ctx, agentLabel, existing, empTag); return true; } @@ -264,7 +283,7 @@ function handleGrokToolEvent( return true; } - const existing = [...ctx.toolLog].reverse().find((t: ToolEntry) => t.stepRef === ref); + const existing = findGrokTool(ctx, ref); const doneTool = existing || { icon: isError ? '❌' : '✅', label: buildPreview(name, 80) || 'tool', @@ -275,8 +294,9 @@ function handleGrokToolEvent( doneTool.label = buildPreview(name, 80) || doneTool.label || 'tool'; doneTool.status = isError ? 'error' : 'done'; if (detail) doneTool.detail = detail; - if (!existing) ctx.toolLog.push(doneTool); + if (!ctx.toolLog.includes(doneTool)) ctx.toolLog.push(doneTool); syncLiveTools(ctx); + updateTraceToolRow(doneTool); emitAgentTool(ctx, agentLabel, doneTool, empTag); pushTrace(ctx, `[${agentLabel}] grok tool ${isError ? 'error' : 'done'}: ${name}`); return true; @@ -318,6 +338,7 @@ export function handleGrokEvent( } if (evt.type === 'thought') { const text = String(evt.data || evt.text || ''); + if (text) ctx.printActivity?.reasoning(text, 'append'); const buf = (ctx.grokThoughtBuf || '') + text; ctx.grokThoughtBuf = buf.length > GROK_THOUGHT_BUFFER_MAX ? buf.slice(-GROK_THOUGHT_BUFFER_MAX) : buf; ensureGrokThinkingProgress(ctx, agentLabel, empTag, ctx.grokThoughtBuf); @@ -326,6 +347,7 @@ export function handleGrokEvent( if (evt.type === 'text') { const text = String(evt.data || evt.text || ''); if (text) { + ctx.printActivity?.message(text, 'append', 'unknown'); finalizeGrokThinkingProgress(ctx, agentLabel, empTag, ctx.grokThoughtBuf); ctx.grokThoughtBuf = ''; { diff --git a/src/agent/events/helpers.ts b/src/agent/events/helpers.ts index 1d8a6cd10..b86725c76 100644 --- a/src/agent/events/helpers.ts +++ b/src/agent/events/helpers.ts @@ -50,10 +50,19 @@ export function emitAgentTool( tool: object, empTag: Record, ): void { + // Legacy parser entries are already accepted ToolEntry projections. Keep this + // observation separate from their unchanged messaging/progress payload. + const entry = tool as Partial; + if (typeof entry.icon === 'string' && typeof entry.label === 'string' && typeof entry.toolType === 'string' + && entry.toolType !== 'thinking' && entry.icon !== '💭' && entry.icon !== '💬') { + ctx.printActivity?.tool({ ...entry, icon: entry.icon, label: entry.label, toolType: entry.toolType }); + } const payload = { agentId: agentLabel, ...tool, ...empTag, + ...(ctx.traceRunId ? { traceRunId: ctx.traceRunId } : {}), + ...(ctx.activityIdentity ?? {}), startedAt: ctx.runStartedAt, // Who this event belongs to. A subscriber that cannot answer that question // has to take every agent_tool on the bus, which is how one channel's diff --git a/src/agent/events/index.ts b/src/agent/events/index.ts index 281775052..e55a9afeb 100644 --- a/src/agent/events/index.ts +++ b/src/agent/events/index.ts @@ -27,7 +27,7 @@ import { updateTraceToolRow } from '../../trace/store.js'; import { handleCodexEvent } from './codex.js'; import { handleCursorEvent } from './cursor.js'; import { handleGrokEvent } from './grok.js'; -import { handleOpenCodeEvent } from './opencode.js'; +import { handleOpenCodeEvent, refreshOpenCodeTool } from './opencode.js'; import { extractToolLabels } from './tool-labels.js'; export function extractSessionId(cli: string, event: CliEventRecord): string | null { @@ -158,6 +158,7 @@ export function extractFromEvent(cli: string, event: CliEventRecord, ctx: SpawnC // Buffer thinking deltas if (inner?.type === 'content_block_delta' && inner.delta?.type === 'thinking_delta') { + if (inner.delta.thinking) ctx.printActivity?.reasoning(inner.delta.thinking, 'append'); if (!ctx.claudeThinkingBuf) ctx.claudeThinkingBuf = ''; ctx.claudeThinkingBuf += inner.delta.thinking || ''; ctx.claudeThinkingHadDelta = true; @@ -182,6 +183,7 @@ export function extractFromEvent(cli: string, event: CliEventRecord, ctx: SpawnC } const seg = appendAssistantRawText(ctx, deltaText); if (seg) { + ctx.printActivity?.message(deltaText, 'append', 'unknown'); // Arm the per-message guard only when real text flowed: an all-empty // text_delta run must NOT skip the complete-block fallback (else its // prose would be dropped). @@ -316,6 +318,7 @@ export function extractFromEvent(cli: string, event: CliEventRecord, ctx: SpawnC const toolLabels = extractToolLabels(cli, event, ctx); for (const toolLabel of toolLabels) { + if (cli === 'opencode' && refreshOpenCodeTool(ctx, agentLabel, empTag, toolLabel)) continue; // Dedupe: same logic as ACP path — skip already-seen tool keys const key = [ toolLabel.icon, @@ -325,6 +328,13 @@ export function extractFromEvent(cli: string, event: CliEventRecord, ctx: SpawnC ].join(':'); if (ctx.seenToolKeys && ctx.seenToolKeys.has(key)) continue; if (ctx.seenToolKeys) ctx.seenToolKeys.add(key); + // Complete reasoning cards have no delta hook. Observe accepted plaintext + // once here; the shared tool emitter deliberately skips synthetic cards. + if (toolLabel.toolType === 'thinking' && toolLabel.detail + && ((cli === 'codex' && event.item?.type === 'reasoning') + || (isClaudeLikeCli(cli) && event.type === 'assistant' && !ctx.hasClaudeStreamEvents))) { + ctx.printActivity?.reasoning(toolLabel.detail, 'replace'); + } // Resolve running → done/error: replace existing running entry in toolLog if (toolLabel.stepRef && (toolLabel.status === 'done' || toolLabel.status === 'error')) { diff --git a/src/agent/events/opencode.ts b/src/agent/events/opencode.ts index d1ce196f9..75ef42590 100644 --- a/src/agent/events/opencode.ts +++ b/src/agent/events/opencode.ts @@ -1,6 +1,7 @@ // OpenCode CLI event adapter import { asCliEventRecord } from '../../types/cli-events.js'; +import { getTraceToolEntry, updateTraceToolRow } from '../../trace/store.js'; import type { CliEventRecord } from './types.js'; import type { SpawnContext, ToolEntry } from './types.js'; import { @@ -13,6 +14,51 @@ import { formatPostToolAssistantLead, } from './helpers.js'; +/** Refresh an existing OpenCode card, or consume a stale/duplicate update. + * Returns false only when the dispatcher must admit a new tool entry. */ +export function refreshOpenCodeTool( + ctx: SpawnContext, + agentLabel: string, + empTag: Record, + toolLabel: ToolEntry, +): boolean { + if (!toolLabel.stepRef) return false; + const index = ctx.toolLog.findIndex(t => t.stepRef === toolLabel.stepRef); + const pointer = ctx.toolTraceIndex?.get(toolLabel.stepRef); + let prior = ctx.toolLog[index]; + if (!prior && pointer) { + prior = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq) ?? undefined; + } + if (prior || pointer) { + // OpenCode may omit running status; do not let that stale card + // overwrite terminal detail/icon while inheriting its old status. + if (['done', 'error', 'stopped'].includes(prior?.status || '') + && !['done', 'error', 'stopped'].includes(toolLabel.status || '')) return true; + const key = [toolLabel.icon, toolLabel.label, toolLabel.stepRef, toolLabel.status || ''].join(':'); + const detail = toolLabel.detail ?? prior?.detail; + if (ctx.seenToolKeys?.has(key) && prior?.detail === detail) return true; + ctx.seenToolKeys?.add(key); + const refreshed: ToolEntry = { ...prior, ...toolLabel }; + if (detail !== undefined) refreshed.detail = detail; + if (pointer) { + refreshed.traceRunId = pointer.traceRunId; + refreshed.traceSeq = pointer.traceSeq; + refreshed.detailAvailable = ctx.traceAudience !== 'internal'; + if (ctx.traceAudience === 'internal') refreshed.rawRetentionStatus = 'internal'; + } + if (index >= 0) ctx.toolLog[index] = refreshed; + else ctx.toolLog.push(refreshed); + if ((refreshed.status === 'done' || refreshed.status === 'error') && ctx.opencodePendingToolRefs) { + ctx.opencodePendingToolRefs = ctx.opencodePendingToolRefs.filter(ref => ref !== refreshed.stepRef); + } + syncLiveTools(ctx); + updateTraceToolRow(refreshed); + emitAgentTool(ctx, agentLabel, refreshed, empTag); + return true; + } + return false; +} + function flushOpenCodeStepText( ctx: SpawnContext, agentLabel: string | undefined, @@ -67,13 +113,26 @@ function finalizeOpencodePendingTools( if (!pendingRefs.length) return; const failed = !!ctx.opencodeHadToolErrorInStep; for (const ref of pendingRefs) { - const existing = [...ctx.toolLog].reverse().find( + let existing = [...ctx.toolLog].reverse().find( (t: ToolEntry) => t.stepRef === ref && (!t.status || t.status === 'running') ); + if (!existing) { + const pointer = ctx.toolTraceIndex?.get(ref); + if (pointer) { + const base = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq); + existing = { icon: '🔧', label: 'tool', toolType: 'tool', ...base, stepRef: ref, + traceRunId: pointer.traceRunId, traceSeq: pointer.traceSeq, + detailAvailable: ctx.traceAudience !== 'internal', + ...(ctx.traceAudience === 'internal' ? { rawRetentionStatus: 'internal' as const } : {}), + }; + } + } if (!existing) continue; + if (existing.status && existing.status !== 'running') continue; existing.status = failed ? 'error' : 'done'; existing.icon = failed ? '❌' : '✅'; syncLiveTools(ctx); + updateTraceToolRow(existing); emitAgentTool(ctx, agentLabel, existing, empTag); } } @@ -114,6 +173,7 @@ export function handleOpenCodeEvent( return; } if (evt.type === 'step_start') { + ctx.printActivity?.nextMessage(); const model = evt.part?.model || evt.model; if (model) ctx.model = model; // LAST-STEP-WINS (NARRATION-BOUNDARY-01): a NEW step means the text the @@ -139,6 +199,7 @@ export function handleOpenCodeEvent( if (evt.type === 'reasoning') { const text = String(evt.part?.text || evt.text || '').trim(); if (text) { + ctx.printActivity?.reasoning(text, 'replace'); const thinkingTool = { icon: '💭', label: buildPreview(text, 80) || 'thinking...', @@ -153,6 +214,7 @@ export function handleOpenCodeEvent( pushTrace(ctx, `[${agentLabel}] opencode reasoning (${text.length} chars)`); } } else if (evt.type === 'text' && evt.part?.text) { + ctx.printActivity?.message(String(evt.part.text), 'append', 'unknown'); if (ctx.opencodeSawToolInStep) { ctx.opencodePostToolText = (ctx.opencodePostToolText || '') + String(evt.part.text); } else { diff --git a/src/agent/lifecycle-handler.ts b/src/agent/lifecycle-handler.ts index fe0fd27ac..63a8285a0 100644 --- a/src/agent/lifecycle-handler.ts +++ b/src/agent/lifecycle-handler.ts @@ -20,9 +20,10 @@ import { clearLiveRun, getLiveRun } from './live-run-state.js'; import { sanitizeToolLogForDurableStorage, serializeSanitizedToolLog } from '../shared/tool-log-sanitize.js'; import { scanStructuredFence } from '../shared/structured-fence.js'; import { finalizeTraceRun, linkTraceRunToMessage } from '../trace/store.js'; +import { mergeLatestTools } from './merge-tool-log.js'; import type { TraceRunStatus } from '../trace/types.js'; import type { RuntimeEventBody, RuntimeTransport, RuntimeTurnOutcome } from '../shared/runtime-contract.js'; -import { lifecycleRuntimeOutcome, runtimeOutcomeExitCode } from './runtime/outcome.js'; +import { handoffRuntimeOutcome, lifecycleRuntimeOutcome, runtimeOutcomeExitCode } from './runtime/outcome.js'; import type { ToolEntry } from '../types/agent.js'; import type { RemoteTarget } from '../messaging/types.js'; import { resolveSpawnOutputText } from './events/helpers.js'; @@ -235,6 +236,7 @@ function runTag(ctx: { traceRunId?: string | null }): Record { export interface ExitContext { runtimeOutcome?: RuntimeTurnOutcome; + runtimeTerminalAttempted?: boolean; requestId?: string; fullText: string; /** Set when fullText hit the safety bound and later output was dropped. */ @@ -343,6 +345,8 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise const isEmployee = !mainManaged; const empTag = isEmployee ? { isEmployee: true } : {}; const liveScope = ctx.liveScope || 'default'; + const ownsLiveRun = () => nativeOutcome === undefined + || (typeof ctx.traceRunId === 'string' && getLiveRun(liveScope).traceRunId === ctx.traceRunId); const traceStatus = nativeOutcome === undefined ? code === 0 ? 'done' : wasKilled ? 'interrupted' : 'error' : nativeOutcome.status === 'stopped' ? 'interrupted' : nativeOutcome.status; @@ -363,8 +367,10 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise }); } catch { console.warn('[runtime:projection] lifecycle observer failed'); } } - if (nativeOutcome === undefined) finalizeTraceRun(ctx.traceRunId, status, error); - else { + if (nativeOutcome === undefined) { + try { finalizeTraceRun(ctx.traceRunId, status, error); } + catch { console.warn('[trace] print finalization failed'); } + } else { try { finalizeTraceRun(nativeTraceRunId, status, error); } catch { console.warn('[runtime] outcome trace finalization failed'); } } @@ -464,7 +470,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise // entry — and a generation that had already expired could still trigger it. // ─── CLI-native compact → auto session refresh (awaited to avoid race with processQueue) ─── - if (ctx.cliNativeCompactDetected && mainManaged && !opts.internal) { + if (nativeOutcome === undefined && ctx.cliNativeCompactDetected && mainManaged && !opts.internal) { console.log('[jaw:compact] CLI-native compaction detected — auto-refreshing session'); try { const { autoCompactRefresh } = await import('../core/compact.js'); @@ -511,7 +517,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise // ─── Phase 54-A: Proactive compact by turn count ─── // CLIs without a reliable native compact/resume path get a conservative // turn-count refresh. AGY owns its compaction and keeps its conversation. - if (mainManaged && !opts.internal && code === 0 && !ctx.cliNativeCompactDetected) { + if (nativeOutcome === undefined && mainManaged && !opts.internal && code === 0 && !ctx.cliNativeCompactDetected) { const turns = ctx.turns ?? memoryFlushCounter; const useTurnCountRefresh = shouldUseTurnCountRefresh(runtimeCli); if (useTurnCountRefresh && turns >= 35) { @@ -542,7 +548,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise // ─── High-turn native-compaction coordination ─── // AGY keeps a native compacted conversation. Other CLIs still use the // conservative fresh-session guard when their compaction is not observable. - if (mainManaged && !opts.internal && code === 0 && !ctx.cliNativeCompactDetected) { + if (nativeOutcome === undefined && mainManaged && !opts.internal && code === 0 && !ctx.cliNativeCompactDetected) { const turns = ctx.turns ?? memoryFlushCounter; if (shouldClearHighTurnSessionBucket(runtimeCli, turns)) { console.log(`[jaw:compact] ${cli} exited after ${turns} turns — clearing session bucket for fresh start`); @@ -565,7 +571,7 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise // cleared here and nowhere else. Same key space as the write (#519). if (code === 0) clearRuntimeCooldown(runtimeCli); - if (code === 0 && runtimeCli === 'grok') { + if (nativeOutcome === undefined && code === 0 && runtimeCli === 'grok') { const recovered = backfillGrokTraceTools(ctx); if (recovered > 0) { console.log(`[jaw:grok] recovered ${recovered} tool event(s) from Grok trace export`); @@ -640,14 +646,9 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise if (nativeOutcome !== undefined) { let finalContent = nativeOutcome.finalText; if (mainManaged && !opts.internal) { - const seen = new Set(); - const combined: unknown[] = []; - for (const tool of [...ctx.toolLog, ...getLiveRun(liveScope).toolLog]) { - if (tool.stepRef && seen.has(tool.stepRef)) continue; - if (tool.stepRef) seen.add(tool.stepRef); - combined.push(tool); - } - const safeTools = sanitizeToolLogForDurableStorage(combined); + const safeTools = sanitizeToolLogForDurableStorage( + mergeLatestTools(ctx.toolLog, ownsLiveRun() ? getLiveRun(liveScope).toolLog : [], nativeTraceRunId || ''), + ); if (finalContent !== null) { finalContent = applyOutputPolicy(finalContent, { scope: 'main' }).text; evaluateRecordPending(ctx.toolLog, finalContent); @@ -675,6 +676,8 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise const errorKind = failed ? classifyExitError(runtimeCli, code ?? 1, ctx.stderrBuf).errorKind : undefined; // Even absent/empty finals must terminate existing UI/collectors. // Only compatibility text collapses whitespace; never the outcome. + handoffRuntimeOutcome(ctx, { ...nativeOutcome, finalText: finalContent }); + ctx.runtimeTerminalAttempted = true; broadcast('agent_done', { ...(nativeTraceRunId ? { traceRunId: nativeTraceRunId } : {}), text: runtimeCompatibilityText(finalContent), @@ -773,21 +776,9 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise }); } const liveRun = getLiveRun(liveScope); - // Union, not pick-one: boss tools (ctx.toolLog) + worker mirrors (liveRun.toolLog, - // preserved across syncs by replaceLiveRunTools). Key on stepRef (durable, never - // stripped by the sanitizer); identity-less entries append (no false dedup). The - // old ternary kept only the longer array and discarded the other, dropping worker - // mirrors whenever the boss tool log was longer (claude). (devlog 260620 R1.) - const unionSeen = new Set(); - const unionToolLog: unknown[] = []; - const pushUnionTool = (t: { stepRef?: unknown }): void => { - const ref = typeof t.stepRef === 'string' && t.stepRef ? t.stepRef : null; - if (ref) { if (unionSeen.has(ref)) return; unionSeen.add(ref); } - unionToolLog.push(t); - }; - for (const t of ctx.toolLog) pushUnionTool(t); - for (const t of liveRun.toolLog) pushUnionTool(t); - const sanitizedToolLog = sanitizeToolLogForDurableStorage(unionToolLog); + const sanitizedToolLog = sanitizeToolLogForDurableStorage( + mergeLatestTools(ctx.toolLog, liveRun.toolLog, ctx.traceRunId || ''), + ); const toolLogJson = serializeSanitizedToolLog(sanitizedToolLog); const info = insertMessageWithTraceRun.run( 'assistant', finalContent, cli, model, @@ -795,7 +786,10 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise ctx.traceRunId || null, chatSessionId, ); const messageId = Number(info.lastInsertRowid || 0); - if (ctx.traceRunId && Number.isInteger(messageId) && messageId > 0) linkTraceRunToMessage(ctx.traceRunId, messageId); + if (ctx.traceRunId && Number.isInteger(messageId) && messageId > 0) { + try { linkTraceRunToMessage(ctx.traceRunId, messageId); } + catch { console.warn('[trace] print link failed'); } + } broadcast('agent_done', { ...runTag(ctx), text: finalContent, toolLog: sanitizedToolLog, origin, ...empTag, ...(wasSteer ? { steered: true } : {}) }); if (opts._heartbeatAnchorId) { @@ -1196,8 +1190,9 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise traceStatus, traceStatus === 'error' ? classifyExitError(runtimeCli, resolvedCode ?? 1, ctx.stderrBuf).message : null, ); - if (mainManaged && !wasSteer) clearLiveRun(liveScope); - if (!opts.internal && !wasSteer) { + const liveOwnedAtFinish = ownsLiveRun(); + if (mainManaged && !wasSteer && liveOwnedAtFinish) clearLiveRun(liveScope); + if (!opts.internal && !wasSteer && liveOwnedAtFinish) { broadcast('agent_status', { status: (resolvedCode === 0 || resolvedCode === null) ? 'done' : 'error', agentId: agentLabel, diff --git a/src/agent/merge-tool-log.ts b/src/agent/merge-tool-log.ts new file mode 100644 index 000000000..85be43cce --- /dev/null +++ b/src/agent/merge-tool-log.ts @@ -0,0 +1,78 @@ +import type { ToolEntry } from '../types/agent.js'; +import { isToolLogOverflowMarker, type SanitizedToolLogEntry } from '../shared/tool-log-sanitize.js'; + +function toolIdentity(tool: ToolEntry, fallbackRunId: string): string | undefined { + // A boss run cannot supply the missing identity of a worker's child run. + const runId = tool.traceRunId || (tool.isEmployee ? '' : fallbackRunId); + if (!runId) return undefined; + if (tool.stepRef) return JSON.stringify([runId, 'ref', tool.stepRef]); + if (Number.isSafeInteger(tool.traceSeq) && tool.traceSeq! > 0) { + return JSON.stringify([runId, 'seq', tool.traceSeq]); + } + return undefined; +} + +const TERMINAL_STATUSES = new Set(['done', 'error', 'failed', 'completed', 'cancelled', 'canceled', 'interrupted', 'stopped']); + +function statusRank(tool: ToolEntry): number { + if (tool.status && TERMINAL_STATUSES.has(tool.status)) return 2; + return tool.status === 'running' ? 1 : 0; +} + +function latestTool(prior: ToolEntry, incoming: ToolEntry, preferIncoming: boolean): ToolEntry { + const difference = statusRank(incoming) - statusRank(prior); + const incomingWins = difference > 0 || (difference === 0 && preferIncoming); + const [winner, other] = incomingWins ? [incoming, prior] : [prior, incoming]; + const detail = winner.detail !== undefined ? winner.detail : other.detail; + return { + ...other, + ...winner, + // Empty is an explicit clear; missing detail can borrow the other snapshot. + ...(detail !== undefined ? { detail } : {}), + }; +} + +function foldSource(entries: readonly (ToolEntry | SanitizedToolLogEntry)[], fallbackRunId: string): ToolEntry[] { + const result: ToolEntry[] = []; + const positions = new Map(); + for (const entry of entries) { + if (isToolLogOverflowMarker(entry)) continue; + const tool: ToolEntry = { ...entry, toolType: entry.toolType ?? 'tool' }; + const key = toolIdentity(tool, fallbackRunId); + const position = key === undefined ? undefined : positions.get(key); + if (position === undefined) { + if (key !== undefined) positions.set(key, result.length); + result.push(tool); + } else { + result[position] = latestTool(result[position]!, tool, true); + } + } + return result; +} + +/** Latest content within each source; primary wins ties across sources. Positions + * stay primary-first, then mirror-only. Entries without a scoped identity remain + * distinct: labels and array positions are never used to infer tool ownership. + * Omission markers are separate from tools: keep one authoritative marker at the + * head so the consumer's sanitizer can absorb it and account for any new caps. */ +export function mergeLatestTools( + primary: readonly ToolEntry[], + mirrors: readonly SanitizedToolLogEntry[], + fallbackRunId: string, +): ToolEntry[] { + const marker = primary.find(isToolLogOverflowMarker) ?? mirrors.find(isToolLogOverflowMarker); + const result = foldSource(primary, fallbackRunId); + const positions = new Map(); + result.forEach((tool, position) => { + const key = toolIdentity(tool, fallbackRunId); + if (key !== undefined) positions.set(key, position); + }); + for (const tool of foldSource(mirrors, fallbackRunId)) { + const key = toolIdentity(tool, fallbackRunId); + const position = key === undefined ? undefined : positions.get(key); + if (position === undefined) result.push(tool); + else result[position] = latestTool(result[position]!, tool, false); + } + // Source omission counts may overlap; never invent a sum across snapshots. + return marker ? [{ ...marker, toolType: marker.toolType ?? 'tool' }, ...result] : result; +} diff --git a/src/agent/native-runtime-run.ts b/src/agent/native-runtime-run.ts new file mode 100644 index 000000000..9a702890a --- /dev/null +++ b/src/agent/native-runtime-run.ts @@ -0,0 +1,207 @@ +import type { ChildProcess } from 'node:child_process'; +import type { NativeRuntimeSession, RuntimePrompt } from './runtime/session.js'; +import type { RuntimeEvent, RuntimeTurnOutcome } from '../shared/runtime-contract.js'; + +export interface NativeRunLease { + child: ChildProcess; + session: NativeRuntimeSession; + release(): void; + /** Quarantine this exact lease synchronously, before awaiting physical close. */ + retire(reason: Error): Promise; +} + +export interface NativeRunHost { + prompt: RuntimePrompt; + turnId: string; + acquire(signal: AbortSignal): Promise; + isCurrent(): boolean; + /** Must undo partial attachment itself if it throws. */ + ready(lease: NativeRunLease): void | (() => void); + event(event: RuntimeEvent): void; + settle(lease: NativeRunLease | null, outcome: RuntimeTurnOutcome, diagnostic: string | null): Promise; + failed(error: unknown, lease: NativeRunLease | null, outcome: RuntimeTurnOutcome): R | Promise; + finalized(): void; +} + +/** Private failure boundary: callers must not retry lifecycle or inference. */ +export class NativeRunFailure extends Error { + readonly outcome: RuntimeTurnOutcome; + constructor(outcome: RuntimeTurnOutcome, cause: unknown) { + super('Native runtime run failed', { cause }); + this.name = 'NativeRunFailure'; + this.outcome = Object.freeze({ ...outcome }); + } +} + +// Same budgets as ACP's default drain and physical reap. These bound host +// contract violations, not inference or the existing logical lifecycle. +const HOST_CLEANUP_MS = 5_000; +const RETIRE_MS = 6_000; + +async function bounded(operation: PromiseLike | T, ms: number, stage: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([operation, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`native_run_${stage}_timeout`)), ms); + })]); + } finally { if (timer) clearTimeout(timer); } +} + +/** One logical turn, one captured lease, and one application settlement. */ +export function runNativeRuntime(host: NativeRunHost): { done: Promise; cancel(reason?: string): void } { + const controller = new AbortController(); + const turnId = host.turnId; + const prompt = host.prompt; + let lease: NativeRunLease | null = null; + let cleanup: (() => void) | undefined; + let cancellation: Promise | null = null; + let cancelOpen = true; + let eventsOpen = false; + let retired = false; + let failedAttempted = false; + let sendResolved = false; + let result: { value: R } | undefined; + let outcome: RuntimeTurnOutcome = { status: 'error', finalText: null, partialText: '' }; + let diagnostic: string | null = null; + const faults: Array<{ stage: string; error: unknown }> = []; + + function remember(stage: string, error: unknown): void { + // Observers can throw once per frame: retain only the first per stage. + if (!faults.some(fault => fault.stage === stage)) faults.push({ stage, error }); + } + + function failure(): NativeRunFailure { + return new NativeRunFailure(outcome, new AggregateError(faults.map(fault => fault.error), + faults.map(fault => fault.stage).join(', '))); + } + + function cancel(reason = 'Native runtime cancelled'): void { + if (!cancelOpen || controller.signal.aborted) return; + // Abort first, so both abort listeners and session.cancel can re-enter. + controller.abort(reason); + const captured = lease; + if (!captured) return; + cancellation = Promise.resolve().then(() => bounded(captured.session.cancel(), HOST_CLEANUP_MS, 'cancel')) + .catch(error => { remember('cancel', error); }); + } + + function stopped(): boolean { + if (!controller.signal.aborted && host.isCurrent()) return false; + outcome = { status: 'stopped', finalText: null, partialText: '' }; + diagnostic = 'Native runtime cancelled before prompt admission'; + if (lease) remember('admission', new Error('native_run_stale_or_cancelled_lease')); + return true; + } + + function event(value: RuntimeEvent): void { + if (!eventsOpen) return; + try { + if (host.isCurrent()) { + // A void-typed JS callback may still return a rejected promise. + void Promise.resolve(host.event(value)).catch(error => { remember('event', error); }); + } + } catch (error) { remember('event', error); } + } + + async function infer(): Promise { + if (stopped()) return; + lease = await host.acquire(controller.signal); + if (stopped()) return; + const session = lease.session; + if (!session.claimTurnOutcome || !session.finalizeTurn) { + throw new Error('native_run_requires_claim_and_finalize'); + } + cleanup = host.ready(lease) || undefined; + if (stopped()) return; + eventsOpen = true; + try { + // A rejection is pre-admission misuse: never read stale session partials. + outcome = Object.freeze({ ...await session.send(prompt, event) }); + sendResolved = true; + } finally { eventsOpen = false; } + if (cancellation) await cancellation; + // No live-owner check here: passive completion survives main-slot release. + let claimed: RuntimeTurnOutcome | null; + try { claimed = session.claimTurnOutcome(turnId); } + catch (error) { + outcome = { status: 'error', finalText: null, partialText: outcome.partialText }; + throw error; + } + if (claimed === null) { + outcome = { status: 'error', finalText: null, partialText: outcome.partialText }; + diagnostic = 'Native runtime outcome claim failed'; + remember('claim', new Error('native_run_outcome_claim_failed')); + } else outcome = Object.freeze({ ...claimed }); + if (!diagnostic && faults.some(fault => fault.stage === 'event')) { + diagnostic = 'Native runtime event observer failed'; + } + } + + async function reportFailure(): Promise { + if (failedAttempted) return; + failedAttempted = true; + try { + const value = await bounded(host.failed(failure(), lease, outcome), HOST_CLEANUP_MS, 'failed'); + // Bookkeeping cannot replace an authoritative settled result. + if (!result) result = { value }; + } catch (error) { remember('failed', error); } + } + + async function retire(): Promise { + if (!lease || retired) return; + retired = true; + try { await bounded(lease.retire(failure()), RETIRE_MS, 'retire'); } + catch (error) { remember('retire', error); } + } + + async function finish(): Promise { + try { + if (cancellation) await cancellation; + } finally { + try { + // Await even a promise returned across the void-typed JS boundary. + if (cleanup) await bounded(cleanup(), HOST_CLEANUP_MS, 'cleanup'); + } catch (error) { remember('cleanup', error); } + finally { + // Cleanup can synchronously/reentrantly cancel. Close the gate and + // observe its single captured operation, without an unbounded loop. + cancelOpen = false; + try { + if (cancellation) await cancellation; + // failed may finish pending canonical state; keep the lease + // held until that application work has been observed too. + if (faults.length) await reportFailure(); + if (faults.length) await retire(); + } finally { + try { if (lease) await bounded(lease.release(), HOST_CLEANUP_MS, 'release'); } + catch (error) { remember('release', error); await retire(); await reportFailure(); } + finally { + try { await bounded(host.finalized(), HOST_CLEANUP_MS, 'finalized'); } + catch (error) { remember('finalized', error); await retire(); await reportFailure(); } + } + } + } + } + } + + const done = Promise.resolve().then(async () => { + try { + await infer(); + result = { value: await host.settle(lease, outcome, diagnostic) }; + } catch (error) { + remember('run', error); + if (!sendResolved && controller.signal.aborted) { + outcome = { status: 'stopped', finalText: null, partialText: '' }; + } + await reportFailure(); + } finally { await finish(); } + if (faults.length) { + // Never log raw private causes (provider text/credentials may be in them). + try { console.warn('[native-runtime-run] failed stages:', faults.map(fault => fault.stage).join(', ')); } + catch (error) { remember('diagnostic', error); } + } + if (result) return result.value; + throw failure(); + }); + return { done, cancel }; +} diff --git a/src/agent/prompt-context.ts b/src/agent/prompt-context.ts index 2fccbb4c5..f5a9acc30 100644 --- a/src/agent/prompt-context.ts +++ b/src/agent/prompt-context.ts @@ -1,4 +1,9 @@ // Pure prompt assembly helpers for argv/stdin runtimes. +import type { RuntimeTransport } from '../shared/runtime-contract.js'; +import { FULLTEXT_MAX_CHARS } from './events/fulltext-bound.js'; + +export const PROMPT_HISTORY_MAX_ROWS = 10; +export const PROMPT_HISTORY_MAX_CHARS = 8000; export const HISTORY_BOUNDARY_INSTRUCTION = [ '[History Boundary]', @@ -81,6 +86,7 @@ function withOperationalContext(prompt: string, sysPrompt: string): string { export function buildPromptForArgs(input: { cli: string; effectiveProvider?: string | null; + runtimeTransport?: RuntimeTransport; prompt: string; historyBlock: string; sysPrompt: string; @@ -94,9 +100,103 @@ export function buildPromptForArgs(input: { ? withHistoryPrompt(input.prompt, input.historyBlock) : input.prompt; - if (isKiroRuntime(input.cli, input.effectiveProvider) && input.sysPrompt) { + const needsOperationalContext = isKiroRuntime(input.cli, input.effectiveProvider) + || (input.cli === 'cursor' && input.runtimeTransport === 'native'); + if (needsOperationalContext && input.sysPrompt) { return withOperationalContext(basePrompt, input.sysPrompt); } return basePrompt; } + +/** Run-owned accepted instructions; publish the next copy only after dispatch commits. */ +export interface CursorAcceptedContext { + readonly messages: readonly string[]; + readonly omitted: boolean; +} + +const CURSOR_CONTEXT_HEADER = '[Cursor redirect context - read-only]\n'; +const CURSOR_ORIGINAL_LABEL = '[Previous request - read-only context]\n'; +const CURSOR_ACCEPTED_LABEL = '[Accepted redirect - read-only context]\n'; +const CURSOR_CONTEXT_OMITTED = '[Some previous request context was omitted to fit the history limit.]'; +const CURSOR_ENTRY_SEPARATOR = '\n\n'; + +/** Clip only at a UTF-16 boundary; leave user-authored text otherwise untouched. */ +function cursorContextSlice(text: string, budget: number, suffix: boolean): string { + const length = Math.max(0, budget); + if (text.length <= length) return text; + let start = suffix ? text.length - length : 0; + let end = suffix ? text.length : length; + const splitsPair = (at: number): boolean => at > 0 && at < text.length + && text.charCodeAt(at - 1) >= 0xD800 && text.charCodeAt(at - 1) <= 0xDBFF + && text.charCodeAt(at) >= 0xDC00 && text.charCodeAt(at) <= 0xDFFF; + if (splitsPair(start)) start++; + if (splitsPair(end)) end--; + return text.slice(start, Math.max(start, end)); +} + +/** Bound retained raw instructions independently of the later serialized budget. */ +export function appendCursorAcceptedInstruction(context: CursorAcceptedContext, instruction: string): CursorAcceptedContext { + const candidates = [...context.messages, instruction]; + const first = Math.max(0, candidates.length - (PROMPT_HISTORY_MAX_ROWS - 1)); + const messages: string[] = []; + let omitted = context.omitted || first > 0; + let remaining = PROMPT_HISTORY_MAX_CHARS; + for (let i = candidates.length - 1; i >= first; i--) { + const message = candidates[i]!; + const retained = cursorContextSlice(message, remaining, true); + if (retained.length !== message.length) omitted = true; + if (retained || !message) messages.push(retained); + remaining -= retained.length; + } + return { messages: messages.reverse(), omitted }; +} + +function allocateCursorContext(original: string, accepted: CursorAcceptedContext, reserveMarker: boolean): { + entries: string[]; omitted: boolean; +} { + const entries: string[] = []; + const first = Math.max(0, accepted.messages.length - (PROMPT_HISTORY_MAX_ROWS - 1)); + let omitted = accepted.omitted || first > 0; + let remaining = PROMPT_HISTORY_MAX_CHARS - CURSOR_CONTEXT_HEADER.length + - (reserveMarker ? CURSOR_ENTRY_SEPARATOR.length + CURSOR_CONTEXT_OMITTED.length : 0); + const retain = (text: string, label: string, suffix: boolean): void => { + if (!text) return; + const overhead = label.length + (entries.length ? CURSOR_ENTRY_SEPARATOR.length : 0); + const content = cursorContextSlice(text, remaining - overhead, suffix); + if (content.length !== text.length) omitted = true; + // A label without any retained content is not a contextual entry. + if (!content) return; + entries.push(label + content); + remaining -= overhead + content.length; + }; + for (let i = accepted.messages.length - 1; i >= first; i--) { + retain(accepted.messages[i]!, CURSOR_ACCEPTED_LABEL, true); + } + retain(original, CURSOR_ORIGINAL_LABEL, false); + return { entries: entries.reverse(), omitted }; +} + +export function buildCursorReplacementPrompt(input: { + instruction: string; + originalRequest: string; + accepted: CursorAcceptedContext; + partialText: string; + sysPrompt: string; +}): string { + // A second allocation reserves the marker before choosing any content. + let context = allocateCursorContext(input.originalRequest, input.accepted, false); + if (context.omitted) { + context = allocateCursorContext(input.originalRequest, input.accepted, true); + context.entries.push(CURSOR_CONTEXT_OMITTED); + } + const historyBlock = CURSOR_CONTEXT_HEADER + context.entries.join(CURSOR_ENTRY_SEPARATOR); + const prompt = buildPromptForArgs({ + cli: 'cursor', runtimeTransport: 'native', isResume: false, + prompt: input.instruction, historyBlock, sysPrompt: input.sysPrompt, + }); + const result = withSteerContext(prompt, input.partialText); + // Same whole-input contract as AcpRuntimeSession.send: never clip current input. + if (result.length > FULLTEXT_MAX_CHARS) throw new Error('acp_runtime_prompt_unsupported'); + return result; +} diff --git a/src/agent/runtime-pool.ts b/src/agent/runtime-pool.ts index 3313d6632..867eda41d 100644 --- a/src/agent/runtime-pool.ts +++ b/src/agent/runtime-pool.ts @@ -1,5 +1,10 @@ import { CodexAppClient, isRecoverableResumeError } from './codex-app-client.js'; import { resolveCodexAppLaneKey } from './args.js'; +import { realpathSync } from 'node:fs'; +import type { SessionOwnerToken } from './session-persistence.js'; +import { createCursorSession, type CursorSessionOptions } from './runtime/acp/cursor-session.js'; +import { validateAcpSessionOptions, type AcpSession } from './runtime/acp/session.js'; +import { normalizeNativePermissions } from './runtime/acp/permissions.js'; import { normalizePiSettings, spawnPersistentPiRpc, @@ -34,13 +39,16 @@ type AcquireWaiter = { resolve: () => void; reject: (err: Error) => void; timer: NodeJS.Timeout; + cleanup?: () => void; }; type PoolEntry = - | { state: 'creating'; scopeKey: string; waiters: AcquireWaiter[]; lastUsedAt: number } + | { state: 'creating'; scopeKey: string; waiters: AcquireWaiter[]; lastUsedAt: number; + abortCreate?: () => void; nativeOwner?: SessionOwnerToken } | { state: 'ready'; scopeKey: string; runtime: R; sessionId: S | null; busy: boolean; dead: boolean; waiters: AcquireWaiter[]; lastUsedAt: number; - disposeExit: () => void }; + disposeExit: () => void; nativeOwner?: SessionOwnerToken; leaseOwner?: symbol; + cursorRetirement?: Promise }; type ReadyEntry = Extract, { state: 'ready' }>; @@ -92,7 +100,24 @@ export interface PiAcquireOptions { waitMs?: number; } -type Engine = 'codex-app' | 'pi'; +export interface CursorAcquireOptions { + key: { scopeKey: string; cwd: string; model: string; effort: string; permissions: unknown }; + binary: string; env: NodeJS.ProcessEnv; promptTimeoutMs: number; + persistenceOwner: SessionOwnerToken; + isCurrentOwner(owner: SessionOwnerToken): boolean; + canAcquire(): boolean; + storedSessionId?: string | null; + forceNew?: boolean; + waitMs?: number; + signal?: AbortSignal; + createSession?: (options: CursorSessionOptions) => Promise; +} +export interface CursorLease extends RuntimeLease { + session: AcpSession; + retire(reason?: Error): Promise; +} + +type Engine = 'codex-app' | 'pi' | 'cursor'; type AnyEntry = PoolEntry; type EngineStore = { entries: Map; @@ -132,6 +157,7 @@ function drainWaiters(entry: { waiters: AcquireWaiter[] }, outcome: 'wake' | Err const pending = entry.waiters.splice(0); for (const waiter of pending) { clearTimeout(waiter.timer); + waiter.cleanup?.(); if (outcome instanceof Error) waiter.reject(outcome); else waiter.resolve(); } @@ -139,17 +165,25 @@ function drainWaiters(entry: { waiters: AcquireWaiter[] }, outcome: 'wake' | Err function removeWaiter(entry: { waiters: AcquireWaiter[] }, id: number): void { const index = entry.waiters.findIndex((waiter) => waiter.id === id); - if (index >= 0) entry.waiters.splice(index, 1); + if (index >= 0) { + const waiter = entry.waiters.splice(index, 1)[0]!; + clearTimeout(waiter.timer); waiter.cleanup?.(); + } } -function waitForEntry(entry: { waiters: AcquireWaiter[] }, waitMs: number): Promise { +function waitForEntry(entry: { waiters: AcquireWaiter[] }, waitMs: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { + if (signal?.aborted) { reject(new Error('runtime pool acquire aborted')); return; } const id = nextWaiterId++; const timer = setTimeout(() => { removeWaiter(entry, id); reject(new Error(`runtime pool acquire timed out after ${waitMs}ms`)); }, waitMs); - entry.waiters.push({ id, resolve, reject, timer }); + const abort = () => { removeWaiter(entry, id); reject(new Error('runtime pool acquire aborted')); }; + entry.waiters.push({ id, resolve, reject, timer, + ...(signal ? { cleanup: () => signal.removeEventListener('abort', abort) } : {}) }); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); }); } @@ -169,6 +203,7 @@ function removeEntry(store: EngineStore, key: string, entry: AnyEntry, reason: E function closeEntry(store: EngineStore, key: string, entry: AnyEntry, reason: Error): void { removeEntry(store, key, entry, reason); + if (entry.state === 'creating') entry.abortCreate?.(); if (entry.state === 'ready') { void Promise.resolve(entry.runtime.close()).catch((err: unknown) => { console.warn('[runtime-pool] close failed:', (err as Error).message); @@ -581,14 +616,229 @@ export async function acquirePiRuntime(opts: PiAcquireOptions): Promise } } +type CursorManagedRuntime = ManagedRuntime & { session: AcpSession }; + +function removeRetiredCursorEntry(store: EngineStore, key: string, entry: AnyEntry): void { + if (store.entries.get(key) !== entry) return; + // A completed fence wakes acquisitions to rescan the scope, not to fail them. + drainWaiters(entry, 'wake'); + removeEntry(store, key, entry, new Error('cursor runtime retired')); +} + +function retireCursorEntry(store: EngineStore, key: string, entry: ReadyEntry, + reason: Error): Promise { + if (entry.cursorRetirement) return entry.cursorRetirement; + if (store.entries.get(key) !== entry) return Promise.resolve(); + let resolve!: () => void, reject!: (error: unknown) => void; + // Install before invoking session code: retire/close can synchronously emit exit. + entry.dead = true; + entry.cursorRetirement = new Promise((yes, no) => { resolve = yes; reject = no; }); + void entry.cursorRetirement.catch(() => { + console.warn('[runtime-pool] cursor retirement failed; awaiting captured child exit'); + }); + try { + (entry.runtime as CursorManagedRuntime).session.retire(reason); + void Promise.resolve(entry.runtime.close()).then(() => { + removeRetiredCursorEntry(store, key, entry); + resolve(); + }, reject); + } catch (error) { reject(error); } + // A rejected close remains fenced. Only observed close or this child's exit removes it. + return entry.cursorRetirement; +} + +function cursorRuntime(session: AcpSession): CursorManagedRuntime { + return { session, get alive() { return session.alive; }, supportsInterrupt: true, + close: () => session.close(), interrupt: () => session.cancel(), kill: () => session.retire(), + onExit: cb => { + const listener = (code: number | null) => cb(code); + session.child.on('exit', listener); + return () => { session.child.off('exit', listener); }; + } }; +} +function makeCursorLease(store: EngineStore, key: string, entry: ReadyEntry, reused: boolean): CursorLease { + const runtime = entry.runtime as CursorManagedRuntime; + const token = Symbol('cursor-lease'); + entry.leaseOwner = token; + let released = false; + const owns = () => store.entries.get(key) === entry && entry.leaseOwner === token; + return { runtime, session: runtime.session, sessionId: runtime.session.nativeSessionId, reused, + release() { + if (released) return; + released = true; + if (!owns()) return; + delete entry.leaseOwner; + entry.busy = false; entry.lastUsedAt = Date.now(); + if (entry.cursorRetirement || entry.dead || !runtime.alive || !runtime.session.idle) { + void retireCursorEntry(store, key, entry, new Error('cursor runtime released before idle')); + return; + } + drainWaiters(entry, 'wake'); + }, + retire(reason = new Error('cursor lease retired')) { + if (released || !owns()) return Promise.resolve(); + return retireCursorEntry(store, key, entry, reason); + }, + async cancel() { + if (released || !owns()) return; + try { await runtime.interrupt(); } + catch { + if (released || !owns()) return; + await retireCursorEntry(store, key, entry, new Error('cursor runtime cancelled and discarded')); + } + }, + }; +} + +async function createCursorEntry(store: EngineStore, key: string, creating: Extract, + opts: CursorAcquireOptions, deadline: number, check: () => void): Promise { + const controller = new AbortController(); + let rejectAbort!: (error: Error) => void; + const interrupted = new Promise((_resolve, reject) => { rejectAbort = reject; }); + void interrupted.catch(() => undefined); + let abandoned = false, installed = false, candidate: AcpSession | null = null; + const abort = (error: Error) => { + if (controller.signal.aborted) return; + controller.abort(error); rejectAbort(error); + removeEntry(store, key, creating, error); + }; + creating.abortCreate = () => abort(new Error('cursor runtime creation replaced')); + const onAbort = () => abort(new Error('cursor runtime acquire aborted')); + const timer = setTimeout(() => abort(new Error('cursor runtime acquire timed out')), Math.max(1, deadline - performance.now())); + opts.signal?.addEventListener('abort', onAbort, { once: true }); + const retire = (session: AcpSession) => { + session.retire(new Error('cursor runtime creation discarded')); + void session.close().catch(() => { console.warn('[runtime-pool] cursor creation cleanup failed'); }); + }; + const creation = Promise.resolve().then(() => { + if (opts.signal?.aborted) onAbort(); + if (controller.signal.aborted) throw new Error('cursor runtime acquire aborted'); + check(); + return (opts.createSession ?? createCursorSession)({ binary: opts.binary, env: opts.env, cwd: opts.key.cwd, + permissions: opts.key.permissions, model: opts.key.model, effort: opts.key.effort, + promptTimeoutMs: opts.promptTimeoutMs, signal: controller.signal, + ...(opts.forceNew || !opts.storedSessionId ? {} : { resumeSessionId: opts.storedSessionId }) }); + }).then(session => { candidate = session; if (abandoned) retire(session); return session; }); + try { + const session = await Promise.race([creation, interrupted]); + check(); + if (controller.signal.aborted || store.entries.get(key) !== creating || !session.alive || !session.idle) { + throw new Error('cursor runtime creation superseded'); + } + const runtime = cursorRuntime(session); + let ready!: ReadyEntry; + const disposeExit = runtime.onExit(() => { + if (store.entries.get(key) !== ready) return; + ready.dead = true; + if (ready.cursorRetirement || !ready.busy) removeRetiredCursorEntry(store, key, ready); + else drainWaiters(ready, 'wake'); + }); + ready = { state: 'ready', scopeKey: opts.key.scopeKey, runtime, sessionId: session.nativeSessionId, + busy: true, dead: false, waiters: [], lastUsedAt: Date.now(), disposeExit, + nativeOwner: { ...opts.persistenceOwner } }; + store.entries.set(key, ready); installed = true; + drainWaiters(creating, 'wake'); + return makeCursorLease(store, key, ready, false); + } catch (error) { + abandoned = true; + controller.abort(error); + if (candidate && !installed) retire(candidate); + removeEntry(store, key, creating, error instanceof Error ? error : new Error('cursor runtime creation failed')); + throw error; + } finally { + clearTimeout(timer); opts.signal?.removeEventListener('abort', onAbort); + delete creating.abortCreate; + } +} + +export async function acquireCursorRuntime(input: CursorAcquireOptions): Promise { + if (typeof input.canAcquire !== 'function') throw new Error('cursor runtime caller admission required'); + const waitMs = input.waitMs ?? DEFAULT_POOL_WAIT_MS; + if (!Number.isSafeInteger(waitMs) || waitMs <= 0 || waitMs > 2_147_483_647) throw new Error('cursor runtime invalid acquire timeout'); + validateAcpSessionOptions({ permissions: input.key.permissions, promptTimeoutMs: input.promptTimeoutMs }); + for (const value of [input.binary, input.key.scopeKey, input.key.cwd]) { + if (typeof value !== 'string' || !value) throw new Error('cursor runtime invalid key'); + } + for (const value of [input.key.model, input.key.effort]) { + if (typeof value !== 'string' || value.length > 1024) throw new Error('cursor runtime invalid key'); + } + if (![input.persistenceOwner.global, input.persistenceOwner.scope].every(value => Number.isSafeInteger(value) && value >= 0)) { + throw new Error('cursor runtime invalid ownership'); + } + const deadline = performance.now() + waitMs; + const owner = { global: input.persistenceOwner.global, scope: input.persistenceOwner.scope }; + const isCurrentOwner = input.isCurrentOwner, canAcquire = input.canAcquire, signal = input.signal; + const check = () => { + let current = false; + try { current = isCurrentOwner(owner) === true && canAcquire() === true; } catch { /* fail closed */ } + if (!current || signal?.aborted) throw new Error('cursor runtime ownership invalidated'); + if (performance.now() >= deadline) throw new Error('cursor runtime acquire timed out'); + }; + check(); + const opts: CursorAcquireOptions = { ...input, persistenceOwner: owner, + key: { ...input.key, cwd: realpathSync(input.key.cwd), permissions: normalizeNativePermissions(input.key.permissions) } }; + check(); startPoolReaper(); + const store = storeFor('cursor'); + const key = JSON.stringify(['cursor', opts.key.scopeKey, opts.key.cwd, opts.binary, + opts.key.model, opts.key.effort, opts.key.permissions, 'native']); + // forceNew invalidates the captured entries only, never a borrower admitted after an await. + const forced = new Set(opts.forceNew + ? [...(store.scopeIndex.get(opts.key.scopeKey) ?? [])].map(k => store.entries.get(k)) : []); + for (;;) { + check(); + // Every await returns here. A key-local check alone can admit two different keys. + let blocked = false; + for (const scopeKey of store.scopeIndex.get(opts.key.scopeKey) ?? []) { + const candidate = store.entries.get(scopeKey); + if (!candidate) continue; + const invalidated = forced.has(candidate) || candidate.nativeOwner?.global !== owner.global + || candidate.nativeOwner?.scope !== owner.scope; + if (candidate.state === 'creating') { + if (invalidated) closeEntry(store, scopeKey, candidate, new Error('cursor runtime creation ownership invalidated')); + else await waitForEntry(candidate, Math.max(1, deadline - performance.now()), signal); + } else if (candidate.cursorRetirement) { + await waitForEntry(candidate, Math.max(1, deadline - performance.now()), signal); + } else if (candidate.busy && !invalidated) { + // Protocol idle/exit is not application settlement. Never interrupt a normal owner. + await waitForEntry(candidate, Math.max(1, deadline - performance.now()), signal); + } else if (invalidated || scopeKey !== key || candidate.dead || !candidate.runtime.alive + || (opts.storedSessionId && opts.storedSessionId !== candidate.sessionId) + || !(candidate.runtime as CursorManagedRuntime).session.idle) { + void retireCursorEntry(store, scopeKey, candidate, new Error('cursor runtime entry stale')); + if (store.entries.get(scopeKey) === candidate) { + await waitForEntry(candidate, Math.max(1, deadline - performance.now()), signal); + } + } else continue; + blocked = true; + break; + } + if (blocked) continue; + const entry = store.entries.get(key); + if (!entry) { + const creating: Extract = { state: 'creating', scopeKey: opts.key.scopeKey, + waiters: [], lastUsedAt: Date.now(), nativeOwner: owner }; + store.entries.set(key, creating); + let keys = store.scopeIndex.get(opts.key.scopeKey); + if (!keys) { keys = new Set(); store.scopeIndex.set(opts.key.scopeKey, keys); } + keys.add(key); + return createCursorEntry(store, key, creating, opts, deadline, check); + } + if (entry.state === 'ready') { + entry.busy = true; entry.lastUsedAt = Date.now(); + return makeCursorLease(store, key, entry, true); + } + } +} + export function startPoolReaper(idleMs = DEFAULT_POOL_IDLE_MS): void { if (reaper) return; reaper = setInterval(() => { const now = Date.now(); - for (const store of stores.values()) { + for (const [engine, store] of stores) { for (const [key, entry] of store.entries) { if (entry.state === 'ready' && !entry.busy && now - entry.lastUsedAt >= idleMs) { - closeEntry(store, key, entry, new Error('runtime pool idle timeout')); + if (engine === 'cursor') void retireCursorEntry(store, key, entry, new Error('runtime pool idle timeout')); + else closeEntry(store, key, entry, new Error('runtime pool idle timeout')); } } } diff --git a/src/agent/runtime/acp/callbacks.ts b/src/agent/runtime/acp/callbacks.ts new file mode 100644 index 000000000..f158fa692 --- /dev/null +++ b/src/agent/runtime/acp/callbacks.ts @@ -0,0 +1,184 @@ +import type { RuntimeEvent, RuntimeEventBody } from '../../../shared/runtime-contract.js'; +import { runtimeRequests, type RuntimeRequests, type RuntimeRequestBinding } from '../requests.js'; +import type { AcpConnection } from './connection.js'; +import type { RpcFrame, RpcId } from './wire.js'; +import { automaticPermission, normalizeNativePermissions, permissionResponse, preparePermissionRequest, validatedPermissionParams } from './permissions.js'; + +export interface AcpRequestOwner { + nativeSessionId: string; + binding: RuntimeRequestBinding; + parentItemId?: string; + isCurrent(): boolean; + /** Must be bound to the captured turn, never the subsequently active turn. */ + emit(body: RuntimeEventBody): RuntimeEvent | null; +} +type Work = { id: RpcId; runId: string; cancelled: boolean; selectedReplyInFlight: boolean; cancel?: () => void }; +const CALLBACK_LIMIT = 32; + +/** Connection-scoped callback owner. Human waits never block notification parsing. */ +export class AcpCallbacks { + private readonly active = new Map(); + private readonly operations = new Map, string | null>(); + private readonly permissions: ReturnType; + private readonly registry: RuntimeRequests; + private disposed = false; + private cancelledRunId: string | null = null; + + constructor(private connection: AcpConnection, private options: { + permissions: unknown; getOwner(): AcpRequestOwner | null; registry?: RuntimeRequests; + }) { + this.permissions = normalizeNativePermissions(options.permissions); + this.registry = options.registry ?? runtimeRequests; + } + get idle(): boolean { return this.active.size === 0 && this.operations.size === 0; } + /** A new protocol attempt may reuse a logical run only after every old write drained. */ + beginRun(): void { + if (!this.idle || this.disposed || !this.connection.alive) throw new Error('acp_callbacks_busy'); + this.cancelledRunId = null; + } + + handle(frame: RpcFrame): void { + if (!('method' in frame) || !('id' in frame) || this.disposed || !this.connection.alive) return; + if (this.active.has(frame.id)) { + const runId = this.active.get(frame.id)!.runId; + this.dispose(); + this.observe(this.connection.refuse(frame.id, -32600, 'Duplicate request') + .finally(() => this.connection.close(new Error('acp_duplicate_request'))), runId); + return; + } + let owner: AcpRequestOwner | null; + try { owner = this.captureOwner(); } + catch { this.fatal(new Error('acp_callback_owner_failed')); return; } + if (!owner || !this.current(owner)) { this.refuse(frame.id, -32600, 'No active turn'); return; } + if (this.cancelledRunId !== owner.binding.runId) this.cancelledRunId = null; + if (this.active.size >= CALLBACK_LIMIT) { this.refuse(frame.id, -32000, 'Request capacity reached', owner.binding.runId); return; } + const work: Work = { id: frame.id, runId: owner.binding.runId, + cancelled: this.cancelledRunId === owner.binding.runId, selectedReplyInFlight: false }; + this.active.set(frame.id, work); + if (frame.method !== 'session/request_permission') { + this.track(work, this.connection.refuse(work.id, -32601, 'Client method unsupported')); + return; + } + let params: ReturnType; + try { params = validatedPermissionParams(frame.params, owner.nativeSessionId); } + catch { + this.track(work, this.connection.refuse(work.id, -32602, 'Invalid permission request')); + return; + } + // Do not retain an arbitrary multi-megabyte raw frame during a human wait. + this.track(work, this.permission(work, owner, params)); + } + + cancelRun(runId: string): void { + let owner: AcpRequestOwner | null; + try { owner = this.captureOwner(); } + catch { this.fatal(new Error('acp_callback_owner_failed')); return; } + if (owner?.binding.runId === runId) this.cancelledRunId = runId; + const works = [...this.active.values()].filter(work => work.runId === runId); + for (const work of works) work.cancelled = true; + for (const work of works) work.cancel?.(); + if (works.some(work => work.selectedReplyInFlight)) this.fatal(new Error('acp_selected_reply_cancelled')); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + const works = [...this.active.values()]; + for (const work of works) work.cancelled = true; + for (const work of works) work.cancel?.(); + this.active.clear(); + if (works.some(work => work.selectedReplyInFlight)) this.connection.close(new Error('acp_selected_reply_cancelled')); + } + + /** Caller seals admission first and owns the bounded session drain deadline. */ + async drain(runId?: string): Promise { + for (;;) { + const pending = [...this.operations].filter(([, owner]) => runId === undefined || owner === null || owner === runId) + .map(([operation]) => operation); + if (!pending.length) { + if (this.disposed || !this.connection.alive) throw new Error('acp_callbacks_closed'); + return; + } + const results = await Promise.allSettled(pending); + if (results.some(result => result.status === 'rejected')) throw new Error('acp_callback_drain_failed'); + } + } + + private captureOwner(): AcpRequestOwner | null { + const source = this.options.getOwner(); + if (!source) return null; + const { runId, sessionId, scope, turnId } = source.binding; + return { nativeSessionId: source.nativeSessionId, binding: { runId, sessionId, scope, turnId }, + ...(source.parentItemId === undefined ? {} : { parentItemId: source.parentItemId }), + isCurrent: source.isCurrent, emit: source.emit }; + } + private current(owner: AcpRequestOwner): boolean { + try { return !this.disposed && this.connection.alive && owner.isCurrent() === true; } + catch { return false; } + } + private emit(owner: AcpRequestOwner, body: RuntimeEventBody): RuntimeEvent | null { + try { return owner.emit(body); } catch { return null; } + } + private refuse(id: RpcId, code: number, message: string, runId: string | null = null): void { + this.observe(this.connection.refuse(id, code, message), runId); + } + private track(work: Work, operation: Promise): void { + this.observe(operation, work.runId, work); + } + private observe(operation: Promise, runId: string | null, work?: Work): void { + if (this.operations.size >= 1024) { + void operation.catch(() => undefined); + this.fatal(new Error('acp_callback_operation_limit')); + return; + } + this.operations.set(operation, runId); + const finish = () => { + this.operations.delete(operation); + if (work && this.active.get(work.id) === work) this.active.delete(work.id); + }; + void operation.then(finish, () => { finish(); this.fatal(new Error('acp_callback_failed')); }); + } + private fatal(error: Error): void { + this.connection.close(error); + this.dispose(); + } + + private async reply(work: Work, owner: AcpRequestOwner, answer: ReturnType): Promise { + if (this.disposed || !this.connection.alive) return; + const current = this.current(owner); + if (this.disposed || !this.connection.alive) return; + const response = work.cancelled || !current + ? { outcome: { outcome: 'cancelled' as const } } : answer; + work.selectedReplyInFlight = response.outcome.outcome === 'selected'; + try { await this.connection.reply(work.id, response); } + finally { work.selectedReplyInFlight = false; } + } + + private async permission(work: Work, owner: AcpRequestOwner, params: ReturnType): Promise { + const cancelled = permissionResponse({ optionId: null }, params.options); + const automatic = automaticPermission(this.permissions, params.options); + if (automatic !== undefined || work.cancelled) { + await this.reply(work, owner, automatic === undefined ? cancelled + : permissionResponse({ optionId: automatic }, params.options)); + return; + } + const prepared = preparePermissionRequest(params.title, params.options); + try { + let pending; + try { + pending = this.registry.open({ ...owner.binding, + ...(owner.parentItemId === undefined ? {} : { parentItemId: owner.parentItemId }), + requestType: 'approval', view: prepared.view, validate: prepared.validate, cancelled, + isCurrent: () => !work.cancelled && this.current(owner) }); + } catch { await this.reply(work, owner, cancelled); return; } + work.cancel = pending.cancel; + try { + if (!this.emit(owner, { kind: 'request', requestId: pending.requestId, requestType: 'approval', view: pending.view })) { + work.cancelled = true; + pending.cancel(); + } + await this.reply(work, owner, await pending.answer); + } finally { this.emit(owner, { kind: 'request-settled', requestId: pending.requestId }); } + } finally { prepared.dispose(); } + } +} diff --git a/src/agent/runtime/acp/config.ts b/src/agent/runtime/acp/config.ts new file mode 100644 index 000000000..730f5cd98 --- /dev/null +++ b/src/agent/runtime/acp/config.ts @@ -0,0 +1,185 @@ +/** Negotiated ACP select options only; no process, session or settings ownership. */ +export interface AcpSelectOption { + readonly value: string; + readonly name: string; +} + +export interface AcpSelectConfig { + readonly id: string; + readonly name: string; + readonly category?: string; + readonly currentValue: string; + readonly options: ReadonlyArray; +} + +export interface AcpConfigPort { + getConfigOptions(): unknown; + /** Must replace its snapshot with the successful response's full configOptions. */ + setConfigOption(id: string, value: string): Promise; +} + +const CONFIG_LIMIT = 64; +const CHOICE_LIMIT = 2048; +const GROUP_LIMIT = 64; // Aggregate per snapshot; groups contain only flat choices. +const ID_LIMIT = 1024; +const NAME_LIMIT = 1000; +const EFFORT_SELECTOR_NAMES = new Set([ + 'effort', 'reasoning', 'reasoningeffort', 'reasoninglevel', 'thinkinglevel', 'thoughtlevel', +]); + +function record(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function text(value: unknown, limit: number, code: string, allowEmpty = false): string { + if (typeof value !== 'string' || value.length > limit || (!allowEmpty && !value.trim())) throw new Error(code); + return value; // Validate without changing a provider's opaque ID/value. +} + +function choices(raw: unknown, budget: { choices: number; groups: number }): AcpSelectOption[] { + if (!Array.isArray(raw) || raw.length > CHOICE_LIMIT + GROUP_LIMIT) { + throw new Error('acp_config_invalid_choices'); + } + const result: AcpSelectOption[] = []; + const values = new Set(), groups = new Set(); + const add = (entry: unknown): void => { + if (++budget.choices > CHOICE_LIMIT) throw new Error('acp_config_choice_limit'); + if (!record(entry) || 'options' in entry || 'group' in entry) { + throw new Error('acp_config_invalid_choice'); + } + const value = text(entry['value'], ID_LIMIT, 'acp_config_invalid_value'); + const name = text(entry['name'], NAME_LIMIT, 'acp_config_invalid_name', true); + if (values.has(value)) throw new Error('acp_config_duplicate_value'); + values.add(value); + result.push({ value, name }); + }; + for (const entry of raw) { + if (!record(entry)) throw new Error('acp_config_invalid_choice'); + if (!('options' in entry)) { add(entry); continue; } + if (++budget.groups > GROUP_LIMIT) throw new Error('acp_config_group_limit'); + if ('value' in entry) throw new Error('acp_config_invalid_group'); + const group = text(entry['group'], ID_LIMIT, 'acp_config_invalid_group'); + text(entry['name'], NAME_LIMIT, 'acp_config_invalid_name', true); + if (groups.has(group)) throw new Error('acp_config_duplicate_group'); + groups.add(group); + const entries: unknown = entry['options']; + if (!Array.isArray(entries) || entries.length > CHOICE_LIMIT - budget.choices) { + throw new Error('acp_config_choice_limit'); + } + for (const child of entries) add(child); + } + return result; +} + +/** Parse an optional configOptions array into copied, bounded select configurations. + * Unknown types are unavailable, not coerced. Extension metadata is not retained. + */ +export function parseAcpSelectConfigs(raw: unknown): AcpSelectConfig[] { + if (raw === undefined || raw === null) return []; + if (!Array.isArray(raw) || raw.length > CONFIG_LIMIT) throw new Error('acp_config_invalid_configs'); + const result: AcpSelectConfig[] = [], ids = new Set(); + const budget = { choices: 0, groups: 0 }; + for (const entry of raw) { + if (!record(entry)) throw new Error('acp_config_invalid_config'); + const id = text(entry['id'], ID_LIMIT, 'acp_config_invalid_id'); + const name = text(entry['name'], NAME_LIMIT, 'acp_config_invalid_name', true); + const type = text(entry['type'], ID_LIMIT, 'acp_config_invalid_type'); + const category = entry['category'] === undefined || entry['category'] === null + ? undefined : text(entry['category'], ID_LIMIT, 'acp_config_invalid_category'); + if (ids.has(id)) throw new Error('acp_config_duplicate_id'); + ids.add(id); + if (type !== 'select') { + if (type === 'boolean' && typeof entry['currentValue'] !== 'boolean') { + throw new Error('acp_config_invalid_current'); + } + continue; + } + const currentValue = text(entry['currentValue'], ID_LIMIT, 'acp_config_invalid_current'); + const options = choices(entry['options'], budget); + if (!options.some(option => option.value === currentValue)) throw new Error('acp_config_invalid_current'); + result.push({ id, name, ...(category === undefined ? {} : { category }), currentValue, options }); + } + return result; +} + +type SelectionKind = 'model' | 'effort'; + +function token(value: string): string { + return value.trim().toLowerCase().replace(/[\s_-]+/g, ''); +} + +function effortSelector(config: AcpSelectConfig): boolean { + const category = token(config.category ?? ''); + if (category === 'model') return false; + if (token(config.id) === 'effort' || category === 'thoughtlevel') return true; + if (category !== 'modeloption' && category !== 'modelconfig') return false; + return EFFORT_SELECTOR_NAMES.has(token(config.id)) || EFFORT_SELECTOR_NAMES.has(token(config.name)); +} + +function selector(configs: ReadonlyArray, kind: SelectionKind): AcpSelectConfig { + const matches = configs.filter(config => kind === 'model' + ? token(config.category ?? '') === 'model' || config.id === 'model' + : effortSelector(config)); + if (matches.length > 1) throw new Error(`acp_config_ambiguous_${kind}`); + const match = matches[0]; + if (!match) throw new Error(`acp_config_unsupported_${kind}`); + return match; +} + +function effortValue(value: string): string { + const normalized = token(value); + if (normalized === 'extrahigh' || normalized === 'xhigh') return 'xhigh'; + if (['none', 'minimal', 'low', 'medium', 'high', 'max'].includes(normalized)) return normalized; + return value; // Unknown future values match exactly, without invented aliases. +} + +function requested(value: unknown, kind: SelectionKind): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value === 'string' && value.length <= ID_LIMIT && !value.trim()) return undefined; + const validated = text(value, ID_LIMIT, `acp_config_invalid_requested_${kind}`); + return validated; +} + +function effortChoice(config: AcpSelectConfig, effort: string): string { + const normalized = effortValue(effort); + const matches = config.options.filter(option => effortValue(option.value) === normalized + || effortValue(option.name) === normalized); + if (matches.length > 1) throw new Error('acp_config_ambiguous_effort_value'); + const match = matches[0]; + if (!match) throw new Error('acp_config_unsupported_effort'); + return match.value; +} + +function assertApplied(configs: ReadonlyArray, kind: SelectionKind, value: string): void { + if (selector(configs, kind).currentValue !== value) throw new Error(`acp_config_${kind}_not_applied`); +} + +/** Serialized setup helper. Caller owns admission, deadlines, retirement and error reporting. + * A later failure does not undo an already acknowledged model write. + */ +export async function configureAcpModel(port: AcpConfigPort, input: { + readonly model?: string | null | undefined; + readonly effort?: string | null | undefined; +}): Promise { + const model = requested(input.model, 'model'), effort = requested(input.effort, 'effort'); + let configs = parseAcpSelectConfigs(port.getConfigOptions()); + if (model !== undefined) { + const config = selector(configs, 'model'); + if (!config.options.some(option => option.value === model)) throw new Error('acp_config_unsupported_model'); + if (config.currentValue !== model) { + await port.setConfigOption(config.id, model); + configs = parseAcpSelectConfigs(port.getConfigOptions()); + assertApplied(configs, 'model', model); + } + } + if (effort === undefined) return; + const config = selector(configs, 'effort'); + const value = effortChoice(config, effort); + if (config.currentValue === value) return; + await port.setConfigOption(config.id, value); + configs = parseAcpSelectConfigs(port.getConfigOptions()); + assertApplied(configs, 'effort', value); + if (model !== undefined) assertApplied(configs, 'model', model); +} diff --git a/src/agent/runtime/acp/connection.ts b/src/agent/runtime/acp/connection.ts new file mode 100644 index 000000000..675027b6a --- /dev/null +++ b/src/agent/runtime/acp/connection.ts @@ -0,0 +1,214 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { decodeFrame, type RpcFrame, type RpcId } from './wire.js'; + +const FRAME_LIMIT = 4 * 1024 * 1024; // payload bytes, excluding LF or CRLF +const CARRY_LIMIT = FRAME_LIMIT + 1; // a split CR delimiter may precede the next LF +const WRITE_LIMIT = 8 * 1024 * 1024; // active + queued bytes, including LF +const WRITE_COUNT_LIMIT = 1024; +const WRITE_TIMEOUT_MS = 30_000; +const PENDING_LIMIT = 64; +type Pending = { + resolve(value: unknown): void; reject(error: Error): void; timer: ReturnType; + onResponse?: (frame: RpcFrame) => void; settling?: boolean; +}; +type Write = { + bytes: Buffer; resolve(): void; reject(error: Error): void; + timer: ReturnType; settled: boolean; +}; + +/** Transport only: the caller owns process reaping and bounded asynchronous frame admission. */ +export class AcpConnection { + private nextId = 0; + private buffer: Buffer = Buffer.alloc(0); + private buffered = 0; + private readonly decoder = new TextDecoder('utf-8', { fatal: true }); + private readonly pending = new Map(); + private readonly writes: Write[] = []; + private activeWrite: Write | null = null; + private queuedBytes = 0; + private closed = false; + + constructor(private child: ChildProcessWithoutNullStreams, private hooks: { + frame(frame: RpcFrame): void; + failed(error: Error): void; + }) { + child.stdout.on('data', this.receive); + child.stdout.once('end', this.ended); + child.once('exit', this.exited); + // Keep error consumers for this child's lifetime, including late EPIPE after close. + child.on('error', this.failed); + child.stdin.on('error', this.failed); + child.stdout.on('error', this.failed); + } + + get alive(): boolean { return !this.closed; } + private failed = () => this.close(new Error('acp_io_error')); + private exited = () => this.close(new Error('acp_child_exit')); + private ended = () => this.close(new Error(this.buffered ? 'acp_truncated_frame' : 'acp_stdout_end')); + + private append(bytes: Buffer): void { + const needed = this.buffered + bytes.length; + const trailingCr = (bytes.at(-1) ?? this.buffer[this.buffered - 1]) === 13; + if (needed > FRAME_LIMIT && !(needed === CARRY_LIMIT && trailingCr)) throw new Error('acp_frame_limit'); + if (needed > this.buffer.length) { + const next = Buffer.allocUnsafe(Math.min(CARRY_LIMIT, Math.max(needed, this.buffer.length * 2, 4096))); + this.buffer.copy(next, 0, 0, this.buffered); + this.buffer = next; + } + bytes.copy(this.buffer, this.buffered); + this.buffered = needed; + } + + private receive = (chunk: Buffer) => { + if (this.closed) return; + if (!Buffer.isBuffer(chunk)) { this.close(new Error('acp_invalid_bytes')); return; } + try { + let offset = 0; + while (!this.closed && offset < chunk.length) { + const newline = chunk.indexOf(10, offset); + if (newline < 0) { this.append(chunk.subarray(offset)); break; } + const bytes = chunk.subarray(offset, newline); + let payload = bytes; + if (this.buffered) { + this.append(bytes); + payload = this.buffer.subarray(0, this.buffered); + } + this.buffered = 0; + if (payload.at(-1) === 13) payload = payload.subarray(0, -1); + if (payload.length > FRAME_LIMIT) throw new Error('acp_frame_limit'); + let line: string; + try { line = this.decoder.decode(payload).trim(); } + catch { throw new Error('acp_invalid_utf8'); } + if (line) this.deliver(decodeFrame(line)); + offset = newline + 1; + } + } catch (error) { + this.close(error instanceof Error ? error : new Error('acp_frame_failure')); + } + }; + + private deliver(frame: RpcFrame): void { + // Each peer owns its ID namespace: a callback is never an outgoing response. + if ('method' in frame) { + try { this.hooks.frame(frame); } + catch { this.close(new Error('acp_frame_hook_failed')); } + return; + } + const entry = this.pending.get(frame.id); + if (!entry || entry.settling) return; // unknown, late or duplicate replies cannot become events + entry.settling = true; + try { entry.onResponse?.(frame); } + catch { this.close(new Error('acp_response_observer_failed')); return; } + if (this.closed) return; // observer retirement must also reject this still-registered request + this.pending.delete(frame.id); + clearTimeout(entry.timer); + if ('error' in frame) entry.reject(new Error(`acp_rpc_error:${frame.error.code}`)); + else entry.resolve(frame.result); + } + + write(frame: RpcFrame): Promise { + if (this.closed) return Promise.reject(new Error('acp_closed')); + let encoded: string; + try { + encoded = JSON.stringify(frame); + if (typeof encoded !== 'string') throw new Error('acp_serialize_failed'); + } + catch { + const error = new Error('acp_serialize_failed'); + this.close(error); + return Promise.reject(error); + } + if (this.closed) return Promise.reject(new Error('acp_closed')); + const size = Buffer.byteLength(encoded) + 1; + if (size - 1 > FRAME_LIMIT || this.queuedBytes + size > WRITE_LIMIT + || this.writes.length + Number(this.activeWrite !== null) >= WRITE_COUNT_LIMIT) { + const error = new Error('acp_write_limit'); + this.close(error); + return Promise.reject(error); + } + // unknown result/params can serialize differently (for example, undefined results). + try { decodeFrame(encoded); } + catch { + const error = new Error('acp_invalid_outgoing_frame'); + this.close(error); + return Promise.reject(error); + } + const bytes = Buffer.from(encoded + '\n'); + const written = new Promise((resolve, reject) => { + const timer = setTimeout(() => this.close(new Error('acp_write_timeout')), WRITE_TIMEOUT_MS); + this.queuedBytes += bytes.length; + this.writes.push({ bytes, resolve, reject, timer, settled: false }); + }); + // Observe immediately without replacing the caller-visible rejecting promise. + void written.catch(() => undefined); + this.pump(); + return written; + } + + private pump(): void { + if (this.closed || this.activeWrite) return; + const entry = this.writes.shift(); + if (!entry) return; + this.activeWrite = entry; + if (!this.child.stdin.writable) { this.close(new Error('acp_stdin_closed')); return; } + try { + this.child.stdin.write(entry.bytes, error => { + if (entry.settled) return; + if (error) { this.close(new Error('acp_write_failed')); return; } + this.settleWrite(entry); + this.pump(); + }); + } catch { this.close(new Error('acp_write_failed')); } + } + + private settleWrite(entry: Write, error?: Error): void { + if (entry.settled) return; + entry.settled = true; + clearTimeout(entry.timer); + this.queuedBytes -= entry.bytes.length; + entry.bytes = Buffer.alloc(0); + if (this.activeWrite === entry) this.activeWrite = null; + if (error) entry.reject(error); else entry.resolve(); + } + + request(method: string, params: unknown, timeoutMs = 30_000, onResponse?: (frame: RpcFrame) => void) { + if (this.closed || this.pending.size >= PENDING_LIMIT) throw new Error('acp_unavailable'); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + throw new Error('acp_invalid_timeout'); + } + const id = `jaw:${++this.nextId}`; + let resolve!: (value: unknown) => void; + let reject!: (error: Error) => void; + const result = new Promise((yes, no) => { resolve = yes; reject = no; }); + void result.catch(() => undefined); + const timer = setTimeout(() => this.close(new Error('acp_timeout')), timeoutMs); + this.pending.set(id, { resolve, reject, timer, ...(onResponse === undefined ? {} : { onResponse }) }); + const dispatched = this.write({ jsonrpc: '2.0', id, method, params }); + void dispatched.catch(error => this.close(error)); + return { id, dispatched, result }; + } + + notify(method: string, params: unknown): Promise { + return this.write({ jsonrpc: '2.0', method, params }); + } + reply(id: RpcId, result: unknown): Promise { return this.write({ jsonrpc: '2.0', id, result }); } + refuse(id: RpcId, code: number, message: string): Promise { + return this.write({ jsonrpc: '2.0', id, error: { code, message } }); + } + + close(error = new Error('acp_closed')): void { + if (this.closed) return; + this.closed = true; + this.buffer = Buffer.alloc(0); + this.buffered = 0; + this.child.stdout.off('data', this.receive); + this.child.stdout.off('end', this.ended); + this.child.off('exit', this.exited); + for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.reject(error); } + this.pending.clear(); + if (this.activeWrite) this.settleWrite(this.activeWrite, error); + for (const entry of this.writes.splice(0)) this.settleWrite(entry, error); + try { this.hooks.failed(error); } + catch { /* All local work is settled even if the process owner's failure hook throws. */ } + } +} diff --git a/src/agent/runtime/acp/content.ts b/src/agent/runtime/acp/content.ts new file mode 100644 index 000000000..ebabacc58 --- /dev/null +++ b/src/agent/runtime/acp/content.ts @@ -0,0 +1,56 @@ +import { FULLTEXT_MAX_CHARS } from '../../events/fulltext-bound.js'; +import { acpRecord } from './session.js'; + +const MAX_CONTENT_ITEMS = 4096; + +function contentRecord(value: unknown): Record { + try { + const record = acpRecord(value); + if (typeof record['type'] !== 'string' || !record['type']) throw new Error(); + return record; + } catch { + throw new Error('acp_invalid_content'); + } +} + +/** Extract flat text blocks without inspecting unsupported payloads. */ +export function acpText(value: unknown): { text: string | null; unsupported: boolean } { + if (value === null || value === undefined) return { text: null, unsupported: false }; + const items: unknown[] = Array.isArray(value) ? value : [value]; + const count = items.length; + if (count > MAX_CONTENT_ITEMS) throw new Error('acp_content_limit'); + const parts: string[] = []; + let length = 0; + let unsupported = false; + for (let index = 0; index < count; index++) { + let block = contentRecord(items[index]); + if (block['type'] === 'content') { + block = contentRecord(block['content']); + // ToolCallContent wraps one content block, never another wrapper or array. + if (block['type'] === 'content') throw new Error('acp_invalid_content'); + } + if (block['type'] !== 'text') { + unsupported = true; + continue; + } + const text = block['text']; + if (typeof text !== 'string') throw new Error('acp_invalid_content'); + if (text.length > FULLTEXT_MAX_CHARS - length) throw new Error('acp_content_limit'); + length += text.length; + parts.push(text); + } + return { text: parts.length ? parts.join('') : null, unsupported }; +} + +/** Preserve the whole snapshot; generic RuntimeProjection owns redaction and clipping. */ +export function acpSnapshot(value: unknown): string { + let text: string | undefined; + try { + text = typeof value === 'string' ? value : JSON.stringify(value); + } catch { + throw new Error('acp_invalid_content'); + } + if (text === undefined) throw new Error('acp_invalid_content'); + if (text.length > FULLTEXT_MAX_CHARS) throw new Error('acp_content_limit'); + return text; +} diff --git a/src/agent/runtime/acp/cursor-session.ts b/src/agent/runtime/acp/cursor-session.ts new file mode 100644 index 000000000..b6441d860 --- /dev/null +++ b/src/agent/runtime/acp/cursor-session.ts @@ -0,0 +1,80 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { realpathSync, statSync } from 'node:fs'; +import { resolveWindowsLaunchSpec, launchArgv, type ResolveDeps } from '../../../core/windows-launch-spec.js'; +import { detectCliBinary } from '../../../core/cli-detect.js'; +import { mergeEnvWindowsSafe } from '../../spawn-env.js'; +import { ownProcess } from '../../spawn/process-kill.js'; +import { normalizeNativePermissions } from './permissions.js'; +import { configureAcpModel } from './config.js'; +import { AcpSession, validateAcpSessionOptions, type AcpSessionOptions } from './session.js'; + +export interface CursorSessionOptions extends Omit { + binary: string; + env: NodeJS.ProcessEnv; + cwd: string; + model?: string | null; + effort?: string | null; + resumeSessionId?: string | null; + signal?: AbortSignal; + spawnImpl?: typeof spawn; + platform?: NodeJS.Platform; + launchDeps?: ResolveDeps; +} + +/** Existing login only. Every failed or aborted startup owns and retires its own child. */ +export async function createCursorSession(options: CursorSessionOptions): Promise { + validateAcpSessionOptions(options); + const permissions = normalizeNativePermissions(options.permissions); + if (!options.binary || typeof options.binary !== 'string') throw new Error('cursor_acp_invalid_binary'); + for (const value of [options.model, options.effort, options.resumeSessionId]) { + if (value !== undefined && value !== null && (typeof value !== 'string' || value.length > 1024)) throw new Error('cursor_acp_invalid_option'); + } + if (options.signal?.aborted) throw new Error('cursor_acp_acquire_aborted'); + let cwd: string; + try { + cwd = realpathSync(options.cwd); + if (!statSync(cwd).isDirectory()) throw new Error('not_directory'); + } catch { throw new Error('cursor_acp_invalid_cwd'); } + const windows = (options.platform ?? process.platform) === 'win32'; + const launch = windows ? resolveWindowsLaunchSpec(options.binary, ['acp'], options.launchDeps ?? { + which: name => detectCliBinary(name).path || null, + }) : null; + if (windows && !launch) throw new Error('cursor_acp_launch_unsupported'); + if (options.signal?.aborted) throw new Error('cursor_acp_acquire_aborted'); + const child: ChildProcessWithoutNullStreams = (options.spawnImpl ?? spawn)(launch?.command ?? options.binary, + launch ? launchArgv(launch) : ['acp'], { cwd, env: launch ? mergeEnvWindowsSafe(options.env, launch.envDelta) : options.env, + stdio: 'pipe', shell: false, windowsHide: true }); + const owned = ownProcess(child, options.ownedProcessOptions); + let session: AcpSession | undefined; + const abort = () => { + if (session) session.retire(new Error('cursor_acp_acquire_aborted')); + else owned.terminate('startup-failed'); + }; + options.signal?.addEventListener('abort', abort, { once: true }); + try { + // Install the lifecycle before checking the post-spawn abort race so + // failure uses the session's bounded close/reap without sending an RPC. + session = new AcpSession(child, { permissions, promptTimeoutMs: options.promptTimeoutMs, + ...(options.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: options.requestTimeoutMs }), + ...(options.controlTimeoutMs === undefined ? {} : { controlTimeoutMs: options.controlTimeoutMs }), + ...(options.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: options.drainTimeoutMs }), + ...(options.registry === undefined ? {} : { registry: options.registry }), + ...(options.failed === undefined ? {} : { failed: options.failed }), + clientMetadata: { parameterizedModelPicker: true } }); + if (options.signal?.aborted) { abort(); throw new Error('cursor_acp_acquire_aborted'); } + await session.start({ cwd, authMethodId: 'cursor_login', + ...(options.resumeSessionId ? { resumeSessionId: options.resumeSessionId } : {}) }); + await configureAcpModel(session, { model: options.model, effort: options.effort }); + if (options.signal?.aborted || !session.idle) throw new Error('cursor_acp_acquire_aborted'); + return session; + } catch (error) { + if (session) { + try { await session.close(); } + catch { throw new Error('cursor_acp_startup_cleanup_failed'); } + } else { + owned.terminate('startup-failed'); + child.stdin?.destroy(); child.stdout?.destroy(); child.stderr?.destroy(); + } + throw error; + } finally { options.signal?.removeEventListener('abort', abort); } +} diff --git a/src/agent/runtime/acp/notification-queue.ts b/src/agent/runtime/acp/notification-queue.ts new file mode 100644 index 000000000..006c2e966 --- /dev/null +++ b/src/agent/runtime/acp/notification-queue.ts @@ -0,0 +1,87 @@ +import type { RpcFrame } from './wire.js'; + +type Consumer = (frame: RpcFrame, signal: AbortSignal) => void | Promise; +type Job = { frame: RpcFrame; bytes: number; consume: Consumer }; +type Waiter = { resolve(): void; reject(error: Error): void }; +const COUNT_LIMIT = 256; +const BYTE_LIMIT = 8 * 1024 * 1024; + +/** Per-turn queue. Human requests bypass it; consumers bind effects to its signal and owner. */ +export class AcpNotificationQueue { + private readonly jobs: Job[] = []; + private readonly waiters = new Set(); + private readonly controller = new AbortController(); + private active: Job | null = null; + private bytes = 0; + private scheduled = false; + private sealed = false; + private failure: Error | null = null; + + constructor(private readonly failed: (error: Error) => void) {} + get idle(): boolean { return this.active === null && this.jobs.length === 0; } + + enqueue(frame: RpcFrame, consume: Consumer): void { + if (this.sealed) throw new Error('acp_notification_after_terminal'); + let bytes: number; + try { bytes = Buffer.byteLength(JSON.stringify(frame)); } + catch { this.fail(new Error('acp_invalid_notification')); return; } + if (this.sealed) throw new Error('acp_notification_after_terminal'); + if (this.jobs.length + Number(this.active !== null) >= COUNT_LIMIT || this.bytes + bytes > BYTE_LIMIT) { + this.fail(new Error('acp_notification_limit')); + return; + } + this.jobs.push({ frame, consume, bytes }); + this.bytes += bytes; + this.schedule(); + } + seal(): void { this.sealed = true; } + close(error = new Error('acp_notification_closed')): void { + if (this.failure) return; + this.failure = error; + this.sealed = true; + this.jobs.length = 0; + this.active = null; + this.bytes = 0; + this.controller.abort(error); + for (const waiter of this.waiters) waiter.reject(error); + this.waiters.clear(); + } + drain(): Promise { + if (this.failure) return Promise.reject(this.failure); + if (this.idle) return Promise.resolve(); + const pending = new Promise((resolve, reject) => this.waiters.add({ resolve, reject })); + void pending.catch(() => undefined); + return pending; + } + private fail(error: Error): void { + if (this.failure) return; + this.close(error); + try { this.failed(error); } catch { /* The closed queue cannot be resurrected by its owner. */ } + } + private schedule(): void { + if (this.scheduled || this.active || this.failure) return; + this.scheduled = true; + queueMicrotask(() => { + this.scheduled = false; + if (!this.failure) void this.run(); + }); + } + private async run(): Promise { + const job = this.jobs.shift(); + if (!job || this.failure) return; + this.active = job; + try { await job.consume(job.frame, this.controller.signal); } + catch { this.fail(new Error('acp_notification_consumer_failed')); } + finally { + if (this.active === job) { + this.active = null; + this.bytes -= job.bytes; + } + if (!this.failure && this.idle) { + for (const waiter of this.waiters) waiter.resolve(); + this.waiters.clear(); + } + if (!this.failure) this.schedule(); + } + } +} diff --git a/src/agent/runtime/acp/permissions.ts b/src/agent/runtime/acp/permissions.ts new file mode 100644 index 000000000..3bd9e15f8 --- /dev/null +++ b/src/agent/runtime/acp/permissions.ts @@ -0,0 +1,137 @@ +import { randomUUID } from 'node:crypto'; +import type { RuntimeRequestView } from '../../../shared/runtime-contract.js'; + +export type AcpPermissionOption = { + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; +}; + +const OPTION_LIMIT = 20; +const ID_LIMIT = 240; +const TEXT_LIMIT = 1000; +const TOKEN_LIMIT = 64; +const TOKEN_PATTERN = /^[a-zA-Z0-9._:*-]+$/; + +function record(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export function permissionOptions(value: unknown): AcpPermissionOption[] { + if (!Array.isArray(value) || value.length === 0 || value.length > OPTION_LIMIT) { + throw new Error('acp_invalid_options'); + } + const seen = new Set(); + const options: AcpPermissionOption[] = []; + for (const item of value) { + if (!record(item)) throw new Error('acp_invalid_option'); + const optionId = item['optionId']; + const name = item['name']; + const kind = item['kind']; + if (typeof optionId !== 'string' || !optionId || optionId.length > ID_LIMIT || seen.has(optionId) + || typeof name !== 'string' || name.length > TEXT_LIMIT + || (kind !== 'allow_once' && kind !== 'allow_always' && kind !== 'reject_once' && kind !== 'reject_always')) { + throw new Error('acp_invalid_option'); + } + seen.add(optionId); + options.push({ optionId, name, kind }); + } + return options; +} + +export function validatedPermissionParams(value: unknown, nativeSessionId: string): { + options: AcpPermissionOption[]; title: string; +} { + if (!record(value)) throw new Error('acp_invalid_permission'); + const sessionId = value['sessionId']; + if (typeof sessionId !== 'string' || !sessionId || sessionId !== nativeSessionId) { + throw new Error('acp_wrong_session'); + } + const tool = value['toolCall']; + if (!record(tool)) throw new Error('acp_invalid_tool'); + const toolCallId = tool['toolCallId']; + const title = tool['title']; + if (typeof toolCallId !== 'string' || !toolCallId || toolCallId.length > ID_LIMIT + || (title !== undefined && title !== null && (typeof title !== 'string' || title.length > TEXT_LIMIT))) { + throw new Error('acp_invalid_tool'); + } + return { + options: permissionOptions(value['options']), + title: typeof title === 'string' ? title : 'Permission request', + }; +} + +export function normalizeNativePermissions(value: unknown): 'auto' | 'safe' | ReadonlyArray { + if (value === 'auto' || value === 'safe') return value; + if (!Array.isArray(value)) throw new Error('invalid_native_permissions'); + const tokens: string[] = []; + for (const entry of value) { + if (typeof entry !== 'string') throw new Error('invalid_native_permissions'); + const token = entry.trim(); + if (!token) continue; + if (token.length > TOKEN_LIMIT || !TOKEN_PATTERN.test(token)) throw new Error('invalid_native_permissions'); + tokens.push(token); + } + // Capture policy for this runtime without freezing or modifying persisted settings. + return Object.freeze(tokens); +} + +export function automaticPermission( + permissions: ReturnType, options: ReadonlyArray, +): string | null | undefined { + if (permissions === 'auto') { + return (options.find(option => option.kind === 'allow_once') + ?? options.find(option => option.kind === 'allow_always'))?.optionId ?? null; + } + if (permissions === 'safe' || Array.isArray(permissions)) return undefined; + return null; +} + +function responseOptionId(value: unknown): string | null { + if (!record(value)) throw new Error('invalid_response'); + const keys = Reflect.ownKeys(value); + if (keys.length !== 1 || keys[0] !== 'optionId') throw new Error('invalid_response'); + const optionId = value['optionId']; + if (optionId !== null && typeof optionId !== 'string') throw new Error('invalid_option'); + return optionId; +} + +/** Native-wire-only: accepts native IDs for an already-authorized automatic decision. */ +export function permissionResponse(value: unknown, options: ReadonlyArray) { + const optionId = responseOptionId(value); + if (optionId === null) return { outcome: { outcome: 'cancelled' as const } }; + if (!options.some(option => option.optionId === optionId)) throw new Error('invalid_option'); + return { outcome: { outcome: 'selected' as const, optionId } }; +} + +export type AcpPermissionResponse = ReturnType; + +/** Consumes validated options; only the registry may sanitize and publish the returned view. */ +export function preparePermissionRequest(title: string, options: ReadonlyArray) { + const nativeByHandle = new Map(); + const view: RuntimeRequestView = { + title, + fields: [{ + id: randomUUID(), label: 'Permission', multiSelect: false, allowFreeform: false, + options: options.map(option => { + const id = randomUUID(); + nativeByHandle.set(id, option.optionId); + // Do not clip or redact here: the canonical registry boundary owns both. + return { id, label: option.name }; + }), + }], + }; + return { + view, + validate(value: unknown): AcpPermissionResponse { + const handle = responseOptionId(value); + if (handle === null) return { outcome: { outcome: 'cancelled' } }; + const optionId = nativeByHandle.get(handle); + if (optionId === undefined) throw new Error('invalid_option'); + return { outcome: { outcome: 'selected', optionId } }; + }, + dispose(): void { nativeByHandle.clear(); }, + }; +} diff --git a/src/agent/runtime/acp/projection.ts b/src/agent/runtime/acp/projection.ts new file mode 100644 index 000000000..2a3dee8ce --- /dev/null +++ b/src/agent/runtime/acp/projection.ts @@ -0,0 +1,142 @@ +import type { RuntimeItemStatus } from '../../../shared/runtime-contract.js'; +import { FULLTEXT_MAX_CHARS } from '../../events/fulltext-bound.js'; +import { RuntimeProjection } from '../projection.js'; +import { acpRecord } from './session.js'; +import { acpSnapshot, acpText } from './content.js'; + +const TOOL_LIMIT = 4096; +const NATIVE_ID_CHARS = 1024; +const UNSUPPORTED_CONTENT = '[unsupported ACP content]'; +type Segment = { ref: string; nativeId: string | null; text: string }; + +function nativeId(value: unknown): string { + if (typeof value !== 'string' || !value.trim() || value.length > NATIVE_ID_CHARS) throw new Error('acp_invalid_id'); + return value; +} +function title(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') throw new Error('acp_invalid_content'); + if (value.length > FULLTEXT_MAX_CHARS) throw new Error('acp_content_limit'); + return value; +} +function toolStatus(value: unknown, prior?: RuntimeItemStatus): RuntimeItemStatus { + if (value === undefined || value === null) return prior ?? 'running'; + switch (value) { + case 'pending': case 'in_progress': return 'running'; + case 'completed': return 'done'; + case 'failed': return 'error'; + default: throw new Error('acp_invalid_tool_status'); + } +} +function exitDetail(raw: unknown): string | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const code = acpRecord(raw)['exit_code']; + return typeof code === 'number' && Number.isSafeInteger(code) && code !== 0 ? `Exit code: ${code}` : undefined; +} + +/** ACP protocol state only. Generic projection owns public IDs, privacy and preview budgets. */ +export class AcpProjection { + private readonly tools = new Map(); + private active: Segment | null = null; + private thoughtRef: string | null = null; + private partial = ''; + private nextSegment = 0; + private toolEpoch = 0; + private stopped = false; + + constructor(private readonly projection: RuntimeProjection) {} + get partialText(): string { return this.partial; } + + update(params: unknown, nativeSessionId: string): void { + const frame = acpRecord(params); + if (!nativeSessionId || frame['sessionId'] !== nativeSessionId) throw new Error('acp_wrong_session'); + const update = acpRecord(frame['update']), kind = update['sessionUpdate']; + if (typeof kind !== 'string' || !kind) throw new Error('acp_invalid_update'); + switch (kind) { + case 'agent_message_chunk': this.message(update); break; + case 'agent_thought_chunk': this.thought(update); break; + case 'tool_call': case 'tool_call_update': this.tool(update, kind); break; + case 'user_message_chunk': this.closeSegment(); break; + default: break; // Config/plan/future metadata is not an assistant answer or terminal. + } + } + + private closeSegment(): void { this.active = null; this.thoughtRef = null; } + + private message(update: Record): void { + const id = update['messageId'] === undefined || update['messageId'] === null ? null : nativeId(update['messageId']); + if (update['content'] === undefined || update['content'] === null) throw new Error('acp_invalid_content'); + const content = acpText(update['content']); + // Check the cumulative raw bound before changing either answer accumulator. + if (content.text !== null && this.partial.length + content.text.length > FULLTEXT_MAX_CHARS) throw new Error('acp_text_limit'); + this.stopped = false; + this.thoughtRef = null; + if (this.active && this.active.nativeId !== id) this.active = null; + if (content.unsupported) { + this.active = null; + this.projection.text('message', 'unsupported-' + (++this.nextSegment), UNSUPPORTED_CONTENT, 'replace'); + } + if (content.text === null) return; + // A missing discriminator cannot separate consecutive anonymous same-type messages. + if (!this.active || this.active.nativeId !== id) { + this.active = { ref: 'message-' + (++this.nextSegment), nativeId: id, text: '' }; + } + this.partial += content.text; + this.active.text += content.text; + this.projection.text('message', this.active.ref, content.text, 'append', 'unknown'); + } + + private thought(update: Record): void { + if (update['content'] === undefined || update['content'] === null) throw new Error('acp_invalid_content'); + const content = acpText(update['content']); + this.stopped = false; + this.active = null; + if (!this.thoughtRef) this.thoughtRef = 'thought-' + (++this.nextSegment); + if (content.text !== null) this.projection.text('reasoning', this.thoughtRef, content.text, 'append'); + if (content.unsupported) this.projection.text('reasoning', this.thoughtRef, UNSUPPORTED_CONTENT, 'append'); + } + + private tool(update: Record, kind: string): void { + const id = nativeId(update['toolCallId']), prior = this.tools.get(id); + // A retired tool's replay is metadata, not a boundary for a newer answer. + if (prior !== undefined && prior !== 'running') return; + if (prior === undefined && this.tools.size >= TOOL_LIMIT) throw new Error('acp_tool_limit'); + const status = toolStatus(update['status'], prior), name = title(update['title']); + const content = acpText(update['content']); + const rawInput = update['rawInput'], rawOutput = update['rawOutput']; + const input = rawInput === undefined || rawInput === null ? undefined : acpSnapshot(rawInput); + const hasRawOutput = rawOutput !== undefined && rawOutput !== null; + const output = hasRawOutput ? acpSnapshot(rawOutput) : content.text ?? undefined; + const details = [kind === 'tool_call_update' ? name : undefined, + content.unsupported ? UNSUPPORTED_CONTENT : undefined, exitDetail(rawOutput)].filter(value => value !== undefined && value !== ''); + this.closeSegment(); + this.stopped = false; + this.tools.set(id, status); + this.projection.tool(this.toolRef(id), { status, + ...(name === undefined ? {} : { name }), + ...(input === undefined ? {} : { input, inputStructured: true }), + ...(output === undefined ? {} : { output, outputStructured: hasRawOutput && typeof rawOutput !== 'string' }), + ...(details.length ? { detail: details.join('\n') } : {}), + }); + } + + private toolRef(id: string): string { return JSON.stringify([this.toolEpoch, id]); } + + finalText(result: Record): string | null { + if (result['stopReason'] !== 'end_turn' || !this.active) return null; + this.projection.text('message', this.active.ref, this.active.text, 'replace', 'final'); + return this.active.text; // Raw authoritative value, including empty, independent of recording. + } + + /** Caller invokes this after the old attempt's terminal/drain fence, never on cancel dispatch. */ + stopTools(): void { + this.closeSegment(); + if (this.stopped) return; + for (const [id, status] of this.tools) { + if (status === 'running') this.projection.tool(this.toolRef(id), { status: 'stopped' }); + } + this.tools.clear(); + this.toolEpoch++; + this.stopped = true; + } +} diff --git a/src/agent/runtime/acp/replacement-turn.ts b/src/agent/runtime/acp/replacement-turn.ts new file mode 100644 index 000000000..07c9b58bb --- /dev/null +++ b/src/agent/runtime/acp/replacement-turn.ts @@ -0,0 +1,161 @@ +import { FULLTEXT_MAX_CHARS } from '../../events/fulltext-bound.js'; +import type { RuntimePrompt } from '../session.js'; +import type { AcpSession, AcpTurnOwner } from './session.js'; +import type { AcpProjection } from './projection.js'; +import type { AcpReplacementIO, AcpReplacementResult } from './replacement.js'; + +export interface AcpReplacementDriver { + readonly currentEpoch: number; + readonly isStopped: boolean; + readonly hasPendingReplacement: boolean; + first(prompt: RuntimePrompt): Promise; + replace(prompt: RuntimePrompt): Promise; + stop(): Promise; +} +export type AcpReplacementFactory = (io: AcpReplacementIO) => AcpReplacementDriver; +export type AcpPrepareReplacement = (instruction: string, partialText: string) => RuntimePrompt; +export type AcpApplicationReplacementResult = { accepted: true; epoch: number } + | { accepted: false; epoch: number; reason: string }; +type Result = Record; +type Attempt = { epoch: number; result: Promise }; +type Input = { original: RuntimePrompt; prepared?: RuntimePrompt; preparing: boolean }; +interface TurnOptions { + protocol: AcpSession; + owner: AcpTurnOwner; + projection: AcpProjection; + create: AcpReplacementFactory; + prepare?: AcpPrepareReplacement; + result?(result: Result): void; +} +function deferred() { + let resolve!: (value: T) => void, reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + void promise.catch(() => undefined); + return { promise, resolve, reject }; +} +function rejectAsync(value: unknown): void { + if (value !== null && (typeof value === 'object' || typeof value === 'function') + && typeof (value as { then?: unknown }).then === 'function') { + void Promise.resolve(value).catch(() => undefined); + throw new Error('acp_replacement_async_callback'); + } +} +function promptSnapshot(value: RuntimePrompt): RuntimePrompt { + if (!value || typeof value.text !== 'string' || value.text.length > FULLTEXT_MAX_CHARS || value.images?.length) { + throw new Error('acp_replacement_prompt_unsupported'); + } + return { text: value.text }; +} + +/** One app turn; protocol attempts are private and never publish separate terminal events. */ +export class AcpReplacementTurn { + private readonly control: AcpReplacementDriver; + private readonly done = deferred(); + private attempt: Attempt | null = null; + private ready: { epoch: number; result: Result } | null = null; + private input: Input | null = null; + private started = false; + private stopping = false; + private settled = false; + private busy = false; + private failure: Error | null = null; + + constructor(private readonly options: TurnOptions) { + this.control = options.create({ start: (prompt, epoch) => this.start(prompt, epoch), + cancelAndDrain: () => this.cancelAndDrain(), retire: error => this.fail(error) }); + } + run(prompt: RuntimePrompt): Promise { + if (this.started) throw new Error('acp_replacement_already_started'); + this.started = true; + void this.control.first(promptSnapshot(prompt)).catch(error => { + if (this.stopping && !this.attempt && this.options.protocol.idle) this.finish(); + else this.fail(error); + }); + return this.done.promise; + } + async steer(prompt: RuntimePrompt, onLocalDispatch?: () => void): Promise { + if (this.failure) throw this.failure; + const captured = promptSnapshot(prompt); + const noStart = (reason: string): AcpApplicationReplacementResult => ({ accepted: false, epoch: this.control.currentEpoch, reason }); + if (this.busy) return noStart('busy'); + if (this.settled || this.stopping || !this.attempt || this.options.protocol.idle || !this.options.protocol.alive) return noStart('not-running'); + if (!this.options.owner.isCurrent()) return noStart('not-current'); + this.busy = true; // Reserve before any control, preparation or observer can re-enter. + this.input = { original: captured, preparing: false }; + let attempted = false; + try { + const result = await this.control.replace(captured); + // Dispatch stays a wire fact; a retired app owner cannot commit its input. + if (result.accepted && !this.options.owner.isCurrent()) throw new Error('acp_replacement_not_current'); + if (result.accepted && onLocalDispatch) { + attempted = true; + try { rejectAsync(onLocalDispatch()); } + catch (cause) { throw new Error('acp_replacement_observer_failed', { cause }); } + } + return result; + } catch (error) { + // attempted is deliberately set before a potentially partial user-row commit. + this.fail(error instanceof Error ? error : new Error(attempted ? 'acp_replacement_observer_failed' : 'acp_replacement_failed')); + throw this.failure; + } finally { this.busy = false; this.input = null; this.finish(); } + } + async cancel(): Promise { + this.stopping = true; + try { await this.control.stop(); } + catch (error) { this.fail(error); throw error; } + finally { this.finish(); } + } + private start(prompt: RuntimePrompt, epoch: number): Promise { + if (!this.started || this.settled || this.stopping || !this.options.owner.isCurrent()) throw new Error('acp_replacement_not_current'); + const value = epoch > 1 && this.options.prepare ? this.input?.prepared : prompt; + if (!value) throw new Error('acp_replacement_prepare_missing'); + const captured = promptSnapshot(value); + const original = deferred(), dispatched = deferred(); + this.ready = null; + this.attempt = { epoch, result: original.promise }; // Before synchronous core callbacks. + const result = this.options.protocol.prompt([{ type: 'text', text: captured.text }], this.options.owner, frame => { + if (epoch !== this.control.currentEpoch) return; + if ('method' in frame) this.options.projection.update(frame.params, this.options.protocol.nativeSessionId); + }, { onDispatched: () => dispatched.resolve() }); + void result.then(original.resolve, error => { dispatched.reject(error); original.reject(error); }); + void original.promise.then(value => { + try { this.options.result?.(value); } catch { console.warn('[runtime:acp] optional result observer failed'); } + if (epoch === this.control.currentEpoch) this.ready = { epoch, result: value }; + this.finish(); + }, error => this.fail(error)); + return dispatched.promise; + } + private async cancelAndDrain(): Promise { + const original = this.attempt; + await this.options.protocol.cancel(); + const result = original ? await original.result : null; + if (!this.stopping && result?.['stopReason'] !== 'cancelled') throw new Error('acp_replacement_cancel_not_confirmed'); + if (!this.options.protocol.idle) throw new Error('acp_replacement_not_idle'); + this.options.projection.stopTools(); + const input = this.input; + if (this.stopping || !input || input.preparing || !this.options.prepare) return; + input.preparing = true; + let prepared: RuntimePrompt; + try { + const value = this.options.prepare(input.original.text, this.options.projection.partialText); + rejectAsync(value); prepared = promptSnapshot(value); + } catch (cause) { throw new Error('acp_replacement_prepare_failed', { cause }); } + if (this.stopping) return; // A synchronous preparation callback may request Stop. + if (!this.options.owner.isCurrent() || !this.options.protocol.idle) throw new Error('acp_replacement_not_current'); + input.prepared = prepared; + } + private fail(error: unknown): void { + if (!this.failure) this.failure = error instanceof Error ? error : new Error('acp_replacement_failed'); + this.options.protocol.retire(this.failure); + this.finish(); + } + private finish(): void { + if (this.settled || this.busy) return; + if (this.failure) { this.settled = true; this.done.reject(this.failure); return; } + if (this.ready && this.ready.epoch === this.control.currentEpoch && !this.control.hasPendingReplacement) { + this.settled = true; this.done.resolve(this.ready.result); + } else if (this.stopping && !this.attempt) { + this.settled = true; this.done.resolve({ stopReason: 'cancelled' }); + } + } +} diff --git a/src/agent/runtime/acp/replacement.ts b/src/agent/runtime/acp/replacement.ts new file mode 100644 index 000000000..d5c63f393 --- /dev/null +++ b/src/agent/runtime/acp/replacement.ts @@ -0,0 +1,139 @@ +import type { RuntimePrompt } from '../session.js'; + +export interface AcpReplacementIO { + /** Register synchronously; resolve after local write dispatch, never full inference. */ + start(prompt: RuntimePrompt, epoch: number): Promise; + /** Original cancelled response, callback/notification drain, prompt finally and idle. */ + cancelAndDrain(): Promise; + /** Retire the owner and settle pending dispatch promises, as AcpSession.retire does. */ + retire(error: Error): void; +} +export type AcpReplacementResult = { accepted: true; epoch: number } + | { accepted: false; epoch: number; reason: 'not-started' | 'stopped' | 'superseded' | 'capacity' }; + +/** A transport fault is fatal/indeterminate, never a retryable no-start result. */ +export class AcpReplacementError extends Error { + constructor(readonly stage: 'cancel' | 'dispatch', cause: unknown) { + super(`acp_replacement_${stage}_failed`, { cause }); + this.name = 'AcpReplacementError'; + } +} +const PENDING_REPLACEMENTS = 32; +function completion() { + let resolve!: () => void, reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +function copyPrompt(prompt: RuntimePrompt): RuntimePrompt { + return { text: prompt.text, ...(prompt.images === undefined ? {} : { + images: prompt.images.map(image => ({ mimeType: image.mimeType, data: image.data })), + }) }; +} + +/** One logical turn's control ordering. The wrapper owns events and its final promise. */ +export class AcpReplacement { + private epoch = 0; + private firstRequested = false; + private stopped = false; + private latestIntent = 0; + private pendingIntent = 0; + private pendingCount = 0; + private tail = Promise.resolve(); + private cancellation: { epoch: number; promise: Promise } | null = null; + private stopWork: Promise | null = null; + private failure: AcpReplacementError | null = null; + + constructor(private readonly io: AcpReplacementIO) {} + get currentEpoch(): number { return this.epoch; } + get isStopped(): boolean { return this.stopped; } + get hasPendingReplacement(): boolean { return this.pendingIntent !== 0; } + + async first(prompt: RuntimePrompt): Promise { + if (this.failure) throw this.failure; + if (this.stopped) throw new Error('acp_control_stopped'); + if (this.firstRequested) throw new Error('acp_control_already_started'); + const captured = copyPrompt(prompt); + this.firstRequested = true; + return this.serial(async () => { + if (this.stopped) throw this.failure ?? new Error('acp_control_stopped'); + this.epoch = 1; + await this.dispatch(captured, this.epoch); + }); + } + + async replace(prompt: RuntimePrompt): Promise { + if (this.failure) throw this.failure; + if (this.stopped) return this.noStart('stopped'); + if (this.epoch === 0) return this.noStart('not-started'); + if (this.pendingCount >= PENDING_REPLACEMENTS) return this.noStart('capacity'); + const captured = copyPrompt(prompt), intent = ++this.latestIntent; + this.pendingIntent = intent; + this.pendingCount++; + // Start the cancellation deadline now, even while an earlier write is held. + const cancelled = this.cancelActive(); + try { + return await this.serial(async () => { + await cancelled; // Fatal cancellation cannot become a retryable no-start. + if (this.failure) throw this.failure; + if (this.stopped) return this.noStart('stopped'); + if (intent !== this.latestIntent) return this.noStart('superseded'); + const epoch = ++this.epoch; + this.pendingIntent = 0; + this.cancellation = null; + await this.dispatch(captured, epoch); + // A later intent cannot retract an already initiated successful dispatch. + return { accepted: true, epoch }; + }); + } finally { this.pendingCount--; } + } + + stop(): Promise { + if (this.stopWork) return this.stopWork; + const done = completion(); + this.stopWork = done.promise; + void done.promise.catch(() => undefined); + this.stopped = true; + this.latestIntent++; + this.pendingIntent = 0; + if (this.failure) { done.reject(this.failure); return done.promise; } + // Reserve stopWork before IO: a reentrant stop receives the same completion. + const cancelled = this.cancelActive(); + void Promise.all([cancelled, this.tail]).then(() => done.resolve(), done.reject); + return done.promise; + } + + private noStart(reason: Extract['reason']): AcpReplacementResult { + return { accepted: false, epoch: this.epoch, reason }; + } + private cancelActive(): Promise { + if (this.epoch === 0) return Promise.resolve(); + if (this.cancellation?.epoch === this.epoch) return this.cancellation.promise; + const done = completion(); + const promise = done.promise.catch(error => { throw this.fail('cancel', error); }); + void promise.catch(() => undefined); + this.cancellation = { epoch: this.epoch, promise }; // reserve before reentrant IO + try { void Promise.resolve(this.io.cancelAndDrain()).then(done.resolve, done.reject); } + catch (error) { done.reject(error); } + return promise; + } + private async dispatch(prompt: RuntimePrompt, epoch: number): Promise { + try { await this.io.start(prompt, epoch); } + catch (error) { throw this.fail('dispatch', error); } + } + private fail(stage: AcpReplacementError['stage'], cause: unknown): AcpReplacementError { + if (this.failure) return this.failure; + const failure = new AcpReplacementError(stage, cause); + this.failure = failure; + this.stopped = true; + this.pendingIntent = 0; + this.latestIntent++; + try { this.io.retire(failure); } + catch (error) { failure.cause = new AggregateError([cause, error], 'acp_retirement_failed'); } + return failure; + } + private serial(work: () => Promise): Promise { + const next = this.tail.then(work); + this.tail = next.then(() => undefined, () => undefined); + return next; + } +} diff --git a/src/agent/runtime/acp/runtime-session.ts b/src/agent/runtime/acp/runtime-session.ts new file mode 100644 index 000000000..84df57089 --- /dev/null +++ b/src/agent/runtime/acp/runtime-session.ts @@ -0,0 +1,221 @@ +import type { RuntimeCapabilities, RuntimeEvent, RuntimeEventBody, RuntimeTurnOutcome } from '../../../shared/runtime-contract.js'; +import { parseRuntimeEvent } from '../../../shared/runtime-event-parse.js'; +import { FULLTEXT_MAX_CHARS } from '../../events/fulltext-bound.js'; +import { recordRuntimeEvent, type RuntimeEventContext } from '../events.js'; +import { RuntimeProjection, type RuntimeEnd } from '../projection.js'; +import { runtimeRequests, type RuntimeRequests } from '../requests.js'; +import type { NativeRuntimeSession, RuntimeInputAcceptance, RuntimePrompt } from '../session.js'; +import type { AcpSession } from './session.js'; +import { AcpProjection } from './projection.js'; +import { AcpReplacementTurn, type AcpReplacementFactory, type AcpPrepareReplacement } from './replacement-turn.js'; + +export interface AcpRuntimeTurnContext extends RuntimeEventContext { isCurrent(): boolean } +export interface AcpRuntimeSessionOptions { + provider: string; + capabilities: RuntimeCapabilities; + getTurnContext(): AcpRuntimeTurnContext; + deferTurnEnd?: boolean; + registry?: RuntimeRequests; + record?: typeof recordRuntimeEvent; + resultUsage?: (result: Record) => Extract | null; + createReplacement?: AcpReplacementFactory; + prepareReplacement?: AcpPrepareReplacement; +} +type Turn = { + context: RuntimeEventContext; + current(): boolean; + projection: RuntimeProjection; + acp: AcpProjection; + emit(body: RuntimeEventBody): RuntimeEvent | null; + cancelled: boolean; + replacement?: AcpReplacementTurn; +}; +type Pending = { turn: Turn; outcome: RuntimeTurnOutcome; claimed?: RuntimeTurnOutcome }; + +function snapshot(outcome: RuntimeTurnOutcome): RuntimeTurnOutcome { + return Object.freeze({ status: outcome.status, finalText: outcome.finalText, partialText: outcome.partialText }); +} +function failureCode(error: unknown): string { + const value = error instanceof Error ? error.message : ''; + return /^(?:acp|cursor_acp)_[a-z_]+$/.test(value) ? value : 'acp_runtime_failed'; +} + +/** One resident protocol session; every send captures a separate immutable app turn. */ +export class AcpRuntimeSession implements NativeRuntimeSession { + readonly capabilities: RuntimeCapabilities; + private active: Turn | null = null; + private pending: Pending | null = null; + private finalizing = false; + private closing = false; + private neutralClose = false; + private boundOwner: { sessionId: string; scope: string } | null = null; + private failure: string | null = null; + private readonly registry: RuntimeRequests; + private readonly createReplacement: AcpReplacementFactory | undefined; + + constructor(readonly protocol: AcpSession, private readonly options: AcpRuntimeSessionOptions) { + this.createReplacement = options.createReplacement; + this.capabilities = Object.freeze({ ...options.capabilities, transport: 'native', + steer: this.createReplacement ? 'cancel-reprompt' : 'restart', questions: false, images: false, subagents: false }); + this.registry = options.registry ?? runtimeRequests; + } + get alive(): boolean { return !this.closing && this.protocol.alive; } + get idle(): boolean { return this.alive && this.protocol.idle && !this.active && !this.pending && !this.finalizing; } + get nativeSessionId(): string { return this.protocol.nativeSessionId; } + get lastError(): string | null { return this.failure; } + + private capture(onEvent: (event: RuntimeEvent) => void): Turn { + const source = this.options.getTurnContext(); + const { runId, sessionId, scope, turnId, audience, parentItemId } = source; + const predicate = source.isCurrent; + const current = () => { try { return predicate() === true; } catch { return false; } }; + const context: RuntimeEventContext = Object.freeze({ runId, sessionId, scope, turnId, audience, + ...(parentItemId === undefined ? {} : { parentItemId }) }); + if ((audience !== 'public' && audience !== 'internal') || typeof predicate !== 'function' || !current() + || !parseRuntimeEvent({ ...context, version: 1, seq: 1, kind: 'turn-start', provider: this.options.provider })) { + throw new Error('acp_runtime_invalid_owner'); + } + if (this.boundOwner && (this.boundOwner.scope !== scope || this.boundOwner.sessionId !== sessionId)) { + throw new Error('acp_runtime_owner_changed'); + } + const record = this.options.record ?? recordRuntimeEvent; + let recording = true; + const emit = (body: RuntimeEventBody): RuntimeEvent | null => { + if (!recording) return null; + let event: RuntimeEvent | null; + try { event = record(context, body); } catch { event = null; } + if (!event) { recording = false; return null; } + try { onEvent(event); } catch { console.warn('[runtime:acp] event observer failed'); } + return event; + }; + const projection = new RuntimeProjection(context, (_context, body) => emit(body)); + this.boundOwner ??= { sessionId, scope }; + return { context, current, projection, acp: new AcpProjection(projection), emit, cancelled: false }; + } + + async send(prompt: RuntimePrompt, onEvent: (event: RuntimeEvent) => void): Promise { + if (!this.idle) throw new Error('acp_runtime_busy'); + if (typeof prompt.text !== 'string' || prompt.text.length > FULLTEXT_MAX_CHARS || prompt.images?.length) { + throw new Error('acp_runtime_prompt_unsupported'); + } + const turn = this.capture(onEvent); + this.active = turn; + this.failure = null; + turn.projection.start(this.options.provider); + let outcome: RuntimeTurnOutcome; + try { + if (turn.cancelled || !turn.current()) throw new Error('acp_runtime_cancelled_before_prompt'); + const owner = { + binding: turn.context, isCurrent: () => this.active === turn && turn.current(), + emit: turn.emit, ...(turn.context.parentItemId === undefined ? {} : { parentItemId: turn.context.parentItemId }), + }; + let result: Record; + if (this.createReplacement) { + turn.replacement = new AcpReplacementTurn({ protocol: this.protocol, owner, projection: turn.acp, + create: this.createReplacement, result: value => this.usage(turn, value), + ...(this.options.prepareReplacement ? { prepare: this.options.prepareReplacement } : {}) }); + result = await turn.replacement.run(prompt); + } else { + result = await this.protocol.prompt([{ type: 'text', text: prompt.text }], owner, + frame => { if ('method' in frame) turn.acp.update(frame.params, this.protocol.nativeSessionId); }); + this.usage(turn, result); + } + const status = turn.cancelled || !turn.current() || result['stopReason'] === 'cancelled' ? 'stopped' + : result['stopReason'] === 'end_turn' ? 'done' : 'error'; + const finalText = status === 'done' ? turn.acp.finalText(result) : null; + if (status !== 'done') turn.acp.stopTools(); + if (status === 'error') this.failure = 'acp_runtime_incomplete'; + outcome = snapshot({ status, finalText, partialText: turn.acp.partialText }); + } catch (error) { + this.failure = failureCode(error); + turn.acp.stopTools(); + try { await this.protocol.close(); } catch { this.failure = 'acp_runtime_cleanup_failed'; } + outcome = snapshot({ status: turn.cancelled || !turn.current() ? 'stopped' : 'error', finalText: null, partialText: turn.acp.partialText }); + } finally { + if (this.active === turn) this.active = null; + } + if (this.options.deferTurnEnd) this.pending = { turn, outcome }; + else { + this.finalizing = true; + try { turn.projection.close({ kind: 'turn-end', status: outcome.status, finalText: outcome.finalText, + ...(this.failure ? { error: this.failure } : {}) }); } + finally { this.finalizing = false; } + } + return outcome; + } + + private usage(turn: Turn, result: Record): void { + if (!this.options.resultUsage) return; + try { + const usage = this.options.resultUsage(result); + if (usage && !turn.emit(usage)) turn.projection.report('persistence'); + } catch { console.warn('[runtime:acp] optional usage unavailable'); } + } + + claimTurnOutcome(turnId: string): RuntimeTurnOutcome | null { + const pending = this.pending; + if (!pending || pending.turn.context.turnId !== turnId) return null; + if (!pending.claimed) { + if (!this.protocol.alive && !this.neutralClose && pending.outcome.status === 'done') { + pending.outcome = snapshot({ ...pending.outcome, status: 'error', finalText: null }); + this.failure ??= 'acp_runtime_failed'; + } + pending.claimed = snapshot(pending.outcome); + } + return pending.claimed; + } + + finalizeTurn(turnId: string, end: RuntimeEnd): boolean { + const pending = this.pending; + if (!pending || !pending.claimed || this.finalizing || pending.turn.context.turnId !== turnId) return false; + if (end.kind !== 'turn-end' || !['done', 'error', 'stopped'].includes(end.status) + || (end.finalText !== null && (typeof end.finalText !== 'string' || end.finalText.length > FULLTEXT_MAX_CHARS)) + || (end.status === 'done' && pending.claimed.status !== 'done')) return false; + this.finalizing = true; + this.pending = null; // retire the token before any event observer can re-enter + try { + pending.turn.projection.close({ kind: 'turn-end', status: end.status, finalText: end.finalText, + ...(end.error === undefined ? {} : { error: end.error }) }); + return true; + } finally { this.finalizing = false; } + } + + async steer(prompt: RuntimePrompt, onLocalDispatch?: () => void): Promise { + const turn = this.active ?? this.pending?.turn; + if (this.createReplacement) { + if (this.active?.replacement && !this.active.cancelled && this.active.current()) { + const result = await this.active.replacement.steer(prompt, onLocalDispatch); + return { mode: 'cancel-reprompt', accepted: result.accepted, turnId: turn!.context.turnId, + ...(!result.accepted ? { reason: result.reason } : {}) }; + } + return { mode: 'cancel-reprompt', accepted: false, turnId: turn?.context.turnId ?? '', reason: 'not-running' }; + } + return { mode: 'restart', accepted: false, turnId: turn?.context.turnId ?? '', reason: 'Use application restart steering' }; + } + + async cancel(): Promise { + if (this.active) this.active.cancelled = true; + if (this.pending && !this.pending.claimed) { + this.pending.outcome = snapshot({ ...this.pending.outcome, status: 'stopped', finalText: null }); + } + if (this.pending?.claimed) { await this.protocol.close(); return; } + if (this.active?.replacement) { await this.active.replacement.cancel(); return; } + await this.protocol.cancel(); + } + + async respond(requestId: string, response: unknown): Promise { + const turn = this.active; + if (!turn || !turn.current()) throw new Error('request_not_current'); + this.registry.respond(requestId, turn.context, response); + } + + async close(): Promise { + if (this.pending && !this.pending.claimed && !this.protocol.alive && !this.neutralClose) { + this.pending.outcome = snapshot({ ...this.pending.outcome, status: 'error', finalText: null }); + } + this.closing = true; + this.neutralClose = true; + if (this.active) this.active.cancelled = true; + await this.protocol.close(); + } +} diff --git a/src/agent/runtime/acp/session.ts b/src/agent/runtime/acp/session.ts new file mode 100644 index 000000000..1b89cb789 --- /dev/null +++ b/src/agent/runtime/acp/session.ts @@ -0,0 +1,289 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { isAbsolute } from 'node:path'; +import { ownProcess, type OwnedProcess, type OwnedProcessOptions } from '../../spawn/process-kill.js'; +import { releaseChildOutputAfterExit } from '../../spawn/exit-drain.js'; +import type { RuntimeRequests } from '../requests.js'; +import { AcpCallbacks, type AcpRequestOwner } from './callbacks.js'; +import { AcpConnection } from './connection.js'; +import { parseAcpSelectConfigs } from './config.js'; +import { AcpNotificationQueue } from './notification-queue.js'; +import type { RpcFrame } from './wire.js'; + +export type AcpTurnOwner = Omit; +export type AcpNotificationConsumer = (frame: RpcFrame, signal: AbortSignal) => void | Promise; +export interface AcpSessionOptions { + permissions: unknown; + promptTimeoutMs: number; + requestTimeoutMs?: number; + controlTimeoutMs?: number; + drainTimeoutMs?: number; + ownedProcessOptions?: OwnedProcessOptions; + registry?: RuntimeRequests; + clientMetadata?: Record; + failed?(error: Error): void; +} +type Request = ReturnType; +type Active = { + owner: AcpRequestOwner; queue: AcpNotificationQueue; consume: AcpNotificationConsumer; + request: Request | null; ready: Promise; resolveReady(value: Request | null): void; + terminal: boolean; stopped: boolean; cancelling: Promise | null; +}; + +export function acpRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('acp_invalid_object'); + return value as Record; +} +function acpString(value: unknown): string { + if (typeof value !== 'string' || !value || value.length > 1024) throw new Error('acp_invalid_string'); + return value; +} +export function validateAcpSessionOptions(options: AcpSessionOptions): void { + for (const timeout of [options.promptTimeoutMs, options.requestTimeoutMs ?? 30_000, + options.controlTimeoutMs ?? 5_000, options.drainTimeoutMs ?? 5_000]) { + if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) throw new Error('acp_invalid_timeout'); + } +} + +/** One process/session, reusable only after terminal, callback writes and notifications drain. */ +export class AcpSession { + readonly connection: AcpConnection; + private readonly owner: OwnedProcess; + private readonly callbacks: AcpCallbacks; + private readonly exited: Promise; + private cleanupExitDrain: (() => void) | null = null; + private active: Active | null = null; + private failure: Error | null = null; + private started = false; + private ready = false; + private controlBusy = false; + private replay = false; + private id = ''; + private configs: unknown = []; + private capabilities: Record = {}; + private stderrCount = 0; + + constructor(readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpSessionOptions) { + this.owner = ownProcess(child, options.ownedProcessOptions); + this.exited = new Promise(resolve => { child.once('exit', () => resolve()); child.once('close', () => resolve()); }); + let connection: AcpConnection | undefined; + try { + validateAcpSessionOptions(options); + if (!child.stdin || !child.stdout || !child.stderr) throw new Error('acp_stdio_unavailable'); + this.connection = connection = new AcpConnection(child, { frame: frame => this.frame(frame), failed: error => this.retire(error) }); + this.callbacks = new AcpCallbacks(this.connection, { permissions: options.permissions, + ...(options.registry === undefined ? {} : { registry: options.registry }), + getOwner: () => this.active && !this.active.terminal && this.alive ? this.active.owner : null }); + this.cleanupExitDrain = releaseChildOutputAfterExit(child); + child.stderr.on('data', this.consumeStderr); + child.stderr.on('error', this.stderrError); + child.once('close', () => this.cleanupIO()); + if (child.exitCode !== null || child.signalCode !== null) this.retire(new Error('acp_child_exit')); + } catch (error) { + connection?.close(new Error('acp_setup_failed')); + this.owner.terminate('startup-failed'); + throw error; + } + } + get alive(): boolean { return !this.failure && this.connection.alive; } + get idle(): boolean { return this.alive && this.ready && !this.controlBusy && !this.active && this.callbacks.idle; } + get nativeSessionId(): string { return this.id; } + get agentCapabilities(): Record { return structuredClone(this.capabilities); } + get stderrBytes(): number { return this.stderrCount; } + getConfigOptions(): unknown { return structuredClone(this.configs); } + + private consumeStderr = (chunk: Buffer) => { + this.stderrCount = Math.min(Number.MAX_SAFE_INTEGER, this.stderrCount + chunk.length); + }; + private stderrError = () => this.retire(new Error('acp_stderr_error')); + private cleanupIO(): void { + this.cleanupExitDrain?.(); this.cleanupExitDrain = null; + this.child.stderr?.off('data', this.consumeStderr); + this.child.stdin?.destroy(); this.child.stdout?.destroy(); this.child.stderr?.destroy(); + } + private updateConfigs(value: unknown): void { + this.configs = parseAcpSelectConfigs(value).map(config => ({ ...config, type: 'select' })); + } + private async rpc(method: string, params: unknown, apply?: (result: unknown) => void): Promise { + const request = this.connection.request(method, params, this.options.requestTimeoutMs ?? 30_000, + frame => { if ('result' in frame) apply?.(frame.result); }); + await request.dispatched; + return request.result; + } + + async start(input: { cwd: string; resumeSessionId?: string; authMethodId?: string }): Promise { + if (this.started || !this.alive) throw new Error('acp_start_unavailable'); + this.started = true; this.replay = true; + try { + if (!isAbsolute(input.cwd)) throw new Error('acp_invalid_cwd'); + const init = acpRecord(await this.rpc('initialize', { protocolVersion: 1, + clientInfo: { name: 'cli-jaw', version: '1' }, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, + ...(this.options.clientMetadata === undefined ? {} : { _meta: this.options.clientMetadata }) } })); + if (init['protocolVersion'] !== 1) throw new Error('acp_protocol_unsupported'); + this.capabilities = acpRecord(init['agentCapabilities']); + if (input.authMethodId !== undefined) { + const method = acpString(input.authMethodId); + const methods = init['authMethods']; + if (!Array.isArray(methods) || !methods.some(item => acpRecord(item)['id'] === method)) throw new Error('acp_auth_method_unavailable'); + await this.rpc('authenticate', { methodId: method }); + } + const load = input.resumeSessionId !== undefined; + if (load) { + if (this.capabilities['loadSession'] !== true) throw new Error('acp_resume_unsupported'); + this.id = acpString(input.resumeSessionId); + } + await this.rpc(load ? 'session/load' : 'session/new', { + ...(load ? { sessionId: this.id } : {}), cwd: input.cwd, mcpServers: [], + }, value => { + const setup = acpRecord(value); + if (!load) this.id = acpString(setup['sessionId']); + this.updateConfigs(setup['configOptions']); + }); + await this.bounded(this.callbacks.drain(), this.options.drainTimeoutMs ?? 5_000, 'acp_drain_timeout'); + this.assertAlive(); this.ready = true; + } catch (error) { this.retire(this.error(error)); throw error; } + finally { this.replay = false; } + } + + async setConfigOption(configId: string, value: string): Promise { + if (!this.idle) throw new Error('acp_config_busy'); + this.controlBusy = true; + try { + await this.rpc('session/set_config_option', { sessionId: this.id, configId, value }, result => { + const response = acpRecord(result); + if (!Array.isArray(response['configOptions'])) throw new Error('acp_missing_config_options'); + this.updateConfigs(response['configOptions']); + }); + await this.bounded(this.callbacks.drain(), this.options.drainTimeoutMs ?? 5_000, 'acp_drain_timeout'); + this.assertAlive(); + } catch (error) { this.retire(this.error(error)); throw error; } + finally { this.controlBusy = false; } + } + + async prompt(parts: ReadonlyArray, source: AcpTurnOwner, consume: AcpNotificationConsumer, + options?: { onDispatched?: () => void }): Promise> { + const onDispatched = options?.onDispatched; + if (onDispatched !== undefined && typeof onDispatched !== 'function') throw new Error('acp_invalid_dispatch_observer'); + const { runId, sessionId, scope, turnId } = source.binding; + const predicate = source.isCurrent, emit = source.emit, parentItemId = source.parentItemId; + const current = () => { try { return predicate() === true; } catch { return false; } }; + if (!this.idle || !current() || !this.idle) throw new Error('acp_prompt_unavailable'); + this.callbacks.beginRun(); + let resolveReady!: Active['resolveReady']; + const ready = new Promise(resolve => { resolveReady = resolve; }); + const active: Active = { owner: { binding: { runId, sessionId, scope, turnId }, nativeSessionId: this.id, emit, + ...(parentItemId === undefined ? {} : { parentItemId }), + isCurrent: () => this.active === active && this.alive && (active.stopped || current()) }, + queue: new AcpNotificationQueue(error => this.retire(error)), consume, request: null, ready, resolveReady, + terminal: false, stopped: false, cancelling: null }; + this.active = active; // callbacks may arrive synchronously from a test peer during write() + try { + const request = this.connection.request('session/prompt', { sessionId: this.id, prompt: parts }, this.options.promptTimeoutMs, () => { + active.terminal = true; active.queue.seal(); + this.callbacks.cancelRun(active.owner.binding.runId); + }); + active.request = request; resolveReady(request); + await request.dispatched; + // Completed local dispatch remains a fact even if cancellation or a terminal frame raced. + try { + const observation: unknown = onDispatched?.(); + if (observation !== null && (typeof observation === 'object' || typeof observation === 'function') + && typeof (observation as { then?: unknown }).then === 'function') { + void Promise.resolve(observation).catch(() => undefined); + throw new Error('acp_async_dispatch_observer'); + } + } + catch { throw new Error('acp_dispatch_observer_failed'); } + const result = acpRecord(await request.result); + const stopReason = result['stopReason']; + if (typeof stopReason !== 'string' || !['end_turn', 'cancelled', 'max_tokens', 'max_turn_requests', 'refusal'].includes(stopReason)) { + throw new Error('acp_invalid_stop_reason'); + } + await this.drain(active); + if (active.cancelling) await active.cancelling; + this.assertAlive(); + return result; + } catch (error) { this.retire(this.error(error)); throw error; } + finally { + resolveReady(null); + this.callbacks.cancelRun(active.owner.binding.runId); + if (this.active === active) this.active = null; + } + } + + cancel(): Promise { + const active = this.active; + if (!active) return Promise.resolve(); + if (active.cancelling) return active.cancelling; + active.stopped = true; + this.callbacks.cancelRun(active.owner.binding.runId); + const operation = (async () => { + const request = active.request ?? await active.ready; + if (!request) { this.assertAlive(); return; } + await request.dispatched; + await this.connection.notify('session/cancel', { sessionId: this.id }); + const result = acpRecord(await request.result); + if (result['stopReason'] !== 'cancelled') throw new Error('acp_cancel_raced_completion'); + await this.drain(active); + this.assertAlive(); + })(); + const cancelling = this.bounded(operation, this.options.controlTimeoutMs ?? 5_000, 'acp_cancel_timeout') + .catch(error => { this.retire(this.error(error)); throw error; }); + void cancelling.catch(() => undefined); + active.cancelling = cancelling; + return cancelling; + } + + private frame(frame: RpcFrame): void { + if (this.failure || !('method' in frame)) return; + const active = this.active; + if ('id' in frame) { + if (active?.terminal) { this.retire(new Error('acp_request_after_terminal')); return; } + this.callbacks.handle(frame); return; + } + if (frame.method !== 'session/update') return; // unsupported extensions are metadata-only + const params = acpRecord(frame.params); + if (this.id && params['sessionId'] !== this.id) throw new Error('acp_wrong_session'); + const update = acpRecord(params['update']); + if (update['sessionUpdate'] === 'config_option_update') { this.updateConfigs(update['configOptions']); return; } + if (this.replay || acpRecord(params['_meta'] ?? {})['isReplay'] === true) return; + if (!['agent_message_chunk', 'agent_thought_chunk', 'user_message_chunk', 'tool_call', 'tool_call_update', 'plan'].includes(String(update['sessionUpdate']))) return; + if (!active || active.terminal) { this.retire(new Error('acp_content_without_active_turn')); return; } + active.queue.enqueue(frame, active.consume); + } + private async drain(active: Active): Promise { + await this.bounded(Promise.all([active.queue.drain(), this.callbacks.drain(active.owner.binding.runId)]), + this.options.drainTimeoutMs ?? 5_000, 'acp_drain_timeout'); + } + private async bounded(operation: Promise, milliseconds: number, code: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([operation, new Promise((_resolve, reject) => { + timer = setTimeout(() => { const error = new Error(code); this.retire(error); reject(error); }, milliseconds); + })]); + } finally { if (timer) clearTimeout(timer); } + } + private error(error: unknown): Error { return error instanceof Error ? error : new Error('acp_session_failed'); } + private assertAlive(): void { if (!this.alive) throw this.failure ?? new Error('acp_retired'); } + + retire(error = new Error('acp_retired')): void { + if (this.failure) return; + this.failure = error; this.ready = false; + this.active?.queue.close(error); + this.callbacks?.dispose(); + this.connection?.close(error); + this.owner.terminate(error.message.includes('timeout') ? 'timeout' : 'cancel'); + try { this.options.failed?.(error); } catch { /* Local retirement is already complete. */ } + } + async close(): Promise { + this.retire(); + let timer: ReturnType | undefined; + try { + if (this.child.exitCode === null && this.child.signalCode === null) { + await Promise.race([this.exited, new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('acp_reap_timeout')), 6000); + })]); + } + } finally { if (timer) clearTimeout(timer); this.cleanupIO(); } + } +} diff --git a/src/agent/runtime/acp/wire.ts b/src/agent/runtime/acp/wire.ts new file mode 100644 index 000000000..840374197 --- /dev/null +++ b/src/agent/runtime/acp/wire.ts @@ -0,0 +1,43 @@ +/** ACP v1 single-envelope subset. Null and unsafe numeric IDs fail closed. */ +export type RpcId = string | number; +export type RpcFrame = + | { jsonrpc: '2.0'; id: RpcId; method: string; params?: unknown } + | { jsonrpc: '2.0'; method: string; params?: unknown } + | { jsonrpc: '2.0'; id: RpcId; result: unknown } + | { jsonrpc: '2.0'; id: RpcId; error: { code: number; message: string; data?: unknown } }; + +export function decodeFrame(line: string): RpcFrame { + let value: unknown; + try { value = JSON.parse(line); } + catch { throw new Error('acp_invalid_json'); } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('acp_invalid_frame'); + const x = value as Record; + if (x['jsonrpc'] !== '2.0') throw new Error('acp_invalid_version'); + const hasId = Object.hasOwn(x, 'id'); + const id = x['id']; + const validId = typeof id === 'string' || (typeof id === 'number' && Number.isSafeInteger(id)); + if (hasId && !validId) throw new Error('acp_invalid_id'); + if (Object.hasOwn(x, 'method')) { + const method = x['method']; + if (typeof method !== 'string' || !method || 'result' in x || 'error' in x) throw new Error('acp_invalid_method'); + const params = Object.hasOwn(x, 'params') ? { params: x['params'] } : {}; + if (hasId && validId) return { jsonrpc: '2.0', id, method, ...params }; + return { jsonrpc: '2.0', method, ...params }; + } + if (!hasId || !validId || Object.hasOwn(x, 'result') === Object.hasOwn(x, 'error')) { + throw new Error('acp_invalid_response'); + } + if ('error' in x) { + const raw = x['error']; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('acp_invalid_error'); + const e = raw as Record; + const code = e['code']; + const message = e['message']; + if (typeof code !== 'number' || !Number.isSafeInteger(code) || typeof message !== 'string') { + throw new Error('acp_invalid_error'); + } + return { jsonrpc: '2.0', id, error: { code, message, + ...(Object.hasOwn(e, 'data') ? { data: e['data'] } : {}) } }; + } + return { jsonrpc: '2.0', id, result: x['result'] }; +} diff --git a/src/agent/runtime/events.ts b/src/agent/runtime/events.ts index 4b44bb587..b21e078b2 100644 --- a/src/agent/runtime/events.ts +++ b/src/agent/runtime/events.ts @@ -1,5 +1,5 @@ import type { RuntimeEvent, RuntimeEventBody, RuntimeEventIdentity } from '../../shared/runtime-contract.js'; -import { appendTraceEvent } from '../../trace/store.js'; +import { appendActivityBody, markActivityFailure } from '../../trace/activity-journal.js'; import { publish } from '../../core/event-bus.js'; import { stringifyTraceValue } from '../../trace/redact.js'; import { encodeRuntimeBody, decodeRuntimeBody, RUNTIME_BODY_BYTES } from '../../trace/runtime-body-codec.js'; @@ -21,18 +21,22 @@ export function recordRuntimeEvent(context: RuntimeEventContext, body: RuntimeEv }; const encoded = encodeRuntimeBody(identity, body); const serialized = stringifyTraceValue(encoded.raw); - if (Buffer.byteLength(serialized, 'utf8') > RUNTIME_BODY_BYTES) return null; + if (Buffer.byteLength(serialized, 'utf8') > RUNTIME_BODY_BYTES) { + markActivityFailure(context, 'event_limit'); + return null; + } const raw: unknown = JSON.parse(serialized); // Validate what will actually be stored before consuming a trace seq. if (!decodeRuntimeBody(raw, identity, body.kind)) return null; - const pointer = appendTraceEvent({ runId: context.runId, source: 'runtime', - eventType: body.kind, raw, preview: body.kind }); + const pointer = appendActivityBody({ runId: context.runId, sessionId: context.sessionId, + scope: context.scope, audience: context.audience, eventType: body.kind, raw: encoded.raw }); if (!pointer) return null; const event = decodeRuntimeBody(raw, { ...identity, seq: pointer.traceSeq }, body.kind); if (!event) return null; if (context.audience === 'public') publish('agent', 'agent_runtime', { ...event }); return event; } catch { + markActivityFailure(context, 'storage_error'); console.warn('[runtime] projection_record_failed'); return null; } diff --git a/src/agent/runtime/print-activity.ts b/src/agent/runtime/print-activity.ts new file mode 100644 index 000000000..87bbeea48 --- /dev/null +++ b/src/agent/runtime/print-activity.ts @@ -0,0 +1,33 @@ +import type { RuntimeEventContext } from './events.js'; +import { markActivityFailure } from '../../trace/activity-journal.js'; +import { finalizeTraceRun } from '../../trace/store.js'; +import { RuntimeProjection, type RuntimeEnd } from './projection.js'; +import { createPrintActivityProjection, type PrintActivityProjection } from './print-projection.js'; + +/** Reuses canonical redaction, preview bounds and the existing per-run gap latch. */ +export function createPrintActivity(context: RuntimeEventContext, provider: string): PrintActivityProjection { + const projection: RuntimeProjection = new RuntimeProjection(context, undefined, reason => { + if (reason === 'capacity' || reason === 'truncated') { + markActivityFailure(context, 'run_limit'); + projection.report('persistence'); + } + }); + projection.start(provider); + return createPrintActivityProjection(body => { + switch (body.kind) { + case 'message': projection.text('message', body.itemId, body.text, body.operation, body.phase); break; + case 'reasoning': projection.text('reasoning', body.itemId, body.text, body.operation); break; + case 'tool': projection.tool(body.itemId, { name: body.name, status: body.status, + ...(body.detail === undefined ? {} : { detail: body.detail }) }, { allowTerminalUpdates: true }); break; + case 'turn-end': projection.close(body); break; + } + }); +} + +/** Existing spawn error/retry paths that bypass the normal lifecycle still close their trace. */ +export function finishPrintActivity(context: { printActivity?: PrintActivityProjection; traceRunId?: string }, end: RuntimeEnd): void { + try { context.printActivity?.finish(end); } + catch { console.warn('[activity:print] bypass_observer_failed'); } + try { finalizeTraceRun(context.traceRunId, end.status === 'stopped' ? 'interrupted' : end.status, end.error); } + catch { console.warn('[activity:print] bypass_trace_failed'); } +} diff --git a/src/agent/runtime/print-projection.ts b/src/agent/runtime/print-projection.ts new file mode 100644 index 000000000..b8e9496d2 --- /dev/null +++ b/src/agent/runtime/print-projection.ts @@ -0,0 +1,47 @@ +import type { RuntimeEventBody, RuntimePhase } from '../../shared/runtime-contract.js'; + +export type PrintToolInput = { icon: string; label: string; toolType: string; detail?: string; + stepRef?: string; traceSeq?: number; status?: string }; +export interface PrintActivityProjection { + nextMessage(): void; + message(text: string, operation: 'append' | 'replace', phase?: RuntimePhase): void; + reasoning(text: string, operation: 'append' | 'replace'): void; + tool(entry: PrintToolInput): void; + finish(end: Extract): void; +} + +/** Observes accepted print content, never selects an answer or retains output buffers. */ +export function createPrintActivityProjection(emit: (body: RuntimeEventBody) => void): PrintActivityProjection { + let message = 1, thought = 0, anonymous = 0; + let closed = false, failed = false, currentThought: string | null = null; + const send = (body: RuntimeEventBody): void => { + if (closed || failed) return; + try { emit(body); } + catch { failed = true; console.warn('[activity:print] observer_failed'); } + }; + return { + nextMessage() { if (!closed) { message++; currentThought = null; } }, + message(text, operation, phase = 'unknown') { + send({ kind: 'message', itemId: `print:message:${message}`, phase, text, operation }); + }, + reasoning(text, operation) { + if (operation === 'replace' || currentThought === null) currentThought = `print:reasoning:${++thought}`; + send({ kind: 'reasoning', itemId: currentThought, text, operation }); + }, + tool(entry) { + const itemId = entry.stepRef ? `print:ref:${entry.stepRef}` + : entry.traceSeq ? `print:trace:${entry.traceSeq}` : `print:anonymous:${++anonymous}`; + if (entry.icon === '💬') { + send({ kind: 'message', itemId, phase: 'unknown', text: entry.detail ?? entry.label, operation: 'replace' }); + } else if (entry.toolType === 'thinking' || entry.icon === '💭') { + send({ kind: 'reasoning', itemId, text: entry.detail ?? entry.label, operation: 'replace' }); + } else { + const status = entry.status === 'error' ? 'error' : entry.status === 'done' ? 'done' + : entry.status === 'stopped' ? 'stopped' : 'running'; + send({ kind: 'tool', itemId, name: entry.label, status, + ...(entry.detail === undefined ? {} : { detail: entry.detail }) }); + } + }, + finish(end) { if (!closed) { send(end); closed = true; } }, + }; +} diff --git a/src/agent/runtime/projection.ts b/src/agent/runtime/projection.ts index a11765292..c78f13786 100644 --- a/src/agent/runtime/projection.ts +++ b/src/agent/runtime/projection.ts @@ -78,6 +78,12 @@ export class RuntimeProjection { return createHash('sha256').update(JSON.stringify([kind, nativeRef])).digest('hex'); } + /** Published display linkage only; never allocation or approval authority. */ + itemId(kind: Preview['kind'], nativeRef: string): string | null { + if (this.recordingFailed) return null; + return this.items.get(this.key(kind, nativeRef))?.itemId ?? null; + } + private id(key: string): string | null { const found = this.items.get(key); if (found) return found.itemId; @@ -126,7 +132,7 @@ export class RuntimeProjection { catch { this.report('persistence'); return WITHHELD_PREVIEW; } } - private save(key: string, body: Preview): void { + private save(key: string, body: Preview, preserveTerminal = false): void { if (this.ended || this.recordingFailed) return; const previous = this.items.get(key); const previousJson = previous ? JSON.stringify(previous) : ''; @@ -144,6 +150,9 @@ export class RuntimeProjection { } } let json = JSON.stringify(next); + // Enrichment is optional. Never spend the budget by deleting already + // authoritative terminal fields to make room for newly learned metadata. + if (preserveTerminal && json.length > available) { this.report('capacity'); return; } // Charge JSON escaping too. Keep already retained IDs; no eviction/reopen. for (const field of fields.filter(field => field !== 'name').reverse()) { if (json.length <= available) break; @@ -160,12 +169,21 @@ export class RuntimeProjection { this.emit(next); } - tool(nativeRef: string, patch: ToolPatch): void { + tool(nativeRef: string, patch: ToolPatch, options: { allowTerminalUpdates?: boolean } = {}): void { if (this.ended || this.recordingFailed) return; const key = this.key('tool', nativeRef); const previous = this.items.get(key); const old = previous?.kind === 'tool' ? previous : undefined; - if (old && old.status !== 'running') return; + const replaceTerminal = options.allowTerminalUpdates === true && patch.status !== undefined && patch.status !== 'running'; + if (old && old.status !== 'running' && !replaceTerminal) { + // A result may precede its start. Fill only unknown metadata; terminal + // status, output, detail and established fields remain authoritative. + const name = (!old.name || old.name === 'tool') && patch.name ? patch.name : old.name; + const input = old.input === undefined && patch.input !== undefined + ? this.source(key, 'input', patch.input, false, patch.inputStructured) : old.input; + this.save(key, { ...old, name, ...(input === undefined ? {} : { input }) }, true); + return; + } const itemId = this.id(key); if (!itemId) return; const body: Tool = { kind: 'tool', itemId, name: old?.name && old.name !== 'tool' ? old.name : patch.name || 'tool', diff --git a/src/agent/runtime/replace-turn.ts b/src/agent/runtime/replace-turn.ts new file mode 100644 index 000000000..4f7f00026 --- /dev/null +++ b/src/agent/runtime/replace-turn.ts @@ -0,0 +1,43 @@ +import type { AcpRuntimeSession } from './acp/runtime-session.js'; + +/** Main-private receipt: dispatched means a local write, not remote acceptance. */ +export type MainReplacementResult = + | { kind: 'dispatched' } + | { kind: 'cancelled' } + | { kind: 'unavailable' | 'race'; reason: string } + | { kind: 'failed'; error: Error }; + +/** Fixed diagnostic: never include either conversation's private binding values. */ +export class MainReplacementOwnerMismatchError extends Error { + readonly code = 'native_replacement_owner_mismatch'; + constructor() { + super('native_replacement_owner_mismatch'); + this.name = 'MainReplacementOwnerMismatchError'; + } +} + +export async function replaceAcpMainTurn( + facade: AcpRuntimeSession | null, + text: string, + onLocalDispatch: () => void, +): Promise { + if (!facade) return { kind: 'unavailable', reason: 'native-turn-not-started' }; + let attempted = false; + try { + const receipt = await facade.steer({ text }, () => { + if (attempted) throw new Error('native_replacement_duplicate_dispatch'); + attempted = true; + return onLocalDispatch(); + }); + if (receipt.accepted !== attempted) throw new Error('native_replacement_inconsistent_receipt'); + if (receipt.accepted) return { kind: 'dispatched' }; + const reason = receipt.reason || 'not-started'; + if (reason === 'stopped') return { kind: 'cancelled' }; + return { kind: ['busy', 'superseded', 'not-current'].includes(reason) ? 'race' : 'unavailable', reason }; + } catch (cause) { + const error = cause instanceof Error ? cause : new Error('native_replacement_failed', { cause }); + // Also fence malformed receipts: no caller may retry an indeterminate write. + facade.protocol.retire(error); + return { kind: 'failed', error }; + } +} diff --git a/src/agent/runtime/requests.ts b/src/agent/runtime/requests.ts new file mode 100644 index 000000000..d445b0d79 --- /dev/null +++ b/src/agent/runtime/requests.ts @@ -0,0 +1,158 @@ +import { randomUUID } from 'node:crypto'; +import type { RuntimeEventIdentity, RuntimeRequestView } from '../../shared/runtime-contract.js'; +import { decodeRuntimeBody, encodeRuntimeBody, RUNTIME_BODY_BYTES, sanitizeRuntimeRequestView } from '../../trace/runtime-body-codec.js'; +import { stringifyTraceValue } from '../../trace/redact.js'; + +export type RuntimeRequestBinding = Pick; +type RequestType = 'approval' | 'question'; +type Entry = RuntimeRequestBinding & { + requestId: string; requestType: RequestType; view: RuntimeRequestView; expiresAt: number; + validate(value: unknown): unknown; isCurrent(): boolean; resolve(value: unknown): void; + cancelled: unknown; timer: ReturnType; +}; +const CAPACITY = 128; +const TTL_MS = 120_000; + +/** Bounded JSON trees only: reject shared references before cloning or expanding them. */ +function cancellationSnapshot(value: unknown): unknown { + try { + const seen = new Set(); + let bytes = 0, nodes = 0; + const consume = (size: number) => { + bytes += size; + if (bytes > RUNTIME_BODY_BYTES) throw new Error('limit'); + }; + const quote = (text: string) => { + if (text.length + 2 > RUNTIME_BODY_BYTES - bytes) throw new Error('limit'); + consume(Buffer.byteLength(JSON.stringify(text))); + }; + const copy = (item: unknown, depth: number): unknown => { + if (++nodes > RUNTIME_BODY_BYTES || depth > 32) throw new Error('limit'); + if (item === null) { consume(4); return null; } + if (typeof item === 'string') { quote(item); return item; } + if (typeof item === 'boolean') { consume(item ? 4 : 5); return item; } + if (typeof item === 'number' && Number.isFinite(item)) { consume(String(item).length); return item; } + if (!item || typeof item !== 'object' || seen.has(item)) throw new Error('non_json_tree'); + if (item instanceof Promise) { + void item.catch(() => undefined); + throw new Error('non_json_tree'); + } + const array = Array.isArray(item); + if (!array && Object.getPrototypeOf(item) !== Object.prototype + && Object.getPrototypeOf(item) !== null) throw new Error('non_json_tree'); + seen.add(item); consume(2); + const keys = Reflect.ownKeys(item).filter(key => !array || key !== 'length'); + if (keys.length > RUNTIME_BODY_BYTES - bytes || (array && keys.length !== item.length)) throw new Error('limit'); + const result: Record | unknown[] = array ? [] : {}; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]!; + if (typeof key !== 'string' || (array && key !== String(i))) throw new Error('non_json_tree'); + const descriptor = Object.getOwnPropertyDescriptor(item, key); + if (!descriptor?.enumerable || !('value' in descriptor)) throw new Error('non_json_tree'); + if (i) consume(1); + if (!array) { quote(key); consume(1); } + const child = copy(descriptor.value, depth + 1); + Object.defineProperty(result, key, { value: child, enumerable: true }); + } + return Object.freeze(result); + }; + return copy(value, 0); + } catch { throw new Error('invalid_cancellation'); } +} + +function requestView(identity: RuntimeEventIdentity, requestId: string, requestType: RequestType, input: unknown): RuntimeRequestView { + const view = sanitizeRuntimeRequestView(input); + if (!view) throw new Error('invalid_request_view'); + const encoded = encodeRuntimeBody(identity, { kind: 'request', requestId, requestType, view }); + const serialized = stringifyTraceValue(encoded.raw); + if (Buffer.byteLength(serialized) > RUNTIME_BODY_BYTES) throw new Error('request_view_limit'); + const event = decodeRuntimeBody(JSON.parse(serialized), identity, 'request'); + if (event?.kind !== 'request') throw new Error('invalid_request_view'); + for (const field of event.view.fields) { + for (const option of field.options) Object.freeze(option); + Object.freeze(field.options); Object.freeze(field); + } + Object.freeze(event.view.fields); + return Object.freeze(event.view); +} + +/** Ephemeral decisions, not a session cache. Native IDs remain inside validators only. */ +export class RuntimeRequests { + private readonly entries = new Map(); + + open(input: RuntimeRequestBinding & { + parentItemId?: string; requestType: RequestType; view: unknown; + validate(value: unknown): T; cancelled: T; isCurrent(): boolean; + }) { + this.prune(); + if (this.entries.size >= CAPACITY) throw new Error('request_capacity'); + const { runId, sessionId, scope, turnId, parentItemId, requestType, validate, isCurrent } = input; + if (typeof validate !== 'function' || typeof isCurrent !== 'function') throw new Error('invalid_request'); + if (!this.current({ expiresAt: Infinity, isCurrent })) throw new Error('request_not_current'); + const cancelled = cancellationSnapshot(input.cancelled); + const requestId = randomUUID(); + const identity: RuntimeEventIdentity = { version: 1, seq: 1, runId, sessionId, scope, turnId, + ...(parentItemId === undefined ? {} : { parentItemId }) }; + const view = requestView(identity, requestId, requestType, input.view); + if (!this.current({ expiresAt: Infinity, isCurrent })) throw new Error('request_not_current'); + if (this.entries.size >= CAPACITY) throw new Error('request_capacity'); + let resolve!: (value: T) => void; + const answer = new Promise(yes => { resolve = yes; }); + const expiresAt = Date.now() + TTL_MS; + const timer = setTimeout(() => this.finish(entry, cancelled), TTL_MS); + const entry: Entry = { runId, sessionId, scope, turnId, requestType, requestId, view, expiresAt, + validate, isCurrent, cancelled, timer, + // Only this entry's validated answer or its captured cancellation reaches finish. + resolve: value => resolve(value as T) }; + this.entries.set(requestId, entry); + return { requestId, answer, expiresAt, view, cancel: () => this.finish(entry, cancelled) }; + } + + list(sessionId: string) { + this.prune(); + return [...this.entries.values()].filter(e => e.sessionId === sessionId).map(e => ({ + runId: e.runId, sessionId: e.sessionId, scope: e.scope, turnId: e.turnId, + requestId: e.requestId, requestType: e.requestType, expiresAt: e.expiresAt, + view: structuredClone(e.view), + })); + } + + respond(requestId: string, binding: RuntimeRequestBinding, response: unknown): void { + const entry = this.entries.get(requestId); + if (!entry || entry.runId !== binding.runId || entry.sessionId !== binding.sessionId + || entry.scope !== binding.scope || entry.turnId !== binding.turnId) throw new Error('request_not_current'); + this.requireCurrent(entry); + const validated = entry.validate(response); + if (validated !== null && (typeof validated === 'object' || typeof validated === 'function') + && typeof (validated as { then?: unknown }).then === 'function') { + void Promise.resolve(validated).catch(() => undefined); + throw new Error('invalid_response'); + } + this.requireCurrent(entry); + this.finish(entry, validated); + } + + cancelRun(runId: string): void { + for (const entry of [...this.entries.values()]) if (entry.runId === runId) this.finish(entry, entry.cancelled); + } + private current(entry: Pick): boolean { + try { return entry.expiresAt > Date.now() && entry.isCurrent() === true; } + catch { return false; } + } + private requireCurrent(entry: Entry): void { + if (this.current(entry) && this.entries.get(entry.requestId) === entry) return; + this.finish(entry, entry.cancelled); + throw new Error('request_not_current'); + } + private prune(): void { + for (const entry of [...this.entries.values()]) if (!this.current(entry)) this.finish(entry, entry.cancelled); + } + private finish(entry: Entry, response: unknown): void { + if (this.entries.get(entry.requestId) !== entry) return; + this.entries.delete(entry.requestId); + clearTimeout(entry.timer); + entry.resolve(response); + } +} + +export const runtimeRequests = new RuntimeRequests(); diff --git a/src/agent/runtime/selection.ts b/src/agent/runtime/selection.ts index 052271f46..338d67b5e 100644 --- a/src/agent/runtime/selection.ts +++ b/src/agent/runtime/selection.ts @@ -24,7 +24,7 @@ export function isNativeSessionBucket(bucket: string): boolean { /** Compiled adapter support, not binary availability or authentication proof. */ export function isNativeAdapterImplemented(cli: string): boolean { - return cli === 'codex-app' || cli === 'pi'; + return cli === 'codex-app' || cli === 'pi' || cli === 'cursor'; } /** Independent of main support; switchable workers are enabled only with ownership support. */ diff --git a/src/agent/runtime/session.ts b/src/agent/runtime/session.ts index cea762012..5d20de6ea 100644 --- a/src/agent/runtime/session.ts +++ b/src/agent/runtime/session.ts @@ -1,4 +1,4 @@ -import type { RuntimeCapabilities, RuntimeEvent, RuntimeTurnOutcome } from '../../shared/runtime-contract.js'; +import type { RuntimeCapabilities, RuntimeEvent, RuntimeEventBody, RuntimeTurnOutcome } from '../../shared/runtime-contract.js'; export interface RuntimePrompt { text: string; @@ -16,6 +16,10 @@ export interface NativeRuntimeSession { readonly capabilities: RuntimeCapabilities; readonly nativeSessionId: string; send(prompt: RuntimePrompt, onEvent: (event: RuntimeEvent) => void): Promise; + /** Main-only completion handshake; absent on adapters using immediate terminals. */ + claimTurnOutcome?(turnId: string): RuntimeTurnOutcome | null; + /** Accepts the captured logical terminal, not proof that a journal write succeeded. */ + finalizeTurn?(turnId: string, end: Extract): boolean; steer(prompt: RuntimePrompt): Promise; cancel(): Promise; respond(requestId: string, response: unknown): Promise; diff --git a/src/agent/spawn.ts b/src/agent/spawn.ts index de7aaa255..d540ff063 100644 --- a/src/agent/spawn.ts +++ b/src/agent/spawn.ts @@ -38,6 +38,7 @@ import { saveUpload as _saveUpload, buildMediaPrompt, buildMediaPromptMany, type import { resolveMainCli, consumePendingBootstrapPrompt, peekPendingBootstrapPrompt } from '../core/main-session.js'; import { getSessionOwnershipGeneration, + isCurrentSessionOwner, persistMainSession, } from './session-persistence.js'; import { isCompactMarkerRow } from '../core/compact.js'; @@ -47,7 +48,7 @@ import { sanitizeWorkerProgressTools } from '../orchestrator/worker-progress.js' import { handleAgentExit, setSpawnAgent, setMainMetaHandler } from './lifecycle-handler.js'; import { buildServicePath } from '../core/runtime-path.js'; import { formatCliUnavailableMessage, detectCliBinary } from '../core/cli-detect.js'; -import { LOCAL_SESSION_SCOPE_ACTIVATION, resolveOrcScope } from '../orchestrator/scope.js'; +import { LOCAL_SESSION_SCOPE_ACTIVATION, resolveExecutionBinding } from '../orchestrator/scope.js'; import { stripInterviewTracker } from '../orchestrator/sanitize.js'; import { beginLiveRun, appendLiveRunText, setLiveRunTraceId, clearLiveRun, replaceLiveRunTools, appendLiveRunTool, getLiveRun } from './live-run-state.js'; import { @@ -56,7 +57,9 @@ import { setSpawnRef as setMemorySpawnRef, } from './memory-flush-controller.js'; import { applyCliEnvDefaults, buildSessionResumeKey, ensureOpencodeAlwaysAllowPermissions, mergeEnvWindowsSafe } from './spawn-env.js'; -import { buildPromptForArgs, shouldBuildHistoryBlock, withHistoryPrompt, withSteerContext } from './prompt-context.js'; +import { buildPromptForArgs, shouldBuildHistoryBlock, withHistoryPrompt, withSteerContext, + PROMPT_HISTORY_MAX_ROWS, PROMPT_HISTORY_MAX_CHARS, appendCursorAcceptedInstruction, + buildCursorReplacementPrompt, type CursorAcceptedContext } from './prompt-context.js'; import { attachWatchdog, DEFAULT_WATCHDOG_ABSOLUTE_HARD_CAP_MS } from './watchdog.js'; import { buildOpencodeRuntimeSnapshot, @@ -65,18 +68,28 @@ import { resolveOpencodeBinary, } from './opencode-diagnostics.js'; import type { SpawnContext, ToolEntry } from '../types/agent.js'; -import type { RuntimeTurnOutcome } from '../shared/runtime-contract.js'; -import { RuntimeProjection } from './runtime/projection.js'; +import type { RuntimeEvent, RuntimeLivenessIdentity, RuntimeTurnOutcome } from '../shared/runtime-contract.js'; +import { handoffRuntimeOutcome } from './runtime/outcome.js'; +import { AcpRuntimeSession } from './runtime/acp/runtime-session.js'; +import { MainReplacementOwnerMismatchError, replaceAcpMainTurn, type MainReplacementResult } from './runtime/replace-turn.js'; +import { AcpReplacement } from './runtime/acp/replacement.js'; +import { beginSteerInput, cancelSteerInputs, cancelAllSteerInputs } from './steer-input-guard.js'; +import { runNativeRuntime, NativeRunFailure, type NativeRunLease } from './native-runtime-run.js'; +import { syncLiveTools } from './events/helpers.js'; +import { RuntimeProjection, type RuntimeEnd } from './runtime/projection.js'; +import { recordRuntimeEvent } from './runtime/events.js'; import { CodexProjection } from './runtime/codex-projection.js'; import { PiProjection } from './runtime/pi-projection.js'; import { PiRawTrace } from './runtime/pi-raw-trace.js'; +import { createPrintActivity, finishPrintActivity } from './runtime/print-activity.js'; import { isNativeAdapterImplemented, isNativeWorkerImplemented, isSwitchableNativeCli, resolveRuntimeTransport, runtimeSessionBucket } from './runtime/selection.js'; import { asCliEventRecord, discriminate, fieldString, type CliEventRecord } from '../types/cli-events.js'; -import type { RemoteTarget } from '../messaging/types.js'; +import { isRemoteTarget, type RemoteTarget } from '../messaging/types.js'; +import { buildRemoteBindingKey } from '../messaging/session-key.js'; import { isJawRuntimeEvent, handleJawRuntimeEvent } from './claude-e-runtime.js'; import { jawRuntimesByScope, runtimeForScope } from './jwc-runtime.js'; import { applyOutputPolicy, runBeforeSpawnChecks, type PolicyVerdict } from '../core/policy-hooks.js'; -import { appendTraceEvent, finalizeTraceRun, stampTraceTool, startTraceRun } from '../trace/store.js'; +import { appendTraceEvent, createTraceId, finalizeTraceRun, stampTraceTool, startTraceRun, updateTraceToolRow } from '../trace/store.js'; import { AGY_COMPLETE_KILL_REASON, appendAgyFullText, @@ -200,6 +213,8 @@ export type MainRunState = { * queues with a reason broadcast). */ steerTurnInBand?: (text: string) => Promise<'steered' | 'unavailable' | 'rejected'>; + /** Native local-dispatch hook; failure must never become queued input. */ + replaceTurn?: (text: string, commitInput: () => void) => Promise; }; export const activeMainProcesses = new Map(); @@ -329,9 +344,10 @@ function broadcastAgentOutput( agentId: agentLabel, cli, text, - ...(ctx.traceRunId ? { traceRunId: ctx.traceRunId } : {}), ...(textLen !== null ? { textLen } : {}), ...empTag, + ...(ctx.traceRunId ? { traceRunId: ctx.traceRunId } : {}), + ...(ctx.activityIdentity ?? {}), }, audience); } @@ -363,6 +379,7 @@ function emitKiroStreamEvents( if (event.kind === 'assistant_delta') { const segment = normalizeAssistantDisplayText(event.text); if (!segment) continue; + ctx.printActivity?.message(segment, 'append', 'unknown'); if (ctx.liveOutputText !== undefined) { ctx.liveOutputText += segment; } @@ -636,6 +653,7 @@ export function killActiveAgent(reason?: string): boolean; export function killActiveAgent(scopeKeyOrReason = 'user', scopedReason?: string): boolean { const scopeKey = scopedReason === undefined ? 'default' : scopeKeyOrReason; const reason = scopedReason ?? scopeKeyOrReason; + cancelSteerInputs(scopeKey); const run = activeMainProcesses.get(scopeKey); const hadTimer = queueCtrl.isRetryPending(scopeKey); const cancelledPendingMain = run?.cancelPending ? (run.cancelPending(reason), true) : false; @@ -652,7 +670,7 @@ export function killActiveAgent(scopeKeyOrReason = 'user', scopedReason?: string settleOnce(run?.meta?.requestId, 'cancelled', { reason }); clearWorkerSlotsOnStop(scopeKey, reason); } - if (run?.cancelTurn && (getActiveMainCli(scopeKey) === 'codex-app' || getActiveMainCli(scopeKey) === 'pi')) { + if (run?.cancelTurn && ['codex-app', 'pi', 'cursor'].includes(getActiveMainCli(scopeKey) || '')) { if (run.process?.pid) killReasons.set(run.process.pid, reason); console.log(`[jaw:kill] reason=${reason} scope=${scopeKey} cli=${getActiveMainCli(scopeKey)} action=lease.cancel`); if (reason === 'steer' || reason === 'interrupt') armExitSettle(scopeKey); @@ -699,6 +717,7 @@ export function killActiveAgent(scopeKeyOrReason = 'user', scopedReason?: string } export function killAllAgents(reason = 'user') { + cancelAllSteerInputs(); const hadTimer = queueCtrl.isRetryPending(null); const mainScopes = [...activeMainProcesses.keys()]; let killedMain = false; @@ -776,11 +795,12 @@ export function waitForAllProcessesEnd(timeoutMs = 2000): Promise { export function canSteerAgent(scopeKey: string): boolean { const run = activeMainProcesses.get(scopeKey); if (run?.meta.cli === 'jwc' && jawRuntimesByScope.get(scopeKey)?.busy === true) return true; - // codex-app: the hook exists exactly while a steerable turn is in flight. - return typeof run?.steerTurnInBand === 'function'; + // Route CLI steering through either the in-band hook or the native replacement hook. + // Each owning hook decides whether the current turn can still accept the input. + return typeof run?.steerTurnInBand === 'function' || typeof run?.replaceTurn === 'function'; } -export type SteerOutcome = 'steered' | 'fallback-queue' | 'new-run'; +export type SteerOutcome = 'steered' | 'fallback-queue' | 'new-run' | 'cancelled'; export async function steerAgent( scopeKey: string, @@ -802,6 +822,56 @@ export async function steerAgent( settleOnce(meta?.requestId, 'steered'); return 'steered'; } + if (typeof run?.replaceTurn === 'function') { + const capturedSessionOwner = getSessionOwnershipGeneration(scopeKey); + const owner = run.meta; + const ownerTarget = isRemoteTarget(owner.target) ? owner.target : undefined; + const ownerRemoteKey = owner.remoteKey ?? (ownerTarget ? buildRemoteBindingKey(ownerTarget) : undefined); + const suppliedTarget = meta?.target; + if ((meta?.chatSessionId !== undefined && meta.chatSessionId !== owner.chatSessionId) + || (meta?.remoteKey !== undefined && meta.remoteKey !== ownerRemoteKey) + || (suppliedTarget !== undefined && (!isRemoteTarget(suppliedTarget) + || buildRemoteBindingKey(suppliedTarget) !== ownerRemoteKey + || (ownerTarget !== undefined && (buildRemoteBindingKey(suppliedTarget) !== buildRemoteBindingKey(ownerTarget) + || suppliedTarget.targetKind !== ownerTarget.targetKind + || suppliedTarget.guildId !== ownerTarget.guildId + || suppliedTarget.parentTargetId !== ownerTarget.parentTargetId))))) { + throw new MainReplacementOwnerMismatchError(); + } + const capturedMeta = { ...meta, ...(suppliedTarget === undefined ? {} : { target: { ...suppliedTarget } }) }; + const capturedOwnerGeneration = run.ownerGeneration; + const workingDir = settings['workingDir'] || null; + let attempted = false; + const inputGuard = beginSteerInput(scopeKey); + let outcome: MainReplacementResult, inputCancelled: boolean; + try { + outcome = await run.replaceTurn(newPrompt, () => { + if (activeMainProcesses.get(scopeKey) !== run || run.ownerGeneration !== capturedOwnerGeneration + || !isCurrentSessionOwner(capturedSessionOwner, scopeKey)) { + throw new MainReplacementOwnerMismatchError(); + } + if (attempted) throw new Error('native_replacement_duplicate_input'); + attempted = true; // A partial recording failure must never retry this input. + insertMessage.run('user', newPrompt, source, '', workingDir, chatSessionId); + broadcast('new_message', { role: 'user', content: newPrompt, source, scope: scopeKey, sessionId: chatSessionId }); + broadcast('steer_started', stripUndefined({ prompt: newPrompt, origin: source || 'web', scope: scopeKey, + sessionId: chatSessionId, target: capturedMeta.target, chatId: capturedMeta.chatId, + requestId: capturedMeta.requestId, remoteKey: capturedMeta.remoteKey, replyViaTarget: capturedMeta.replyViaTarget, + mode: 'cancel-reprompt', localDispatch: true })); + settleOnce(capturedMeta.requestId, 'steered'); + }); + } finally { inputCancelled = inputGuard.isCancelled(); inputGuard.release(); } + if (outcome.kind === 'failed') throw outcome.error; + if ((outcome.kind === 'dispatched') !== attempted) throw new Error('native_replacement_inconsistent_receipt'); + if (outcome.kind === 'dispatched') return 'steered'; + if (outcome.kind === 'cancelled' || inputCancelled) { + settleOnce(capturedMeta.requestId, 'cancelled', { reason: 'native-steer-stopped', scope: scopeKey, sessionId: chatSessionId }); + return 'cancelled'; + } + broadcast('steer_rejected', stripUndefined({ prompt: newPrompt, origin: source || 'web', scope: scopeKey, + sessionId: chatSessionId, reason: outcome.reason, requestId: capturedMeta.requestId })); + return 'fallback-queue'; + } if (typeof run?.steerTurnInBand === 'function') { // codex-app same-turn steer. The user row is written only AFTER the // server accepts — a fallback must not leave a duplicate insert for the @@ -932,7 +1002,8 @@ export function makeCleanEnv( return merged; } -function buildHistoryBlock(currentPrompt: string, workingDir: string | null | undefined, chatSessionId: string, maxSessions = 10, maxTotalChars = 8000) { +function buildHistoryBlock(currentPrompt: string, workingDir: string | null | undefined, chatSessionId: string, + maxSessions = PROMPT_HISTORY_MAX_ROWS, maxTotalChars = PROMPT_HISTORY_MAX_CHARS) { const recent = getRecentMessages.all(workingDir || null, chatSessionId, Math.max(1, maxSessions * 2)) as RecentMessageRow[]; if (!recent.length) return ''; @@ -1031,6 +1102,7 @@ import { CodexAppClient, CodexSteerError, isRecoverableResumeError } from './cod import { acquireCodexAppRuntime, acquirePiRuntime, + acquireCursorRuntime, type PiLease, } from './runtime-pool.js'; import { @@ -1052,7 +1124,7 @@ export type { QueueController } from './spawn/queue.js'; export interface SpawnLifecycle { - onActivity?: (source: string) => void; + onActivity?: (source: string, identity?: RuntimeLivenessIdentity) => void; onExit?: (code: number | null) => void; } @@ -1126,27 +1198,13 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const empTag = isEmployee ? { isEmployee: true } : {}; const multiSessionEnabled = settings["multiSession"]?.enabled === true; const capturedScope = currentSessionScope(); - const scopeKey = multiSessionEnabled - ? (opts.scopeKey || capturedScope?.scope || resolveOrcScope(stripUndefined({ - origin, - target: opts.target, - chatId: opts.chatId, - workingDir: settings["workingDir"] || null, - persistedScopeId: opts.remoteKey, - multiSessionEnabled, - }))) - : 'default'; - const chatSessionId = multiSessionEnabled - ? (opts.chatSessionId || capturedScope?.chatSessionId || getActiveChatSession()) - : getActiveChatSession(); - if (multiSessionEnabled) { - opts = stripUndefined({ - ...opts, - scopeKey, - chatSessionId, - ...(opts.remoteKey ? { remoteKey: opts.remoteKey } : {}), - }); - } + const binding = resolveExecutionBinding(stripUndefined({ scope: opts.scopeKey, chatSessionId: opts.chatSessionId, + captured: capturedScope, activeChatSessionId: getActiveChatSession(), origin, + target: opts.target, chatId: opts.chatId, workingDir: settings['workingDir'] || null, + persistedScopeId: opts.remoteKey, multiSessionEnabled })); + const scopeKey = binding.scope, chatSessionId = binding.chatSessionId; + opts = stripUndefined({ ...opts, scopeKey, chatSessionId, + ...(opts.remoteKey ? { remoteKey: opts.remoteKey } : {}) }); let mainRun = mainManaged ? activeMainProcesses.get(scopeKey) : undefined; if (mainManaged && mainRun && !opts._settingsGateWaited) { @@ -1169,14 +1227,15 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { console.log('[jaw] Agent already running, skipping'); return { child: null, promise: Promise.resolve({ text: '', code: -1 }) }; } - mainRun!.starting = true; + const waitingRun = mainRun!; + waitingRun.starting = true; let cancelled = false; let cancelReason = 'user'; const cancelThisSpawn = (reason: string) => { cancelled = true; cancelReason = reason; }; - mainRun!.cancelPending = cancelThisSpawn; + waitingRun.cancelPending = cancelThisSpawn; const promise: Promise = (async () => { try { await waitForRuntimeSettingsIdle(); @@ -1187,14 +1246,51 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { return await next.promise; } finally { const latest = activeMainProcesses.get(scopeKey); - if (latest?.cancelPending === cancelThisSpawn) delete latest.cancelPending; - if (latest) latest.starting = false; + if (latest === waitingRun) { + if (latest.cancelPending === cancelThisSpawn) delete latest.cancelPending; + latest.starting = false; + } void processQueue(scopeKey); } })(); return { child: null, promise }; } + let resolve: (value: SpawnPromiseResult) => void; + const resultPromise = new Promise(r => { resolve = r; }); + + const session = (getSession() as SessionRow | undefined) ?? {}; + const persistenceOwner = getSessionOwnershipGeneration(scopeKey); + const ownerGeneration = persistenceOwner.global; + if (mainRun) mainRun.ownerGeneration = ownerGeneration; + let cli = resolveMainCli(opts.cli, settings, session); + if (mainRun) mainRun.meta.cli = cli; + + // Namespace selection is captured once for this run. Builtin native + // Codex/Pi keep their existing bucket keys; only switchable adapters use + // the new namespace. Reject unavailable native choices before fallback, + // saved-session reads, bootstrap consumption, or worker isolation. + const runtimeTransport = isSwitchableNativeCli(cli) + ? resolveRuntimeTransport(settings['perCli']?.[cli]?.transport) : 'print'; + const permissions = opts.permissions || settings['permissions'] || session.permissions || 'auto'; + const unavailableNative = runtimeTransport === 'native' + && (!isNativeAdapterImplemented(cli) || (isEmployee && !isNativeWorkerImplemented(cli))); + const restrictiveNative = runtimeTransport === 'native' && cli === 'cursor' && permissions !== 'auto'; + if (unavailableNative || restrictiveNative) { + const message = unavailableNative + ? `${cli} native ${isEmployee ? 'worker ' : ''}transport is not implemented in this build. Set perCli.${cli}.transport to "print" to use compatibility mode.` + : 'Cursor native restrictive permissions are not verified in this build. Select print transport to retain restrictive permission behavior.'; + const released = mainManaged && activeMainProcesses.get(scopeKey) === mainRun + && releaseMainRun(scopeKey, null, ownerGeneration); + broadcast('agent_done', { + text: message, error: true, origin, cli, scope: scopeKey, sessionId: chatSessionId, + ...(opts.requestId ? { requestId: opts.requestId } : {}), ...empTag, + }, isEmployee ? 'internal' : 'public'); + resolve!({ text: message, code: 78 }); + if (released) void processQueue(scopeKey); + return { child: null, promise: resultPromise }; + } + // Ensure AGENTS.md on disk is fresh before CLI reads it // Skip for employee spawns — distribute.ts manages AGENTS.md isolation if (!opts.internal && !opts._isFallback && !opts.agentId) regenerateB(); @@ -1215,6 +1311,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { if (mainManaged) { setCurrentMainMeta(scopeKey, stripUndefined({ origin, + cli, target: opts.target, chatId: opts.chatId, requestId: opts.requestId, @@ -1225,42 +1322,13 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { })); } - let resolve: (value: SpawnPromiseResult) => void; - const resultPromise = new Promise(r => { resolve = r; }); - - const session = (getSession() as SessionRow | undefined) ?? {}; - const persistenceOwner = getSessionOwnershipGeneration(scopeKey); - const ownerGeneration = persistenceOwner.global; - if (mainRun) mainRun.ownerGeneration = ownerGeneration; - let cli = resolveMainCli(opts.cli, settings, session); - if (mainRun) mainRun.meta.cli = cli; - - // Namespace selection is captured once for this run. Builtin native - // Codex/Pi keep their existing bucket keys; only switchable adapters use - // the new namespace. Reject unavailable native choices before fallback, - // saved-session reads, bootstrap consumption, or worker isolation. - const runtimeTransport = isSwitchableNativeCli(cli) - ? resolveRuntimeTransport(settings['perCli']?.[cli]?.transport) : 'print'; - if (runtimeTransport === 'native' - && (!isNativeAdapterImplemented(cli) || (isEmployee && !isNativeWorkerImplemented(cli)))) { - const message = `${cli} native ${isEmployee ? 'worker ' : ''}transport is not implemented in this build. Set perCli.${cli}.transport to "print" to use compatibility mode.`; - const released = mainManaged && activeMainProcesses.get(scopeKey) === mainRun - && releaseMainRun(scopeKey, null, ownerGeneration); - broadcast('agent_done', { - text: message, error: true, origin, cli, scope: scopeKey, sessionId: chatSessionId, - ...(opts.requestId ? { requestId: opts.requestId } : {}), ...empTag, - }, isEmployee ? 'internal' : 'public'); - resolve!({ text: message, code: 78 }); - if (released) void processQueue(scopeKey); - return { child: null, promise: resultPromise }; - } // Phase 52: Bootstrap consumption is moved BELOW the bucket-aware `isResume` // computation so we can use the authoritative per-bucket resume decision // instead of the legacy `isResumeGuess` heuristic. See comment near line 762. // ─── Fallback retry: skip to fallback if retries exhausted ─── - if (!opts._isFallback && !opts.internal) { + if (runtimeTransport !== 'native' && !opts._isFallback && !opts.internal) { const st = queueCtrl.fallbackStateForScope(scopeKey).get(cli); if (st?.fallbackCli && st.retriesLeft <= 0) { const fbAvail = detectCli(st.fallbackCli)?.available; @@ -1331,7 +1399,6 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { return { child: null, promise: resultPromise }; } - const permissions = opts.permissions || settings["permissions"] || session.permissions || 'auto'; if (cli === 'opencode') { ensureOpencodeAlwaysAllowPermissions(); } @@ -1352,7 +1419,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const model = cli === 'ai-e' && effectiveProvider === 'claude' ? migrateLegacyClaudeValue(requestedModel) : requestedModel; - const runtimeModel = cli === 'cursor' ? resolveCursorModelVariant(model, effort) : model; + const runtimeModel = cli === 'cursor' && runtimeTransport !== 'native' ? resolveCursorModelVariant(model, effort) : model; const codexMultiplexMain = cli === 'codex-app' && mainManaged && !opts.agentId && settings["runtime"]?.codexApp?.multiplex === true; if (mainManaged) { @@ -1525,14 +1592,15 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { prompt, settings["workingDir"], chatSessionId, - 10, - 8000, + PROMPT_HISTORY_MAX_ROWS, + PROMPT_HISTORY_MAX_CHARS, ) : ''; let agyBootstrap: AgyBootstrapEnvelope | null = null; let promptForArgs = buildPromptForArgs({ cli, effectiveProvider, + runtimeTransport, prompt, historyBlock, sysPrompt, @@ -1588,7 +1656,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { return buildResumeArgs(cli, runtimeModel, effort, sid, promptForArgs, permissions, options); }; let args: string[] = []; - if (cli !== 'agy') args = buildCurrentArgs(argOptions); + if (cli !== 'agy' && runtimeTransport !== 'native') args = buildCurrentArgs(argOptions); const agentLabel = agentId || 'main'; const traceAudience: 'public' | 'internal' = (opts.internal || isEmployee) ? 'internal' : 'public'; @@ -1674,6 +1742,238 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { } + // ─── Native Cursor main: protocol ownership is separate from application settlement. ─── + if (runtimeTransport === 'native' && cli === 'cursor' && mainManaged) { + const capturedRun = mainRun!; + const nativeCwd = spawnCwd || process.cwd(); + let traceRunId: string; + try { traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: nativeCwd, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); } + catch { traceRunId = createTraceId(); console.warn('[runtime:cursor] trace creation unavailable'); } + const identity = Object.freeze({ runId: traceRunId, sessionId: chatSessionId, scope: scopeKey, + turnId: traceRunId, audience: traceAudience, + ...(opts.runtimeParentItemId ? { parentItemId: opts.runtimeParentItemId } : {}) }); + const ctx: SpawnContext = { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), + hasClaudeStreamEvents: false, sessionId: null, cost: null, turns: null, duration: null, tokens: null, + stderrBuf: '', runStartedAt: Date.now(), origin, + ...(opts.requestId ? { requestId: opts.requestId } : {}), + liveScope: effectiveLiveScope, parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience }; + const toolMirror = new Map(); + const mirrorLimit = 160; + let facade: AcpRuntimeSession | null = null, ownedLease: NativeRunLease | null = null; + let nativeStarted = false, runtimeEnded = false, finalizeFailed = false, finalized = false; + let stopReason: string | null = null, queueRequested = false; + let capturedExit: (typeof exitSettlers extends Map ? T : never) | undefined; + let selectedResult: SpawnPromiseResult | undefined; + const ownsRun = () => !finalized && activeMainProcesses.get(scopeKey) === capturedRun + && isCurrentSessionOwner(persistenceOwner, scopeKey); + const cursorTarget = opts.target ? { ...opts.target } : undefined; + let acceptedContext: CursorAcceptedContext = { messages: [], omitted: false }; + const prepareReplacement = (instruction: string, partialText: string) => { + if (!ownsRun()) throw new Error('cursor_acp_owner_lost'); + return { text: buildCursorReplacementPrompt({ + instruction: prependRemoteConversationContext(instruction, cursorTarget), + originalRequest: promptForSnapshot, accepted: acceptedContext, partialText, sysPrompt, + }) }; + }; + const replaceHook = (instruction: string, commitInput: () => void): Promise => + replaceAcpMainTurn(facade, instruction, () => { + if (!ownsRun()) throw new Error('cursor_acp_owner_lost'); + const next = appendCursorAcceptedInstruction(acceptedContext, instruction); + const result: unknown = commitInput(); + if (result !== null && (typeof result === 'object' || typeof result === 'function') + && typeof (result as { then?: unknown }).then === 'function') { + void Promise.resolve(result).catch(() => undefined); + throw new Error('cursor_acp_async_input_commit'); + } + acceptedContext = next; + }); + const resultFor = (outcome: RuntimeTurnOutcome): SpawnPromiseResult => ({ + text: outcome.finalText?.trim() ?? '', code: outcome.status === 'done' ? 0 : outcome.status === 'stopped' ? 130 : 1, + runtimeOutcome: outcome, traceRunId, + }); + const diagnostic = () => facade?.lastError?.includes('config') + ? 'Cursor native model or effort is unsupported. Choose an advertised model/effort; Composer models may require an unset effort.' + : 'Cursor native runtime failed. Check the native model, effort and existing CLI login.'; + const endRuntime = (end: RuntimeEnd) => { + if (runtimeEnded) return; + runtimeEnded = true; + if (facade?.claimTurnOutcome(traceRunId)) { + if (!facade.finalizeTurn(traceRunId, end)) finalizeFailed = true; + } else if (!nativeStarted) { + const failedStart = new RuntimeProjection(identity); + failedStart.start(cli); failedStart.close(end); + } else { + finalizeFailed = true; + console.warn('[runtime:cursor] missing owned finalizer'); + } + }; + const failRuntime = (outcome: RuntimeTurnOutcome): SpawnPromiseResult => { + ctx.stallWatchdog?.stop(); + const selected = ctx.runtimeTerminalAttempted && ctx.runtimeOutcome ? ctx.runtimeOutcome : { + status: stopReason || ctx.stallReason ? 'stopped' as const : 'error' as const, + finalText: null, partialText: outcome.partialText, + }; + handoffRuntimeOutcome(ctx, selected); + try { + if (!ctx.runtimeTerminalAttempted) { + ctx.runtimeTerminalAttempted = true; + broadcast('agent_done', { traceRunId, scope: scopeKey, sessionId: chatSessionId, origin, cli, + ...(opts.requestId ? { requestId: opts.requestId } : {}), + text: selected.status === 'stopped' ? '' : `❌ ${diagnostic()}`, error: true, + runtimeStatus: selected.status, runtimeFinality: selected.finalText === null ? 'absent' : 'present', + }, traceAudience); + } + } finally { + endRuntime({ kind: 'turn-end', status: selected.status, finalText: selected.finalText, + ...(selected.status === 'error' ? { error: diagnostic() } : {}) }); + } + return selectedResult ?? resultFor(selected); + }; + let nativeRun!: ReturnType>; + const cancelHook = (reason: string) => { + stopReason ??= reason; + if (reason === 'steer' || reason === 'interrupt') { + armExitSettle(scopeKey); + capturedExit ??= exitSettlers.get(scopeKey); + } + nativeRun.cancel(reason); + }; + capturedRun.starting = true; + nativeRun = runNativeRuntime({ + turnId: traceRunId, prompt: { text: promptForArgs }, isCurrent: ownsRun, + acquire: async signal => { + const lease = await acquireCursorRuntime({ + key: { scopeKey, cwd: nativeCwd, model: runtimeModel === 'default' ? '' : runtimeModel, effort, permissions }, + binary: detected.path || 'cursor-agent', env: spawnEnv, promptTimeoutMs: resolvedAgyPrintTimeoutMs, + persistenceOwner, isCurrentOwner: token => isCurrentSessionOwner(token, scopeKey), canAcquire: ownsRun, + storedSessionId: resumeSessionId, forceNew, signal, + }); + facade = new AcpRuntimeSession(lease.session, { provider: cli, deferTurnEnd: true, + getTurnContext: () => ({ ...identity, isCurrent: ownsRun }), + createReplacement: io => new AcpReplacement(io), prepareReplacement, + capabilities: { transport: 'native', steer: 'cancel-reprompt', resume: lease.session.agentCapabilities['loadSession'] === true, + tools: true, toolOutput: true, approvals: true, questions: false, images: false, subagents: false }, + record: (context, body) => { + if (body.kind === 'turn-start') nativeStarted = true; + return recordRuntimeEvent(context, body); + }, + }); + ownedLease = { child: lease.session.child, session: facade, + release: () => lease.release(), retire: reason => lease.retire(reason) }; + return ownedLease; + }, + ready: lease => { + if (!ownsRun()) throw new Error('native_run_owner_lost'); + capturedRun.process = lease.child; + if (!opts._skipInsert) insertMessage.run('user', prompt, cli, runtimeModel, nativeCwd, chatSessionId); + capturedRun.starting = false; + ctx.sessionId = lease.session.nativeSessionId || null; + if (capturedRun.cancelPending === cancelHook) delete capturedRun.cancelPending; + capturedRun.cancelTurn = cancelHook; + capturedRun.replaceTurn = replaceHook; + beginLiveRun(liveScope, cli); setLiveRunTraceId(liveScope, traceRunId); + const activityIdentity: RuntimeLivenessIdentity = { runId: traceRunId, sessionId: chatSessionId, + scope: scopeKey, origin, ...(opts.requestId ? { requestId: opts.requestId } : {}) }; + const onIo = () => { + if (!ownsRun()) return; + try { opts.lifecycle?.onActivity?.('native-runtime', activityIdentity); } + catch { console.warn('[runtime:cursor] liveness observer failed'); } + }; + const dispose = () => { + ctx.stallWatchdog?.stop(); + if (capturedRun.replaceTurn === replaceHook) delete capturedRun.replaceTurn; + lease.child.stdout?.off('data', onIo); lease.child.stderr?.off('data', onIo); + }; + try { + lease.child.stdout?.on('data', onIo); lease.child.stderr?.on('data', onIo); + ctx.stallWatchdog = attachWatchdog(lease.child, agentLabel, reason => { + ctx.stallReason = reason; nativeRun.cancel(reason); + }); + broadcast('agent_status', { running: true, status: 'running', agentId: agentLabel, cli, + scope: scopeKey, sessionId: chatSessionId, traceRunId, + ...(opts.requestId ? { requestId: opts.requestId } : {}) }, traceAudience); + return dispose; + } catch (error) { dispose(); throw error; } + }, + event: (event: RuntimeEvent) => { + if (event.kind === 'usage') { + ctx.tokens = { ...(event.inputTokens === undefined ? {} : { input_tokens: event.inputTokens }), + ...(event.outputTokens === undefined ? {} : { output_tokens: event.outputTokens }), + ...(event.cachedTokens === undefined ? {} : { cached_input_tokens: event.cachedTokens }) }; + } + if (event.kind !== 'tool') return; + let tool = toolMirror.get(event.itemId); + if (!tool && toolMirror.size >= mirrorLimit) return; + const detail = [event.input, event.output, event.detail].filter(value => value !== undefined && value !== '').join('\n'); + if (!tool) { + tool = { icon: '🔧', label: event.name, toolType: 'tool', status: event.status, detail, + stepRef: `runtime:${traceRunId}:${event.itemId}` }; + stampTraceTool(tool, ctx, 'tool'); toolMirror.set(event.itemId, tool); + } else { + Object.assign(tool, { label: event.name, status: event.status, detail }); updateTraceToolRow(tool); + } + ctx.toolLog = [...toolMirror.values()]; syncLiveTools(ctx); + }, + settle: async (lease, outcome, problem) => { + ctx.stallWatchdog?.stop(); ctx.fullText = outcome.partialText; + if (problem) ctx.stderrBuf = problem; + if (lease) ctx.sessionId = lease.session.nativeSessionId || null; + const recordedReason = consumeKillReason(lease?.child.pid); + const killReason = stopReason || recordedReason; + const wasKilled = Boolean(killReason), wasSteer = killReason === 'steer' || killReason === 'interrupt' || killReason === DUP_REGISTRATION_KILL_REASON; + const code = outcome.status === 'done' ? 0 : outcome.status === 'stopped' ? 130 : 1; + handoffRuntimeOutcome(ctx, outcome); + try { opts.lifecycle?.onExit?.(code); } catch { console.warn('[runtime:cursor] exit observer failed'); } + await handleAgentExit({ onRuntimeEnd: endRuntime, + ctx, code, cli, model: runtimeModel, effectiveProvider, agentLabel, mainManaged, origin, + resumeKey, prompt, opts, cfg: { ...cfg, effort }, ownerGeneration, persistenceOwner, forceNew, empSid, + isResume, wasKilled, wasSteer, smokeResult: detectSmokeResponse(outcome.finalText ?? '', ctx.toolLog, code, cli), + effortDefault: effort, costLine: '', resolve: value => { selectedResult ??= value; }, + activeProcesses, scopeKey, runtimeTransport, scopedBucket: currentBucket, chatSessionId, + childProcess: lease?.child ?? null, releaseMainRun, + retryState: queueCtrl.retryStateForScope(scopeKey), fallbackState: queueCtrl.fallbackStateForScope(scopeKey), + fallbackMaxRetries: FALLBACK_MAX_RETRIES, processQueue: () => { queueRequested = true; }, + }); + if (finalizeFailed && lease) await lease.retire(new Error('native_runtime_finalization_failed')); + return selectedResult ?? resultFor(ctx.runtimeOutcome ?? outcome); + }, + failed: (_error, _lease, outcome) => failRuntime(outcome), + finalized: () => { + finalized = true; + try { + if (capturedRun.cancelPending === cancelHook) delete capturedRun.cancelPending; + if (capturedRun.cancelTurn === cancelHook) delete capturedRun.cancelTurn; + if (capturedRun.replaceTurn === replaceHook) delete capturedRun.replaceTurn; + if (activeMainProcesses.get(scopeKey) === capturedRun) { + const child = ownedLease?.child ?? null; + if (releaseMainRun(scopeKey, child, ownerGeneration)) { + if (getLiveRun(liveScope).traceRunId === traceRunId) clearLiveRun(liveScope); + broadcast('agent_status', { running: false, agentId: agentLabel, cli, scope: scopeKey, + sessionId: chatSessionId, traceRunId }, traceAudience); + } + } + } finally { + if (capturedExit && exitSettlers.get(scopeKey) === capturedExit) { + exitSettlers.delete(scopeKey); capturedExit.resolve(); + } + // Exceptional settlement can release the slot before lifecycle requests its normal wake. + if (queueRequested || !activeMainProcesses.has(scopeKey)) void processQueue(scopeKey); + } + }, + }); + capturedRun.cancelPending = cancelHook; + let resolved = false; + const resolveOnce = (value: SpawnPromiseResult) => { if (!resolved) { resolved = true; resolve!(value); } }; + nativeRun.done.then(resolveOnce, error => { + const prior = ctx.runtimeTerminalAttempted && ctx.runtimeOutcome ? ctx.runtimeOutcome + : error instanceof NativeRunFailure ? { ...error.outcome, + status: stopReason || ctx.stallReason || error.outcome.status === 'stopped' ? 'stopped' as const : 'error' as const, finalText: null } + : { status: 'error' as const, finalText: null, partialText: ctx.fullText }; + resolveOnce(selectedResult ?? resultFor(prior)); + }); + return { child: null, promise: resultPromise }; + } + // ─── Copilot ACP branch ────────────────────── if (cli === 'copilot') { // Write model + reasoning_effort to ~/.copilot/config.json (CLI flags unsupported) @@ -1725,6 +2025,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { activeProcesses.delete(agentLabel); } broadcast('agent_done', { text: `❌ ${msg}`, error: true, origin, ...empTag }, isEmployee ? 'internal' : 'public'); + finishPrintActivity(ctx, { kind: 'turn-end', status: 'error', finalText: null, error: msg }); resolve!({ text: '', code: 1 }); if (mainManaged) void processQueue(scopeKey); }); @@ -1735,7 +2036,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { if (!opts.internal) broadcast('agent_status', { status: 'running', cli, agentId: agentLabel, ...empTag }, traceAudience); if (mainManaged && !opts.internal) beginLiveRun(liveScope, cli); - const traceRunId = startTraceRun({ cli, model, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience }); + const traceRunId = startTraceRun({ cli, model, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); if (mainManaged && !opts.internal) setLiveRunTraceId(liveScope, traceRunId); const ctx: CopilotSpawnContext = { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), @@ -1749,7 +2050,10 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience, + activityIdentity: { sessionId: chatSessionId, scope: scopeKey }, }; + ctx.printActivity = createPrintActivity({ runId: traceRunId, sessionId: chatSessionId, + scope: scopeKey, turnId: traceRunId, audience: traceAudience }, cli); // Flush accumulated 💭 thinking buffer as a single merged event function flushThinking() { @@ -1785,6 +2089,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const parsedTool = parsed.tool; // Buffer 💭 thought chunks → flush when different event arrives if (parsedTool.icon === '💭') { + ctx.printActivity?.reasoning(parsedTool.detail || parsedTool.label, 'append'); ctx.thinkingBuf += parsedTool.detail || parsedTool.label; return; } @@ -1814,10 +2119,12 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { // carry no boundary signal and simply accumulate. if (parsed.messageId && ctx.acpAssistantMessageId !== undefined && ctx.acpAssistantMessageId !== parsed.messageId) { + ctx.printActivity?.nextMessage(); ctx.fullText = ''; ctx.outputTextStarted = false; } if (parsed.messageId) ctx.acpAssistantMessageId = parsed.messageId; + ctx.printActivity?.message(parsed.text, 'append', 'unknown'); const segment = appendAssistantTextSegment(ctx, parsed.text); if (segment) { broadcastAgentOutput(ctx, agentLabel, cli, segment, empTag, traceAudience); @@ -1985,6 +2292,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { // - trace: if (traceText) traceText = `⏹️ [interrupted]…` handleAgentExit({ ctx, code: acpCode, cli, model, agentLabel, mainManaged, origin, + onRuntimeEnd: end => ctx.printActivity?.finish(end), resumeKey, prompt, opts, cfg, ownerGeneration, persistenceOwner, forceNew, empSid, isResume, wasKilled, wasSteer, smokeResult, @@ -2020,7 +2328,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const piSessionId = isResume && bucketSessionId ? bucketSessionId : ''; console.log(`[jaw:pi] isResume=${isResume}, bucketSessionId=${bucketSessionId || 'none'}, piSessionId=${piSessionId || 'new'}`); const piPrompt = withSteerContext(piSessionId ? prompt : withHistoryPrompt(prompt, historyBlock), opts.steerContext); - const traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience }); + const traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); const ctx: SpawnContext = { fullText: '', traceLog: [], @@ -2046,6 +2354,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience, + activityIdentity: { sessionId: chatSessionId, scope: scopeKey }, }; const activity = new RuntimeProjection({ runId: traceRunId, sessionId: chatSessionId, scope: scopeKey, @@ -2314,7 +2623,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { } if (!opts.internal) broadcast('agent_status', { status: 'running', cli, agentId: agentLabel, ...empTag }, traceAudience); - const traceRunId = startTraceRun({ cli, model, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience }); + const traceRunId = startTraceRun({ cli, model, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); if (mainManaged && !opts.internal) setLiveRunTraceId(liveScope, traceRunId); const ctx: CopilotSpawnContext = { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), @@ -2328,6 +2637,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience, + activityIdentity: { sessionId: chatSessionId, scope: scopeKey }, }; const activity = new RuntimeProjection({ @@ -2989,6 +3299,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { activeProcesses.delete(agentLabel); } broadcast('agent_done', { text: `❌ ${msg}`, error: true, origin, ...empTag }, isEmployee ? 'internal' : 'public'); + finishPrintActivity(ctx, { kind: 'turn-end', status: 'error', finalText: null, error: msg }); resolve!({ text: '', code: 127 }); if (mainManaged) void processQueue(scopeKey); }); @@ -3015,7 +3326,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { if (!opts.internal) broadcast('agent_status', { status: 'running', cli, agentId: agentLabel, ...runtimeStatusMeta, ...empTag }, traceAudience); - const traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience }); + const traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); if (mainManaged && !opts.internal) setLiveRunTraceId(liveScope, traceRunId); // Native `agy --conversation ... -p` may emit only the current answer. // Length-based replay trimming can therefore swallow the whole new answer. @@ -3043,6 +3354,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience, + activityIdentity: { sessionId: chatSessionId, scope: scopeKey }, ...(opencodeSpawnAudit ? { opencodeSpawnAudit: opencodeSpawnAudit as Record } : {}), ...(agyResumeOffset > 0 ? { agyResumeOffset, agyBytesReceived: 0 } : {}), ...(cli === 'agy' ? { @@ -3059,6 +3371,8 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { ...(kiroPlainText || cli === 'agy' || cli === 'pi' ? { liveOutputText: '' } : {}), ...(kiroPlainText ? { kiroLastVisibleAt: Date.now(), kiroHeartbeatSent: false } : {}), }; + ctx.printActivity = createPrintActivity({ runId: traceRunId, sessionId: chatSessionId, + scope: scopeKey, turnId: traceRunId, audience: traceAudience }, cli); let agyClosing = false; let agyGuardedStaleDetected = false; const scheduleAgyQuietCompletion = () => { @@ -3204,6 +3518,9 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { } const outputChunk = extractOutputChunk(dispatchCli, event, ctx); if (outputChunk) { + // Dedicated providers observe before their destructive legacy resets. + // Copilot's ordinary print fallback exposes only accepted assistant text here. + if (dispatchCli === 'copilot') ctx.printActivity?.message(outputChunk, 'append', 'unknown'); broadcastAgentOutput(ctx, agentLabel, cli, outputChunk, empTag, (opts.internal || isEmployee) ? 'internal' : 'public'); } }; @@ -3250,6 +3567,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const newText = normalizeAssistantDisplayText(newStart > 0 ? text.slice(newStart) : text); ctx.agyResumeOffset = 0; if (!newText) return; + ctx.printActivity?.message(newText, 'append', 'unknown'); if (ctx.liveOutputText !== undefined) ctx.liveOutputText += newText; ctx.outputTextStarted = true; appendTraceEvent({ runId: ctx.traceRunId, source: 'cli_raw', eventType: 'plain_text', raw: newText }); @@ -3280,6 +3598,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { return; } appendTraceEvent({ runId: ctx.traceRunId, source: 'cli_raw', eventType: 'plain_text', raw: displayText }); + ctx.printActivity?.message(displayFullText, 'replace', 'unknown'); broadcastAgentOutput(ctx, agentLabel, cli, displayText, empTag, traceAudience); scheduleAgyQuietCompletion(); return; @@ -3402,6 +3721,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { if (agyResumeDecision.ok && !opts._agyStaleFreshRetry) { if (mainManaged) releaseMainRun(scopeKey, child, ownerGeneration); else activeProcesses.delete(agentLabel); + finishPrintActivity(ctx, { kind: 'turn-end', status: 'stopped', finalText: null, error: 'AGY stale resume; retrying fresh' }); const { promise: freshPromise } = spawnAgent(prompt, { ...opts, _agyStaleFreshRetry: true, _skipResume: true, _skipInsert: true, }); @@ -3561,6 +3881,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { // - trace: if (traceText) traceText = `⏹️ [interrupted]…` handleAgentExit({ ctx, code: effectiveExitCode, cli, model: runtimeModel, effectiveProvider, agentLabel, mainManaged, origin, + onRuntimeEnd: end => ctx.printActivity?.finish(end), resumeKey, prompt, opts, cfg, ownerGeneration, persistenceOwner, forceNew, empSid, isResume, wasKilled, wasSteer, smokeResult, diff --git a/src/agent/steer-input-guard.ts b/src/agent/steer-input-guard.ts new file mode 100644 index 000000000..2756ddd1d --- /dev/null +++ b/src/agent/steer-input-guard.ts @@ -0,0 +1,33 @@ +/** A pending fallback keeps its Stop observation until its actual enqueue decision. */ +export interface SteerInputGuard { + isCancelled(): boolean; + release(): void; +} +type Token = { cancelled: boolean }; +const pending = new Map>(); + +export function beginSteerInput(scope: string): SteerInputGuard { + let tokens = pending.get(scope); + if (!tokens) { tokens = new Set(); pending.set(scope, tokens); } + const owned = tokens, token: Token = { cancelled: false }; + owned.add(token); + let released = false; + return { + isCancelled: () => released || token.cancelled, + release: () => { + if (released) return; + released = true; owned.delete(token); + if (!owned.size && pending.get(scope) === owned) pending.delete(scope); + }, + }; +} + +export function cancelSteerInputs(scope: string): void { + const tokens = pending.get(scope); + pending.delete(scope); // genuinely new post-Stop input receives a fresh set + for (const token of tokens ?? []) token.cancelled = true; +} + +export function cancelAllSteerInputs(): void { + for (const scope of pending.keys()) cancelSteerInputs(scope); +} diff --git a/src/cli/acp-client.ts b/src/cli/acp-client.ts index 86f6348c1..823f16f06 100644 --- a/src/cli/acp-client.ts +++ b/src/cli/acp-client.ts @@ -7,6 +7,7 @@ import { EventEmitter } from 'events'; import { createInterface } from 'readline'; import { ownProcess, type OwnedProcess, type OwnedProcessOptions } from '../agent/spawn/process-kill.js'; import { createTextStreamReader } from '../agent/stream-text.js'; +import { decodeFrame, type RpcFrame } from '../agent/runtime/acp/wire.js'; type AcpId = string | number; interface AcpRequest

{ jsonrpc: '2.0'; id: AcpId; method: string; params?: P } @@ -240,46 +241,42 @@ export class AcpClient extends EventEmitter { const trimmed = line.trim(); if (!trimmed) return; - let msg: Record; - try { msg = JSON.parse(trimmed) as Record; } catch { - if (process.env["DEBUG"]) console.log(`[acp] non-JSON line: ${trimmed.slice(0, 100)}`); + let msg: RpcFrame; + try { msg = decodeFrame(trimmed); } catch { + // Preserve legacy stdout tolerance without logging raw malformed frames. return; } // Any valid JSON-RPC message = agent is alive → reset idle timer this._activityPing?.(); - const id = msg["id"] as number | string | undefined; - const method = msg["method"] as string | undefined; + // Peers own independent ID spaces: dispatch methods before correlating responses. + if ('method' in msg) { + if ('id' in msg) { + this._handleAgentRequest(msg); + } else { + if (msg.method === 'session/cancelled') { + console.warn(`[acp:cancelled] ${JSON.stringify(msg.params || {}).slice(0, 200)}`); + } + this.emit(msg.method, msg.params); + } + return; + } // Response to a request (has id) + const id = msg.id; if (id != null && this._pending.has(id as number)) { const p = this._pending.get(id as number)!; this._pending.delete(id as number); if (p.timer) clearTimeout(p.timer); - const error = msg["error"] as { code: number; message: string; data?: unknown } | undefined; - if (error) { + if ('error' in msg) { + const error = msg.error; const details = error.data ? ` ${JSON.stringify(error.data)}` : ''; p.reject(new Error(`ACP error [${error.code}]: ${error.message}${details}`)); } else { - p.resolve(msg["result"]); - } - return; - } - - // Agent request to client (has id + method) — auto-respond - if (id != null && method) { - this._handleAgentRequest(msg as unknown as AcpRequest); - return; - } - - // Notification from agent (no id, has method) - if (method) { - if (method === 'session/cancelled') { - console.warn(`[acp:cancelled] ${JSON.stringify(msg["params"] || {}).slice(0, 200)}`); + p.resolve(msg.result); } - this.emit(method, msg["params"]); return; } } diff --git a/src/cli/handlers-runtime.ts b/src/cli/handlers-runtime.ts index 9b713043e..c886dec2d 100644 --- a/src/cli/handlers-runtime.ts +++ b/src/cli/handlers-runtime.ts @@ -8,6 +8,7 @@ import { t } from '../core/i18n.js'; import { detectCli, settings } from '../core/config.js'; import type { CliCommandContext } from './command-context.js'; import type { SlashResult } from './types.js'; +import { beginSteerInput } from '../agent/steer-input-guard.js'; async function safeCall( fn: (() => Promise | T) | undefined | null, @@ -244,18 +245,21 @@ export async function steerHandler(args: string[], ctx: CliCommandContext): Prom const iface = ctx.interface || 'cli'; - // Prefer in-band same-turn steer when the runtime supports it (jwc always; - // codex-app while a steerable turn is in flight). No kill, no context loss. - // A steer that the runtime cannot accept falls back to the QUEUE — never to - // killing a turn we failed to steer. + // Prefer an owning runtime hook: in-band for JWC/Codex, or native cancel-reprompt. + // A typed no-start queues once; a fatal/indeterminate dispatch propagates without resend. if (canSteerAgent(scopeKey)) { - const outcome = await steerAgent(scopeKey, prompt, iface, sessionScopeMeta()); - if (outcome === 'steered') { - return { ok: true, type: 'steer', text: t('cmd.steer.started', {}, L) }; - } - const { submitMessage } = await import('../orchestrator/gateway.js'); - submitMessage(prompt, { origin: iface, ...sessionScopeMeta(), midRunPolicy: 'followup' }); - return { ok: true, type: 'success', text: 'Steer unavailable for the current turn — message queued as follow-up.' }; + const inputGuard = beginSteerInput(scopeKey); + try { + const outcome = await steerAgent(scopeKey, prompt, iface, sessionScopeMeta()); + if (outcome === 'steered' || outcome === 'new-run') { + return { ok: true, type: 'steer', text: t('cmd.steer.started', {}, L) }; + } + if (outcome === 'cancelled') return { ok: true, type: 'success', text: 'Pending steer cancelled.' }; + const { submitMessage } = await import('../orchestrator/gateway.js'); + if (inputGuard.isCancelled()) return { ok: true, type: 'success', text: 'Pending steer cancelled.' }; + submitMessage(prompt, { origin: iface, ...sessionScopeMeta(), midRunPolicy: 'followup' }); + return { ok: true, type: 'success', text: 'Steer unavailable for the current turn — message queued as follow-up.' }; + } finally { inputGuard.release(); } } // Kill running agent (or cancel retry timer) and wait for clean exit diff --git a/src/core/bus.ts b/src/core/bus.ts index d340ac394..ea8c58b65 100644 --- a/src/core/bus.ts +++ b/src/core/bus.ts @@ -55,6 +55,12 @@ export function inferTopic(type: string): EventTopic { } export function broadcast(type: string, data: Record, audience: 'public' | 'internal' = 'public') { + // Semantic presentation is never input to collectors, forwarders or ACK owners. + // Its producer supplies captured identity, even when multi-session is disabled. + if (type === 'agent_runtime' || type === 'agent_runtime_gap') { + if (audience === 'public') ssePublish('agent', type, data); + return; + } const captured = currentSessionScope(); const scopedData = captured && settings["multiSession"]?.enabled === true ? { ...data, scope: data["scope"] ?? captured.scope, sessionId: data["sessionId"] ?? captured.chatSessionId } diff --git a/src/core/chat-sessions.ts b/src/core/chat-sessions.ts index 1e37e21ee..83764c613 100644 --- a/src/core/chat-sessions.ts +++ b/src/core/chat-sessions.ts @@ -193,6 +193,7 @@ export function deleteChatSession(sessionId: string): boolean { const result = deleteStmt.run(sessionId); if (result.changes === 0) return null; deleteMessagesStmt.run(sessionId); + db.prepare('DELETE FROM trace_runs WHERE session_id = ?').run(sessionId); return row; })(); if (deleted) { diff --git a/src/core/config.ts b/src/core/config.ts index 814d79b54..dc4833b0a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -6,6 +6,7 @@ import path from 'path'; import { join } from 'path'; import { DEFAULT_CLI, CLI_KEYS, buildDefaultPerCli } from '../cli/registry.js'; import { SWITCHABLE_NATIVE_CLIS, resolveRuntimeTransport } from '../agent/runtime/selection.js'; +import { presentationMode } from '../shared/presentation.js'; import type { MessengerChannel } from '../messaging/types.js'; import { pickFirstReadyCli } from '../cli/readiness.js'; import { migrateLegacyClaudeValue } from '../cli/claude-models.js'; @@ -243,6 +244,7 @@ function createDefaultSettings() { permissions: 'auto', workingDir: JAW_HOME, perCli: buildDefaultPerCli(), + presentation: { mode: 'activity' as const }, pi: { defaultProfileId: 'progrok', profiles: [{ @@ -558,6 +560,10 @@ export const isMessengerChannel = (value: unknown): value is MessengerChannel => MESSENGER_CHANNELS.includes(value as MessengerChannel); export function migrateSettings(s: Record, sourceVersion = readSettingsSchemaVersion(s)) { + s['presentation'] = { + ...(isPlainRecord(s['presentation']) ? s['presentation'] : {}), + mode: presentationMode(s), + }; // Whatever the document claimed on the way in, what leaves this function is written // by the current schema and says so. Individual markers below key off `sourceVersion`, // which was read before this line, so stamping here does not disarm them. diff --git a/src/core/db.ts b/src/core/db.ts index 7466408d4..187c2d106 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -345,6 +345,17 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_trace_events_run_seq ON trace_events(run_id, seq); `); +// Additive owner metadata: legacy rows remain readable without invented scopes. +const traceRunCols = new Set((db.prepare('PRAGMA table_info(trace_runs)').all() as { name: string }[]).map(c => c.name)); +if (!traceRunCols.has('session_id')) db.exec('ALTER TABLE trace_runs ADD COLUMN session_id TEXT'); +if (!traceRunCols.has('scope_key')) db.exec('ALTER TABLE trace_runs ADD COLUMN scope_key TEXT'); +db.exec(` + CREATE INDEX IF NOT EXISTS idx_trace_runs_session ON trace_runs(session_id, id); + CREATE INDEX IF NOT EXISTS idx_trace_runtime ON trace_events(run_id, seq) WHERE source = 'runtime'; + CREATE UNIQUE INDEX IF NOT EXISTS idx_trace_runtime_control ON trace_events(run_id) + WHERE source = 'system' AND event_type = 'runtime.control.v1'; +`); + // Lightweight migration for existing DBs created before `trace` column existed. const messageCols = db.prepare('PRAGMA table_info(messages)').all(); if (!(messageCols as Record[]).some(c => c["name"] === 'trace')) { @@ -373,6 +384,10 @@ if (!(messageCols as Record[]).some(c => c["name"] === 'session db.exec("ALTER TABLE messages ADD COLUMN session_id TEXT DEFAULT 'default'"); } db.exec('CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)'); +// Only the original message link can establish a historical owner. Forked copies +// also carry trace_run_id, so backfilling from that column would steal ownership. +db.exec(`UPDATE trace_runs SET session_id = (SELECT session_id FROM messages WHERE id = trace_runs.message_id) + WHERE session_id IS NULL AND message_id IS NOT NULL`); const SEARCH_FTS_SQL = ` CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( diff --git a/src/core/runtime-settings.ts b/src/core/runtime-settings.ts index e16d8fcbb..54394fd64 100644 --- a/src/core/runtime-settings.ts +++ b/src/core/runtime-settings.ts @@ -354,6 +354,7 @@ async function applyRuntimeSettingsPatchSerialised( throw new Error('invalid_settings_field'); } const patch = sanitized.value; + const presentationOnly = Object.keys(patch).length === 1 && Object.hasOwn(patch, 'presentation'); validateDispatchApprovalPatch(patch); if (!opts.allowWikiLifecycle && wikiRouteManagedPatchPaths(patch).length > 0) { throw new Error('wiki_configuration_requires_wiki_route'); @@ -381,7 +382,7 @@ async function applyRuntimeSettingsPatchSerialised( }); } - opts.resetFallbackState?.(); + if (!presentationOnly) opts.resetFallbackState?.(); // CLI-changed branch delegates main-session clearing to cliSwitchRefresh // (which writes a cleared row inside its DB transaction). On refresh failure @@ -412,11 +413,11 @@ async function applyRuntimeSettingsPatchSerialised( rollbackHandled = true; throw e; } - } else { + } else if (!presentationOnly) { syncMainSessionToSettings(prevCli); } - syncJwcConfigDefault(settings); + if (!presentationOnly) syncJwcConfigDefault(settings); if (settings["workingDir"] !== prevWorkingDir) { try { diff --git a/src/core/settings-merge.ts b/src/core/settings-merge.ts index cf0e671ec..1c8e461c5 100644 --- a/src/core/settings-merge.ts +++ b/src/core/settings-merge.ts @@ -4,6 +4,7 @@ import { mergeAckSettings } from '../messaging/ack-reaction.js'; import { mergeSlackAutoJoin } from '../slack/auto-join.js'; import { isRuntimeTransport, isSwitchableNativeCli } from '../agent/runtime/selection.js'; +import { isPresentationMode } from '../shared/presentation.js'; export type SettingsInputSource = 'boot' | 'watch' | 'api'; export type SettingsPersistenceShape = 'absent' | 'present'; @@ -36,6 +37,20 @@ export function sanitizeSettingsInput( const rejectedPaths: string[] = []; let persistenceShape: SettingsPersistenceShape = 'absent'; + if (Object.hasOwn(input, 'presentation')) { + if (!isPlainRecord(input['presentation'])) { + delete value['presentation']; + invalidPaths.push('presentation'); + } else { + const presentation = { ...input['presentation'] }; + if (Object.hasOwn(presentation, 'mode') && !isPresentationMode(presentation['mode'])) { + delete presentation['mode']; + invalidPaths.push('presentation.mode'); + } + value['presentation'] = presentation; + } + } + if (isPlainRecord(input['perCli'])) { const perCli = Object.fromEntries(Object.entries(input['perCli']).map(([cli, entry]) => { if (!isPlainRecord(entry)) return [cli, entry]; @@ -154,7 +169,7 @@ export function mergeSettingsPatch(current: Record, patch: Record = {}, locale: string = 'ko', ): Promise { + meta = { ...meta }; return new Promise((resolve) => { + const binding = resolveExecutionBinding({ + ...meta, + chatSessionId: meta['chatSessionId'] === undefined ? meta['sessionId'] : meta['chatSessionId'], + persistedScopeId: meta['remoteKey'], + captured: currentSessionScope() ?? null, + activeChatSessionId: getActiveChatSession(), + multiSessionEnabled: settings['multiSession']?.enabled === true, + }); + const runMeta = { ...meta, ...binding, origin: meta['origin'] || 'web', + _onRuntimeActivity: onRuntimeActivity }; + const requestId = meta['requestId'] || undefined; let collected = ''; let ownTerminalDiagnostic = ''; let nativeSeen = false; + let settled = false; + let runtimeActive = true; + let nativeRunId: string | undefined; let timeout: ReturnType; const IDLE_TIMEOUT = 1200000; function resetTimeout() { + if (settled) return; clearTimeout(timeout); timeout = setTimeout(() => { + settled = true; + runtimeActive = false; removeBroadcastListener(handler); // This is a collector/application timeout, not a runtime // completion. Keep data empty: an observed agent_done does not @@ -49,20 +71,32 @@ export function orchestrateAndCollectData( }, IDLE_TIMEOUT); } + function onRuntimeActivity(identity: RuntimeLivenessIdentity) { + if (settled || !runtimeActive || identity.scope !== binding.scope + || identity.sessionId !== binding.chatSessionId || identity.origin !== runMeta.origin + || identity.requestId !== requestId || !identity.runId.trim() + || (nativeRunId !== undefined && identity.runId !== nativeRunId)) return; + nativeRunId = identity.runId; + nativeSeen = true; + resetTimeout(); + } + const matchesNativeIdentity = (data: Record): boolean => { - const sessionId = meta['chatSessionId'] ?? meta['sessionId']; - return (!meta['requestId'] || data['requestId'] === meta['requestId']) - && (!meta['scope'] || data['scope'] === meta['scope']) - && (!sessionId || data['sessionId'] === sessionId) - && (!meta['origin'] || data['origin'] === meta['origin']); + return (!requestId || data['requestId'] === requestId) + && data['scope'] === binding.scope + && data['sessionId'] === binding.chatSessionId + && data['origin'] === runMeta.origin + && (nativeRunId === undefined || data['traceRunId'] === nativeRunId); }; const handler = (type: string, data: Record) => { + if (settled) return; const native = (data['runtimeFinality'] === 'present' || data['runtimeFinality'] === 'absent') && (data['runtimeStatus'] === 'done' || data['runtimeStatus'] === 'error' || data['runtimeStatus'] === 'stopped'); // A mismatched native terminal must not remove this request's // listener, extend its timeout, or contaminate its diagnostic. if (native && !matchesNativeIdentity(data)) return; if (native && (type === 'agent_done' || type === 'orchestrate_done')) nativeSeen = true; + if (native && type === 'agent_done') runtimeActive = false; // Live assistant chunks arrive as agent_output (Web UI + spawn.ts); legacy alias agent_chunk. if (type === 'agent_chunk' || type === 'agent_output' || type === 'agent_tool' || type === 'agent_status' || type === 'agent_retry' || @@ -85,6 +119,8 @@ export function orchestrateAndCollectData( if (meta?.["origin"] && data?.["origin"] && data["origin"] !== meta["origin"]) return; if (!meta?.["requestId"] && meta?.["chatId"] && data?.["chatId"] && data["chatId"] !== meta["chatId"]) return; clearTimeout(timeout); + settled = true; + runtimeActive = false; removeBroadcastListener(handler); const terminalText = typeof data['text'] === 'string' && data['text'].trim() ? data['text'] : ''; resolve({ text: native @@ -93,17 +129,20 @@ export function orchestrateAndCollectData( } }; addBroadcastListener(handler); + resetTimeout(); const run = isResetIntent(prompt) - ? orchestrateReset(meta) + ? orchestrateReset(runMeta) : isContinueIntent(prompt) - ? orchestrateContinue(meta) - : orchestrate(prompt, meta); + ? orchestrateContinue(runMeta) + : orchestrate(prompt, runMeta); Promise.resolve(run).catch(err => { + if (settled) return; + settled = true; + runtimeActive = false; clearTimeout(timeout); removeBroadcastListener(handler); resolve({ text: `❌ ${err.message}`, data: {} }); }); - resetTimeout(); }); } diff --git a/src/orchestrator/gateway.ts b/src/orchestrator/gateway.ts index 6afbc5ac7..7af1ab931 100644 --- a/src/orchestrator/gateway.ts +++ b/src/orchestrator/gateway.ts @@ -21,6 +21,7 @@ import type { RuntimeOrigin, RemoteTarget } from '../messaging/types.js'; import { buildRemoteBindingKey, normalizedThreadId, type SessionScope } from '../messaging/session-key.js'; import { sessionLanes } from './session-lanes.js'; import { admitRequest, settleOnce } from './request-registry.js'; +import { beginSteerInput } from '../agent/steer-input-guard.js'; export type SubmitResult = { action: 'started' | 'queued' | 'rejected'; @@ -107,17 +108,17 @@ function applyMidRunPolicy( // steer joins the queue — with its queue_update broadcast — instead of // dying silently. Queue is the fallback only for in-band failures, // never for missing capability. + const steerMeta = stripUndefined({ chatSessionId: ctx.chatSessionId, target: ctx.meta.target, + chatId: ctx.meta.chatId, requestId: ctx.requestId, remoteKey: ctx.remoteKey, replyViaTarget: ctx.meta.replyViaTarget }); + const inputGuard = beginSteerInput(ctx.scopeKey); runDetached( - steerAgent(ctx.scopeKey, ctx.text, ctx.meta.origin, stripUndefined({ - chatSessionId: ctx.chatSessionId, - target: ctx.meta.target, - chatId: ctx.meta.chatId, - requestId: ctx.requestId, - remoteKey: ctx.remoteKey, - replyViaTarget: ctx.meta.replyViaTarget, - })).then(outcome => { - if (outcome === 'fallback-queue') queue(); - }), + steerAgent(ctx.scopeKey, ctx.text, ctx.meta.origin, steerMeta).then(outcome => { + // Stop settles an undispatched redirect as cancelled; never recreate it after the purge. + if (outcome !== 'fallback-queue') return; + if (inputGuard.isCancelled()) { + settleOnce(ctx.requestId, 'cancelled', { reason: 'native-steer-stopped', scope: ctx.scopeKey, sessionId: ctx.chatSessionId }); + } else queue(); + }).finally(() => inputGuard.release()), 'steer', { ...ctx.meta, requestId: ctx.requestId, eventScope: { scope: ctx.scopeKey, sessionId: ctx.chatSessionId } }, ); diff --git a/src/orchestrator/pipeline.ts b/src/orchestrator/pipeline.ts index 0e2f2e766..247a83376 100644 --- a/src/orchestrator/pipeline.ts +++ b/src/orchestrator/pipeline.ts @@ -12,7 +12,7 @@ import { getLatestUnconsumedAnchor, } from '../core/db.js'; import { getActiveChatSession } from '../core/chat-sessions.js'; -import { withSessionScope } from '../core/session-context.js'; +import { currentSessionScope, withSessionScope } from '../core/session-context.js'; import { clearPromptCache } from '../prompt/builder.js'; import { spawnAgent, killAgentById } from '../agent/spawn.js'; @@ -45,10 +45,10 @@ import { extractElicitationSpecs, renderPlainElicitationSpec, } from '../shared/elicitation-spec.js'; -import { resolveOrcScope } from './scope.js'; +import { resolveExecutionBinding } from './scope.js'; import { sessionLanes } from './session-lanes.js'; import { settleOnce } from './request-registry.js'; -import type { RuntimeTurnOutcome } from '../shared/runtime-contract.js'; +import type { RuntimeLivenessIdentity, RuntimeTurnOutcome } from '../shared/runtime-contract.js'; // ─── Parser re-exports ───────────────────────────── import { @@ -62,6 +62,31 @@ export { type SpawnAgentLike = typeof spawnAgent; +function captureExecutionMeta(meta: Record & { remoteKey?: string | null }) { + const binding = resolveExecutionBinding({ + ...meta, + persistedScopeId: meta.remoteKey ?? null, + captured: currentSessionScope() ?? null, + activeChatSessionId: getActiveChatSession(), + multiSessionEnabled: settings['multiSession']?.enabled === true, + }); + return { ...meta, ...binding }; +} + +function runtimeActivityLifecycle(meta: Record) { + const callback = meta['_onRuntimeActivity']; + if (meta['_workerResult'] || typeof callback !== 'function') return undefined; + return { onActivity(source: string, identity?: RuntimeLivenessIdentity) { + if (source !== 'native-runtime' || !identity + || ![identity.runId, identity.sessionId, identity.scope, identity.origin] + .every(value => typeof value === 'string' && value.trim().length > 0) + || (identity.requestId !== undefined + && (typeof identity.requestId !== 'string' || !identity.requestId.trim()))) return; + // This private observer must not turn model I/O into a provider failure. + try { callback(identity); } catch { /* liveness observer is best-effort */ } + } }; +} + const REMOTE_ELICITATION_BLOCKED_ORIGINS = new Set(['telegram', 'discord', 'cli', 'slack']); const STRUCTURED_REMOTE_FENCE_RE = /```(elicitation|choice-buttons|search-results)[^\n]*\n([\s\S]*?)```/g; const INCOMPLETE_STRUCTURED_REMOTE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})(elicitation|choice-buttons|search-results)[^\n]*(?:\n[\s\S]*)?$/im; @@ -260,6 +285,7 @@ export async function orchestrate( prompt: string, meta: Record = {}, ) { + meta = captureExecutionMeta(meta); const origin = meta["origin"] || 'web'; const chatId = meta["chatId"]; const target = meta["target"]; @@ -272,10 +298,8 @@ export async function orchestrate( // forwarder must fire for THESE turns only, or an ordinary reply, which the // dispatch path already posts, would go out twice. const fromQueue = meta["_fromQueue"] === true; - const scope = meta["scope"] || resolveOrcScope({ - origin, target, chatId, persistedScopeId: meta["remoteKey"], - multiSessionEnabled: settings["multiSession"]?.enabled === true, - }); + const scope: string = meta['scope']; + const chatSessionId: string = meta['chatSessionId']; // --- drain pending worker results before normal processing --- if (!meta["_skipReplayDrain"]) { @@ -457,21 +481,17 @@ export async function orchestrate( // (from a hub ThreadRoute) via the existing SpawnOpts.model/sysPrompt — no session // persistence, so it affects only this request's agent run. const overrides = meta["overrides"] as { model?: string; systemPrompt?: string } | undefined; - const multiSessionEnabled = settings["multiSession"]?.enabled === true; - const chatSessionId = typeof meta["chatSessionId"] === 'string' - ? meta["chatSessionId"] - : getActiveChatSession(); + const lifecycle = runtimeActivityLifecycle(meta); const spawn = () => runSpawnAgent(prompt, { origin, target, chatId, requestId, replyViaTarget, - ...(multiSessionEnabled ? { - scopeKey: scope, - chatSessionId, - ...(typeof meta["remoteKey"] === 'string' ? { remoteKey: meta["remoteKey"] } : {}), - } : {}), + scopeKey: scope, + chatSessionId, + ...(typeof meta["remoteKey"] === 'string' ? { remoteKey: meta["remoteKey"] } : {}), + ...(lifecycle ? { lifecycle } : {}), _skipInsert: !!meta["_skipInsert"], _heartbeatAnchorId: meta["_heartbeatAnchorId"], // Kill-path steer salvage: the interrupted turn's partial output, @@ -482,9 +502,7 @@ export async function orchestrate( ...(overrides?.model ? { model: overrides.model } : {}), ...(overrides?.systemPrompt ? { sysPrompt: overrides.systemPrompt } : {}), }); - const { promise } = multiSessionEnabled - ? withSessionScope({ scope, chatSessionId }, spawn) - : spawn(); + const { promise } = withSessionScope({ scope, chatSessionId }, spawn); const result = await promise as Record; const nativeOutcome: RuntimeTurnOutcome | undefined = result['runtimeOutcome']; const nativeTags = nativeOutcome ? { @@ -701,15 +719,13 @@ export async function orchestrate( export async function orchestrateContinue( meta: Record = {}, ) { + meta = captureExecutionMeta(meta); const origin = meta["origin"] || 'web'; const chatId = meta["chatId"]; const target = meta["target"]; const requestId = meta["requestId"]; const replyViaTarget = meta["replyViaTarget"] === true; - const scope = meta["scope"] || resolveOrcScope({ - origin, target, chatId, persistedScopeId: meta["remoteKey"], - multiSessionEnabled: settings["multiSession"]?.enabled === true, - }); + const scope: string = meta['scope']; const state = getState(scope); // Active PABCD → resume from current state @@ -739,15 +755,13 @@ export async function orchestrateContinue( export async function orchestrateReset( meta: Record = {}, ) { + meta = captureExecutionMeta(meta); const origin = meta["origin"] || 'web'; const chatId = meta["chatId"]; const target = meta["target"]; const requestId = meta["requestId"]; const replyViaTarget = meta["replyViaTarget"] === true; - const scope = meta["scope"] || resolveOrcScope({ - origin, target, chatId, persistedScopeId: meta["remoteKey"], - multiSessionEnabled: settings["multiSession"]?.enabled === true, - }); + const scope: string = meta['scope']; // --- cancel PABCD workers only — preserve main agent + message queue --- for (const w of getActiveWorkers(scope)) { killAgentById(w.agentId); diff --git a/src/orchestrator/scope.ts b/src/orchestrator/scope.ts index 080e725c4..f2cd93d6c 100644 --- a/src/orchestrator/scope.ts +++ b/src/orchestrator/scope.ts @@ -1,5 +1,6 @@ import type { RemoteTarget } from '../messaging/types.js'; import { buildRemoteBindingKey } from '../messaging/session-key.js'; +import type { SessionScope } from '../messaging/session-key.js'; import { settings } from '../core/config.js'; type OrcScopeInput = { @@ -60,6 +61,26 @@ export function resolveOrcScope(input: OrcScopeInput = {}): string { return scopeForChatSession(sessionId, remoteKey, input.multiSessionEnabled === true); } +/** Capture server placement once; the feature gate controls only automatic scope derivation. */ +export function resolveExecutionBinding(input: OrcScopeInput & { + scope?: string; + captured?: SessionScope | null; + activeChatSessionId: string; +}): Readonly { + // Untyped orchestration metadata must not silently turn a malformed explicit + // identity into the shared default. Empty strings retain the existing fallback convention. + for (const value of [input.scope, input.chatSessionId, input.captured?.scope, + input.captured?.chatSessionId, input.activeChatSessionId]) { + if (value !== undefined && typeof value !== 'string') { + throw new TypeError('Execution binding identities must be strings'); + } + } + return Object.freeze({ + scope: input.scope || input.captured?.scope || resolveOrcScope(input), + chatSessionId: input.chatSessionId || input.captured?.chatSessionId || input.activeChatSessionId || 'default', + }); +} + export function findActiveScope(_origin: string, _chatId?: string | number, _meta?: { workingDir?: string }): string | null { return 'default'; } diff --git a/src/routes/messages.ts b/src/routes/messages.ts index b82226c3c..19d509463 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -16,6 +16,7 @@ import { dashboardActivityTitleFromExcerpt } from '../core/message-summary.js'; import { sanitizeSerializedToolLog, serializeSanitizedToolLog, parseToolLogBounded } from '../shared/tool-log-sanitize.js'; import { isAgentBusy } from '../agent/spawn.js'; import { listToolEntriesForMessage } from '../trace/store.js'; +import { mergeLatestTools } from '../agent/merge-tool-log.js'; import { HYDRATE_TOOL_CARDS_FROM_TRACE } from '../core/config.js'; // Option D (devlog 260620 Phase 3): tool cards for a finished message come from @@ -32,20 +33,12 @@ export function resolveToolLog( if (traceTools.length) { // Boss tools come from trace_events (durable, uncapped). Worker mirrors // (isEmployee) stay sourced from the blob, where Phase 1 already preserves them - // sanitized — so enabling the flag never drops worker cards. Union by stepRef. + // sanitized — so enabling the flag never drops worker cards. // (Folding worker child runs from trace via parent_run_id is the purer Option D // path but needs a cross-process linkage write; the blob mirror is display- // equivalent and ships the flag safely now — devlog 260620 doc 20/31.) const blobWorkers = parseToolLogBounded(blobToolLog).filter((t) => t.isEmployee === true); - if (!blobWorkers.length) return serializeSanitizedToolLog(traceTools); - const seen = new Set(); - const merged: unknown[] = []; - for (const t of [...traceTools, ...blobWorkers] as { stepRef?: unknown }[]) { - const ref = typeof t.stepRef === 'string' && t.stepRef ? t.stepRef : null; - if (ref) { if (seen.has(ref)) continue; seen.add(ref); } - merged.push(t); - } - return serializeSanitizedToolLog(merged); + return serializeSanitizedToolLog(mergeLatestTools(traceTools, blobWorkers, '')); } } return sanitizeSerializedToolLog(blobToolLog); diff --git a/src/routes/orchestrate.ts b/src/routes/orchestrate.ts index 5c10fa2fd..f833aec63 100644 --- a/src/routes/orchestrate.ts +++ b/src/routes/orchestrate.ts @@ -3,10 +3,12 @@ import type { AuthMiddleware } from './types.js'; import { fail } from '../http/response.js'; import { isAgentBusy, messageQueue, getQueuedMessageSnapshotForScope, removeQueuedMessage, killActiveAgent, waitForProcessEnd, waitForExitSettled, getCurrentMainMeta, getSteerWaitMsForActiveAgent, setQueueHold, clearQueueHold, setSteerInProgress, isSteerInProgress } from '../agent/spawn.js'; import { getLiveRun } from '../agent/live-run-state.js'; -import { countToolTraceRows, listToolEntriesForRun } from '../trace/store.js'; +import { listToolEntriesForRun } from '../trace/store.js'; +import { mergeLatestTools } from '../agent/merge-tool-log.js'; import { orchestrate, orchestrateContinue, orchestrateReset, isResetIntent, isContinueIntent, drainPendingReplays } from '../orchestrator/pipeline.js'; import { getSession, insertMessage } from '../core/db.js'; import { getActiveChatSession } from '../core/chat-sessions.js'; +import { resolveRequestSessionStrict } from './session-request.js'; import { getState, getCtx, setState, resetState, canTransition, resetEveryState, parseWorkerVerdict, aggregateBatchVerdicts } from '../orchestrator/state-machine.js'; import type { WorkerVerdict } from '../orchestrator/state-machine.js'; import { normalizeTaskTags } from '../prompt/builder.js'; @@ -75,17 +77,15 @@ function getSafeLiveRun(scope: string) { const liveRun = getLiveRun(scope); let toolLog = sanitizeToolLogForDurableStorage(liveRun.toolLog); if (liveRun.running && liveRun.traceRunId) { - const bossCount = toolLog.filter(t => t.isEmployee !== true && !isToolLogOverflowMarker(t)).length; - const ramBehind = toolLog.length === 0 - || toolLog.some(isToolLogOverflowMarker) - || countToolTraceRows(liveRun.traceRunId) > bossCount; - if (ramBehind) { - const boss = listToolEntriesForRun(liveRun.traceRunId); - if (boss.length > bossCount) { - const mirrors = toolLog.filter(t => t.isEmployee === true); - toolLog = sanitizeToolLogForDurableStorage([...boss, ...mirrors]); - } - } + // Updates replace rows in place, so equal counts still need a durable read. + // All RAM entries remain fallback when storage missed a tool, including boss tools. + const boss = listToolEntriesForRun(liveRun.traceRunId, 400); + // Durable reconstruction recalculates omissions; an old RAM marker is not a tool. + // Without durable rows, retain the marker documenting RAM-only history loss. + const mirrors = boss.length + ? liveRun.toolLog.filter(tool => !isToolLogOverflowMarker(tool)) + : liveRun.toolLog; + toolLog = sanitizeToolLogForDurableStorage(mergeLatestTools(boss, mirrors, liveRun.traceRunId)); } return { ...liveRun, toolLog }; } @@ -273,8 +273,17 @@ export function registerOrchestrateRoutes(app: Express, requireAuth: AuthMiddlew res.json({ ok: true, progress }); }); - app.get('/api/orchestrate/snapshot', requireAuth, (_req, res) => { - const scope = resolveOrcScope({ origin: 'web', workingDir: settings["workingDir"] || null }); + app.get('/api/orchestrate/snapshot', requireAuth, (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + const requested = req.query['session']; + if (requested !== undefined && (typeof requested !== 'string' || !requested.trim() || requested.length > 240)) { + fail(res, 400, 'invalid_session'); + return; + } + const resolved = resolveRequestSessionStrict(requested); + if (!resolved.ok) { fail(res, 404, 'unknown_session'); return; } + const scope = resolved.scope; + const activityIdentity = { sessionId: resolved.chatSessionId, scope }; const runtime = getRuntimeSnapshot(scope); const ctx = getCtx(scope); const scopedWorkers = getActiveWorkers(scope); @@ -306,6 +315,7 @@ export function registerOrchestrateRoutes(app: Express, requireAuth: AuthMiddlew researchReport: ctx.researchReport, } : null; res.json({ + activityIdentity, orc: { scope, state: getState(scope), diff --git a/src/routes/runtime-requests.ts b/src/routes/runtime-requests.ts new file mode 100644 index 000000000..3978dee8a --- /dev/null +++ b/src/routes/runtime-requests.ts @@ -0,0 +1,33 @@ +import type { Router, RequestHandler } from 'express'; +import { runtimeRequests, type RuntimeRequests } from '../agent/runtime/requests.js'; +import { ok, fail } from '../http/response.js'; + +function isId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 240; +} + +/** Uses the existing instance auth policy; binding is not a per-session tenant ACL. */ +export function registerRuntimeRequestRoutes(app: Router, requireAuth: RequestHandler, registry: RuntimeRequests = runtimeRequests): void { + app.get('/api/runtime/requests', requireAuth, (req, res) => { + const sessionId = req.query['sessionId']; + if (!isId(sessionId)) { fail(res, 400, 'invalid_session'); return; } + ok(res, { requests: registry.list(sessionId) }); + }); + app.post('/api/runtime/requests/:id', requireAuth, (req, res) => { + const body: unknown = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) { fail(res, 400, 'invalid_response'); return; } + const x = body as Record; + const requestId = req.params['id']; + const runId = x['runId'], sessionId = x['sessionId'], scope = x['scope'], turnId = x['turnId']; + if (Object.keys(x).some(key => !['runId', 'sessionId', 'scope', 'turnId', 'response'].includes(key)) + || !isId(requestId) || !isId(runId) || !isId(sessionId) || !isId(scope) || !isId(turnId) + || !Object.hasOwn(x, 'response')) { fail(res, 400, 'invalid_response'); return; } + try { + registry.respond(requestId, { runId, sessionId, scope, turnId }, x['response']); + ok(res, { accepted: true }); + } catch (error) { + const stale = error instanceof Error && error.message === 'request_not_current'; + fail(res, stale ? 409 : 400, stale ? 'request_not_current' : 'invalid_response'); + } + }); +} diff --git a/src/routes/traces.ts b/src/routes/traces.ts index 45239a5ae..bf93ab70b 100644 --- a/src/routes/traces.ts +++ b/src/routes/traces.ts @@ -1,8 +1,43 @@ import type { Express, NextFunction, Request, Response } from 'express'; import { fail, ok } from '../http/response.js'; import { getTraceEvent, getTraceRun, listTraceEvents } from '../trace/store.js'; +import { isTraceSessionOwner, listActivityRuns, readActivityPage, ACTIVITY_PAGE_ROWS as ACTIVITY_PAGE_SIZE } from '../trace/activity-journal.js'; type AuthMiddleware = (req: Request, res: Response, next: NextFunction) => void; +const TRACE_ID_RE = /^tr_[A-Za-z0-9_-]{16,80}$/; + +function explicitSession(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 240; +} + +function decimalQuery(value: unknown, fallback: number): number { + if (value === undefined) return fallback; + if (typeof value !== 'string' || !/^\d+$/.test(value)) return NaN; + const n = Number(value); + return Number.isSafeInteger(n) ? n : NaN; +} + +function activityQuery(req: Request) { + const query = req.query; + if (Object.keys(query).some(key => !['session', 'after', 'through', 'limit'].includes(key))) return null; + const session = query['session']; + if (!explicitSession(session)) return null; + const after = decimalQuery(query['after'], 0); + const through = decimalQuery(query['through'], 0); + const limit = decimalQuery(query['limit'], ACTIVITY_PAGE_SIZE); + if (![after, through, limit].every(Number.isSafeInteger) || limit < 1 || limit > ACTIVITY_PAGE_SIZE) return null; + return { sessionId: session, after, limit, ...(query['through'] === undefined ? {} : { through }) }; +} + +function rawRead(handler: (req: Request, res: Response) => void) { + return (req: Request, res: Response): void => { + try { + handler(req, res); + } catch { + fail(res, 503, 'trace_unavailable'); + } + }; +} function parseLimit(value: unknown, fallback: number): number { const n = Number(value); @@ -22,11 +57,63 @@ function publicRunOrFail(req: Request, res: Response) { fail(res, 404, 'trace_not_found'); return null; } + // Session-only backfills remain readable as raw diagnostics. Scope alone + // cannot authorize access, and only truly ownerless rows retain legacy access. + if (run.session_id != null || run.scope_key != null) { + const session = req.query['session']; + if (!explicitSession(session) || session !== run.session_id || !isTraceSessionOwner(run.id, session)) { + fail(res, 404, 'trace_not_found'); + return null; + } + } return run; } export function registerTraceRoutes(app: Express, requireAuth: AuthMiddleware): void { - app.get('/api/traces/:runId', requireAuth, (req, res) => { + // Set before auth and parameter parsing so failures cannot be cached either. + app.use('/api/traces', (_req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + next(); + }); + + app.get('/api/traces/activity-runs', requireAuth, (req, res) => { + const query = req.query; + const session = query['session']; + const after = query['after'] ?? ''; + if (Object.keys(query).some(key => !['session', 'after'].includes(key)) + || !explicitSession(session) || typeof after !== 'string' + || (after !== '' && !TRACE_ID_RE.test(after))) { + fail(res, 400, 'invalid_activity_query'); + return; + } + try { + ok(res, { runs: listActivityRuns(session, after), pageSize: ACTIVITY_PAGE_SIZE }); + } catch { + fail(res, 503, 'activity_unavailable'); + } + }); + + app.get('/api/traces/:runId/activity', requireAuth, (req, res) => { + const query = activityQuery(req); + const runId = String(req.params['runId'] || ''); + if (!query || !TRACE_ID_RE.test(runId)) { + fail(res, 400, 'invalid_activity_query'); + return; + } + try { + const page = readActivityPage({ runId, ...query }); + if (!page) { + fail(res, 404, 'trace_not_found'); + return; + } + ok(res, page); + } catch (error) { + fail(res, error instanceof RangeError ? 409 : 503, + error instanceof RangeError ? 'activity_resync_required' : 'activity_unavailable'); + } + }); + + app.get('/api/traces/:runId', requireAuth, rawRead((req, res) => { const run = publicRunOrFail(req, res); if (!run) return; ok(res, { @@ -44,15 +131,15 @@ export function registerTraceRoutes(app: Express, requireAuth: AuthMiddleware): finishedAt: run.finished_at || null, error: run.error || null, }); - }); + })); - app.get('/api/traces/:runId/events', requireAuth, (req, res) => { + app.get('/api/traces/:runId/events', requireAuth, rawRead((req, res) => { const run = publicRunOrFail(req, res); if (!run) return; ok(res, listTraceEvents(run.id, parseOffset(req.query["offset"]), parseLimit(req.query["limit"], 80))); - }); + })); - app.get('/api/traces/:runId/events/:seq', requireAuth, (req, res) => { + app.get('/api/traces/:runId/events/:seq', requireAuth, rawRead((req, res) => { const run = publicRunOrFail(req, res); if (!run) return; const seq = Number(req.params["seq"]); @@ -76,5 +163,5 @@ export function registerTraceRoutes(app: Express, requireAuth: AuthMiddleware): createdAt: event.created_at || 0, raw: event.raw, }); - }); + })); } diff --git a/src/shared/presentation.ts b/src/shared/presentation.ts new file mode 100644 index 000000000..c6055f103 --- /dev/null +++ b/src/shared/presentation.ts @@ -0,0 +1,24 @@ +export type PresentationMode = 'activity' | 'legacy'; + +export function isPresentationMode(value: unknown): value is PresentationMode { + return value === 'activity' || value === 'legacy'; +} + +export function presentationMode(settings: unknown): PresentationMode { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return 'activity'; + const block = (settings as Record)['presentation']; + if (!block || typeof block !== 'object' || Array.isArray(block)) return 'activity'; + const mode = (block as Record)['mode']; + return isPresentationMode(mode) ? mode : 'activity'; +} + +/** Captured server response identity; clients must not derive execution scopes. */ +export interface ActivityIdentity { sessionId: string; scope: string; } + +export function parseActivityIdentity(value: unknown): ActivityIdentity | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const input = value as Record; + const id = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 240; + return id(input['sessionId']) && id(input['scope']) + ? { sessionId: input['sessionId'], scope: input['scope'] } : null; +} diff --git a/src/shared/runtime-contract.ts b/src/shared/runtime-contract.ts index bc3eac394..925a28a74 100644 --- a/src/shared/runtime-contract.ts +++ b/src/shared/runtime-contract.ts @@ -2,6 +2,14 @@ export type RuntimeTransport = 'native' | 'print'; export type RuntimeSteerMode = 'native-input' | 'cancel-reprompt' | 'queued' | 'restart'; export type RuntimePhase = 'commentary' | 'final' | 'unknown'; export type RuntimeItemStatus = 'running' | 'done' | 'error' | 'stopped'; +/** Private owned-I/O callback identity. Never a wire event or content carrier. */ +export interface RuntimeLivenessIdentity { + runId: string; + sessionId: string; + scope: string; + origin: string; + requestId?: string; +} export interface RuntimeTurnOutcome { status: 'done' | 'error' | 'stopped'; /** null means absent; an empty string is an authoritative empty answer. */ diff --git a/src/shared/runtime-event-parse.ts b/src/shared/runtime-event-parse.ts index 471b76409..d62dffb5d 100644 --- a/src/shared/runtime-event-parse.ts +++ b/src/shared/runtime-event-parse.ts @@ -33,6 +33,8 @@ function requestView(x: unknown): RuntimeRequestView | null { return { title: x['title'], fields }; } +export { requestView as parseRuntimeRequestView }; + export function parseRuntimeEvent(value: unknown): RuntimeEvent | null { if (!record(value) || value['version'] !== 1 || !id(value['runId']) || !id(value['sessionId']) || !id(value['scope']) || !id(value['turnId']) || diff --git a/src/trace/activity-control.ts b/src/trace/activity-control.ts new file mode 100644 index 000000000..0d8482b35 --- /dev/null +++ b/src/trace/activity-control.ts @@ -0,0 +1,75 @@ +import { db } from '../core/db.js'; + +export const ACTIVITY_CONTROL_TYPE = 'runtime.control.v1'; +export type ActivityLoss = 'event_limit' | 'run_limit' | 'global_limit' | 'storage_error' | 'retention'; +export type ActivityControl = { + version: 1; count: number; bytes: number; lastSeq: number; closed: boolean; loss: ActivityLoss | null; +}; +const CONTROL_BYTES = 2048; +const losses = new Set(['event_limit', 'run_limit', 'global_limit', 'storage_error', 'retention']); +const read = db.prepare(`SELECT seq, + CASE WHEN length(CAST(raw_json AS BLOB)) <= ${CONTROL_BYTES} THEN raw_json ELSE NULL END AS raw_json + FROM trace_events WHERE run_id = ? AND source = 'system' AND event_type = 'runtime.control.v1'`); +const write = db.prepare(`UPDATE trace_events SET raw_json = ?, bytes = ?, preview = 'runtime control' + WHERE run_id = ? AND seq = ? AND source = 'system' AND event_type = 'runtime.control.v1'`); + +export function readActivityControl(runId: string): { seq: number; state: ActivityControl } | null { + const row = read.get(runId) as { seq: number; raw_json: string | null } | undefined; + if (!row) return null; + if (row.raw_json === null) throw new Error('activity_control_corrupt'); + let value: unknown; + try { value = JSON.parse(row.raw_json); } + catch { throw new Error('activity_control_corrupt'); } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('activity_control_corrupt'); + const c = value as Record; + if (Object.keys(c).some(k => !['version', 'count', 'bytes', 'lastSeq', 'closed', 'loss'].includes(k)) + || c['version'] !== 1 || typeof c['closed'] !== 'boolean' + || !['count', 'bytes', 'lastSeq'].every(k => Number.isSafeInteger(c[k]) && Number(c[k]) >= 0) + || (c['loss'] !== null && !losses.has(c['loss'] as ActivityLoss))) throw new Error('activity_control_corrupt'); + return { seq: row.seq, state: { version: 1, count: Number(c['count']), bytes: Number(c['bytes']), + lastSeq: Number(c['lastSeq']), closed: c['closed'], loss: c['loss'] as ActivityLoss | null } }; +} + +export function writeActivityControl(runId: string, seq: number, state: ActivityControl): void { + const raw = JSON.stringify(state); + if (write.run(raw, Buffer.byteLength(raw), runId, seq).changes !== 1) throw new Error('activity_control_missing'); +} + +/** Best-effort metadata must never obstruct final delivery or interrupted MESSAGE salvage. */ +export function markActivityLoss(runId: string, loss: ActivityLoss): void { + try { + db.transaction(() => { + const current = readActivityControl(runId); + if (current && !current.state.loss) writeActivityControl(runId, current.seq, { ...current.state, loss }); + }).immediate(); + } catch { console.warn('[activity] loss_metadata_unavailable'); } +} + +export function closeActivity(runId: string, loss?: ActivityLoss): void { + try { + db.transaction(() => { + const current = readActivityControl(runId); + if (!current) return; + writeActivityControl(runId, current.seq, { ...current.state, closed: true, + loss: current.state.loss ?? loss ?? (current.state.closed ? null : 'storage_error') }); + }).immediate(); + } catch { console.warn('[activity] close_metadata_unavailable'); } +} + +/** Delete a whole append-dependent prefix; preserve its cursor and explicit loss. */ +export function expireActivityPrefix(runId: string): number { + return db.transaction(() => { + let current: ReturnType = null; + try { current = readActivityControl(runId); } + catch (error) { + if (!(error instanceof Error) || error.message !== 'activity_control_corrupt') throw error; + // Keep the corrupt control as a fail-closed tombstone for active owners; + // reclaim its entire runtime prefix without blocking unrelated retention. + console.warn('[activity] expired_corrupt_control'); + } + if (current) writeActivityControl(runId, current.seq, { + ...current.state, count: 0, bytes: 0, closed: true, loss: 'retention', + }); + return db.prepare("DELETE FROM trace_events WHERE run_id = ? AND source = 'runtime'").run(runId).changes; + }).immediate(); +} diff --git a/src/trace/activity-journal.ts b/src/trace/activity-journal.ts new file mode 100644 index 000000000..bdbd7a723 --- /dev/null +++ b/src/trace/activity-journal.ts @@ -0,0 +1,153 @@ +import { db } from '../core/db.js'; +import { settings } from '../core/config.js'; +import type { RuntimeEvent } from '../shared/runtime-contract.js'; +import type { TracePointer, TraceRunStatus } from './types.js'; +import { appendTraceEvent } from './store.js'; +import { stringifyTraceValue } from './redact.js'; +import { decodeRuntimeBody, RUNTIME_BODY_BYTES, type RuntimeBodyRecord } from './runtime-body-codec.js'; +import { ACTIVITY_CONTROL_TYPE, readActivityControl, writeActivityControl, markActivityLoss, + type ActivityControl, type ActivityLoss } from './activity-control.js'; +export { closeActivity, type ActivityLoss } from './activity-control.js'; + +export const ACTIVITY_RUN_ROWS = 4096; +export const ACTIVITY_RUN_BYTES = 4 * 1024 * 1024; +export const ACTIVITY_GLOBAL_ROWS = 20_000; +export const ACTIVITY_GLOBAL_BYTES = 32 * 1024 * 1024; +export const ACTIVITY_PAGE_ROWS = 40; +export const ACTIVITY_PAGE_BYTES = 256 * 1024; + +type OwnerRow = { id: string; session_id: string; scope_key: string | null; + status: TraceRunStatus; audience: 'public' | 'internal' }; +export type ActivityOwner = OwnerRow & { scope_key: string }; +const ownerStmt = db.prepare(`SELECT r.id, r.session_id, r.scope_key, r.status, r.audience + FROM trace_runs r JOIN chat_sessions s ON s.id = r.session_id WHERE r.id = ? AND r.session_id = ?`); +const totals = db.prepare("SELECT COUNT(*) AS count, COALESCE(SUM(bytes), 0) AS bytes FROM trace_events WHERE source = 'runtime'"); +const rowCount = db.prepare('SELECT COUNT(*) AS count FROM trace_events'); +const rowsStmt = db.prepare(`SELECT seq, event_type, bytes, + CASE WHEN length(CAST(raw_json AS BLOB)) <= ${RUNTIME_BODY_BYTES} THEN raw_json ELSE NULL END AS raw_json + FROM trace_events WHERE run_id = ? AND source = 'runtime' AND seq > ? AND seq <= ? ORDER BY seq LIMIT ?`); +const terminalStmt = db.prepare(`SELECT 1 FROM trace_events WHERE run_id = ? AND seq = ? + AND source = 'runtime' AND event_type = 'turn-end'`); +const runsStmt = db.prepare(`SELECT r.id, r.message_id, r.status, r.started_at + FROM trace_runs r JOIN chat_sessions s ON s.id = r.session_id + WHERE r.session_id = ? AND r.audience = 'public' AND length(r.scope_key) BETWEEN 1 AND 240 + AND (? = '' OR r.id > ?) ORDER BY r.id LIMIT ${ACTIVITY_PAGE_ROWS}`); + +export function isTraceSessionOwner(runId: string, sessionId: string): boolean { + const row = ownerStmt.get(runId, sessionId) as OwnerRow | undefined; + return row?.audience === 'public'; +} + +export function getActivityOwner(runId: string, sessionId: string): ActivityOwner | null { + const row = ownerStmt.get(runId, sessionId) as OwnerRow | undefined; + return row?.audience === 'public' && row.scope_key && row.scope_key.length <= 240 + ? { ...row, scope_key: row.scope_key } : null; +} + +export type ActivityRunSummary = { id: string; messageId: number | null; status: TraceRunStatus; startedAt: number }; +export function listActivityRuns(sessionId: string, after = ''): ActivityRunSummary[] { + const rows = runsStmt.all(sessionId, after, after) as { + id: string; message_id: number | null; status: TraceRunStatus; started_at: number; + }[]; + return rows.map(r => ({ id: r.id, messageId: r.message_id, status: r.status, startedAt: r.started_at })); +} + +type AppendInput = { runId: string; sessionId: string; scope: string; audience: 'public' | 'internal'; + eventType: string; raw: RuntimeBodyRecord }; +type AppendOwner = Pick; + +/** Preflight failures must not retire another owner or an already-completed run. */ +export function markActivityFailure(input: AppendOwner, loss: ActivityLoss): void { + try { + db.transaction(() => { + const owner = ownerStmt.get(input.runId, input.sessionId) as OwnerRow | undefined; + if (!owner || owner.scope_key !== input.scope || owner.audience !== input.audience || owner.status !== 'running') return; + const current = readActivityControl(input.runId); + if (current && !current.state.closed) markActivityLoss(input.runId, loss); + }).immediate(); + } catch { console.warn('[activity] failure_metadata_unavailable'); } +} +const append = db.transaction((input: AppendInput): TracePointer | null => { + const owner = ownerStmt.get(input.runId, input.sessionId) as OwnerRow | undefined; + if (!owner || owner.scope_key !== input.scope || owner.audience !== input.audience || owner.status !== 'running') return null; + let current = readActivityControl(input.runId); + if (!current) { + if (input.eventType !== 'turn-start') return null; + const state: ActivityControl = { version: 1, count: 0, bytes: 0, lastSeq: 0, closed: false, loss: null }; + const pointer = appendTraceEvent({ runId: input.runId, source: 'system', eventType: ACTIVITY_CONTROL_TYPE, + raw: state, preview: 'runtime control' }); + if (!pointer) throw new Error('activity_control_write_failed'); + current = { seq: pointer.traceSeq, state }; + } + const c = current.state; + if (c.closed || c.loss) return null; + const bytes = Buffer.byteLength(stringifyTraceValue(input.raw)); + const total = totals.get() as { count: number; bytes: number }; + const allRows = (rowCount.get() as { count: number }).count; + const configuredRows = settings['trace']?.maxRows ?? 50_000; + const loss: ActivityLoss | null = bytes > RUNTIME_BODY_BYTES ? 'event_limit' + : c.count >= ACTIVITY_RUN_ROWS || c.bytes + bytes > ACTIVITY_RUN_BYTES ? 'run_limit' + : total.count >= ACTIVITY_GLOBAL_ROWS || total.bytes + bytes > ACTIVITY_GLOBAL_BYTES + || allRows >= configuredRows ? 'global_limit' : null; + if (loss) { writeActivityControl(input.runId, current.seq, { ...c, loss }); return null; } + const pointer = appendTraceEvent({ runId: input.runId, source: 'runtime', eventType: input.eventType, + raw: input.raw, preview: input.eventType }); + if (!pointer) throw new Error('activity_write_failed'); + writeActivityControl(input.runId, current.seq, { ...c, count: c.count + 1, bytes: c.bytes + bytes, + lastSeq: pointer.traceSeq, closed: input.eventType === 'turn-end' }); + return pointer; +}); + +export function appendActivityBody(input: AppendInput): TracePointer | null { + try { return append.immediate(input); } + catch { + markActivityFailure(input, 'storage_error'); + console.warn('[activity] append_failed'); + return null; + } +} + +export type ActivityPage = { runId: string; sessionId: string; scope: string; status: TraceRunStatus; + events: RuntimeEvent[]; nextAfter: number; through: number; hasMore: boolean; incomplete: boolean; loss: string | null }; +type PageInput = { runId: string; sessionId: string; after: number; through?: number; limit: number }; +const readPage = db.transaction((input: PageInput): ActivityPage | null => { + const owner = getActivityOwner(input.runId, input.sessionId); + if (!owner) return null; + const control = readActivityControl(input.runId); + const high = input.through ?? control?.state.lastSeq ?? 0; + if (high > (control?.state.lastSeq ?? 0) || input.after > high) throw new RangeError('activity_cursor'); + const limit = Math.max(1, Math.min(ACTIVITY_PAGE_ROWS, input.limit)); + const rows = rowsStmt.all(input.runId, input.after, high, limit + 1) as { + seq: number; event_type: string; raw_json: string | null; bytes: number; + }[]; + const page: ActivityPage = { runId: owner.id, sessionId: owner.session_id, scope: owner.scope_key, + status: owner.status, events: [], nextAfter: input.after, through: high, hasMore: false, + incomplete: false, loss: control?.state.loss ?? (control ? null : 'unavailable') }; + // Include identity/envelope overhead; loss/boolean changes fit the reserved bytes. + let used = Buffer.byteLength(JSON.stringify(page)) + 128; + let consumed = 0; + for (const row of rows) { + if (consumed >= limit) break; + let event: RuntimeEvent | null = null; + if (row.raw_json !== null && row.bytes >= 0 && row.bytes <= RUNTIME_BODY_BYTES) { + try { event = decodeRuntimeBody(JSON.parse(row.raw_json), { + version: 1, runId: owner.id, sessionId: owner.session_id, scope: owner.scope_key, seq: row.seq, + }, row.event_type); } catch { /* Corrupt storage advances the scan cursor below. */ } + } + if (!event) { consumed++; page.nextAfter = row.seq; page.loss = 'corrupt'; continue; } + const bytes = Buffer.byteLength(JSON.stringify(event)) + 1; + if (used + bytes > ACTIVITY_PAGE_BYTES) break; + used += bytes; + page.events.push(event); + page.nextAfter = row.seq; + consumed++; + } + page.hasMore = consumed < rows.length; + if (!page.hasMore) page.nextAfter = high; + const missingTerminal = owner.status !== 'running' + && (!control || !terminalStmt.get(owner.id, control.state.lastSeq)); + page.incomplete = page.loss !== null || missingTerminal; + return page; +}); + +export function readActivityPage(input: PageInput): ActivityPage | null { return readPage(input); } diff --git a/src/trace/activity-retention.ts b/src/trace/activity-retention.ts new file mode 100644 index 000000000..9427a2ad3 --- /dev/null +++ b/src/trace/activity-retention.ts @@ -0,0 +1,54 @@ +import { db } from '../core/db.js'; +import { expireActivityPrefix } from './activity-control.js'; + +const rawPredicate = "source <> 'runtime' AND NOT (source = 'system' AND event_type = 'runtime.control.v1')"; +const countRows = db.prepare('SELECT COUNT(*) AS count FROM trace_events'); + +/** Retention never removes an append base from a retained semantic suffix. */ +export function pruneActivityTraceRows(cutoff: number, maxRows: number): { deletedEvents: number; deletedRuns: number } { + return db.transaction(() => { + const before = (countRows.get() as { count: number }).count; + for (const run of db.prepare(`SELECT DISTINCT run_id FROM trace_events + WHERE source = 'runtime' AND created_at < ?`).all(cutoff) as { run_id: string }[]) { + expireActivityPrefix(run.run_id); + } + // A producer may still own an expired, closed projection. Only the trace + // lifecycle status, not control.closed, makes its owner safe to remove. + let deletedRuns = db.prepare(`DELETE FROM trace_runs WHERE started_at < ? AND + (status <> 'running' OR (session_id IS NULL AND scope_key IS NULL AND NOT EXISTS (SELECT 1 FROM trace_events e + WHERE e.run_id = trace_runs.id AND e.source = 'system' AND e.event_type = 'runtime.control.v1')))`) + .run(cutoff).changes; + db.prepare(`DELETE FROM trace_events WHERE created_at < ? AND ${rawPredicate}`).run(cutoff); + const cap = Math.max(0, Math.floor(maxRows)); + let total = (countRows.get() as { count: number }).count; + if (total > cap) { + db.prepare(`DELETE FROM trace_events WHERE rowid IN + (SELECT rowid FROM trace_events WHERE ${rawPredicate} ORDER BY created_at, rowid LIMIT ?)`) + .run(total - cap); + } + total = (countRows.get() as { count: number }).count; + if (total > cap) { + const closed = db.prepare(`SELECT r.id FROM trace_runs r WHERE r.status <> 'running' + AND EXISTS (SELECT 1 FROM trace_events e WHERE e.run_id = r.id AND e.source = 'runtime') + ORDER BY r.started_at, r.id`).all() as { id: string }[]; + for (const run of closed) { + if (total <= cap) break; + total -= expireActivityPrefix(run.id); + } + } + // Tombstones themselves have a finite lifetime under a configured row cap. + // If necessary remove oldest closed owners, preserving active owners even + // when their control has already been closed by expiry. + if (total > cap) { + const closed = db.prepare(`SELECT r.id, COUNT(e.seq) AS count FROM trace_runs r + JOIN trace_events e ON e.run_id = r.id WHERE r.status <> 'running' + GROUP BY r.id ORDER BY r.started_at, r.id`).all() as { id: string; count: number }[]; + for (const run of closed) { + if (total <= cap) break; + deletedRuns += db.prepare('DELETE FROM trace_runs WHERE id = ?').run(run.id).changes; + total -= run.count; + } + } + return { deletedEvents: before - (countRows.get() as { count: number }).count, deletedRuns }; + }).immediate(); +} diff --git a/src/trace/store.ts b/src/trace/store.ts index 3143dcf94..d25197148 100644 --- a/src/trace/store.ts +++ b/src/trace/store.ts @@ -5,6 +5,8 @@ import { JAW_HOME } from '../core/config.js'; import { db } from '../core/db.js'; import type { ToolEntry } from '../types/agent.js'; import { stringifyTraceValue, tracePreview } from './redact.js'; +import { closeActivity } from './activity-control.js'; +import { pruneActivityTraceRows } from './activity-retention.js'; import type { TraceAudience, TraceCarrier, TraceEventInput, TracePointer, TraceRetentionStatus, TraceRunInput, TraceRunStatus } from './types.js'; const TRACE_INLINE_MAX_BYTES = 96_000; @@ -17,6 +19,7 @@ type TraceRunRow = { working_dir?: string | null; agent_label?: string | null; audience?: TraceAudience; status?: TraceRunStatus; raw_retention_status?: TraceRetentionStatus; event_count?: number; byte_count?: number; started_at?: number; finished_at?: number | null; error?: string | null; + session_id: string | null; scope_key: string | null; }; type TraceEventRow = { run_id: string; seq: number; source: string; event_type: string; preview?: string | null; @@ -26,8 +29,8 @@ type TraceEventRow = { const insertRun = db.prepare(` INSERT INTO trace_runs - (id, parent_run_id, cli, model, working_dir, agent_label, audience, started_at, last_event_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, parent_run_id, cli, model, working_dir, agent_label, audience, started_at, last_event_at, session_id, scope_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertEvent = db.prepare(` INSERT INTO trace_events @@ -57,7 +60,7 @@ const maxSeqStmt = db.prepare('SELECT MAX(seq) AS seq FROM trace_events WHERE ru const listRunsForMessageStmt = db.prepare( 'SELECT id, audience, started_at FROM trace_runs WHERE message_id = ? ORDER BY started_at ASC, id ASC'); const listToolEventsForRunStmt = db.prepare(` - SELECT seq, event_type, raw_json, raw_path + SELECT seq, event_type, raw_json, raw_path, bytes, retention_status FROM trace_events WHERE run_id = ? AND source = 'tool' ORDER BY seq ASC LIMIT ? `); // WP4 (devlog 260703 doc 12): live-run hydration reads the NEWEST tool rows and the @@ -76,14 +79,10 @@ const interruptStaleStmt = db.prepare(` UPDATE trace_runs SET status = 'interrupted', finished_at = ?, error = COALESCE(error, 'process exited before finalization') WHERE status = 'running' `); -const pruneEventsStmt = db.prepare('DELETE FROM trace_events WHERE created_at < ?'); -const pruneRunsStmt = db.prepare('DELETE FROM trace_runs WHERE started_at < ?'); -const countAllEventsStmt = db.prepare('SELECT COUNT(*) AS c FROM trace_events'); -const trimEventsStmt = db.prepare('DELETE FROM trace_events WHERE rowid IN (SELECT rowid FROM trace_events ORDER BY created_at ASC LIMIT ?)'); const liveRunIdsStmt = db.prepare('SELECT id FROM trace_runs'); const seqCache = new Map(); -function createTraceId(): string { return `tr_${crypto.randomUUID().replace(/-/g, '')}`; } +export function createTraceId(): string { return `tr_${crypto.randomUUID().replace(/-/g, '')}`; } function ensureTraceDir(runId: string): string { const dir = join(TRACE_DIR, runId); fs.mkdirSync(dir, { recursive: true }); @@ -116,7 +115,8 @@ export function startTraceRun(input: TraceRunInput): string { const id = createTraceId(); const now = Date.now(); insertRun.run(id, input.parentRunId || null, input.cli || 'agent', input.model || null, - input.workingDir || null, input.agentLabel || null, input.audience || 'public', now, now); + input.workingDir || null, input.agentLabel || null, input.audience || 'public', now, now, + input.sessionId ?? null, input.scopeKey ?? null); return id; } @@ -224,6 +224,7 @@ export function stampTraceToolEntries(ctx: TraceCarrier & { toolLog?: ToolEntry[ } export function finalizeTraceRun(runId: string | null | undefined, status: TraceRunStatus, error?: string | null): void { if (!runId || !TRACE_ID_RE.test(runId)) return; + if (status !== 'running') closeActivity(runId); finalizeRunStmt.run(status, Date.now(), error || null, runId); seqCache.delete(runId); } @@ -232,6 +233,9 @@ export function linkTraceRunToMessage(runId: string | null | undefined, messageI linkRunStmt.run(messageId, runId); } export function markStaleTraceRunsInterrupted(): void { + for (const row of db.prepare("SELECT id FROM trace_runs WHERE status = 'running'").all() as { id: string }[]) { + closeActivity(row.id); + } interruptStaleStmt.run(Date.now()); // Interrupted runs never reach finalizeTraceRun, so their seq cursors // stayed in seqCache forever. They receive no further events — the cache @@ -241,10 +245,23 @@ export function markStaleTraceRunsInterrupted(): void { // Remove on-disk spill dirs whose run no longer exists in trace_runs. function pruneOrphanTraceDirs(): void { - if (!fs.existsSync(TRACE_DIR)) return; + if (!fs.existsSync(TRACE_DIR) || !fs.lstatSync(TRACE_DIR).isDirectory()) return; const live = new Set((liveRunIdsStmt.all() as { id: string }[]).map((r) => r.id)); - for (const name of fs.readdirSync(TRACE_DIR)) { - if (TRACE_ID_RE.test(name) && !live.has(name)) fs.rmSync(join(TRACE_DIR, name), { recursive: true, force: true }); + for (const entry of fs.readdirSync(TRACE_DIR, { withFileTypes: true })) { + if (!TRACE_ID_RE.test(entry.name) || !entry.isDirectory()) continue; + const dir = join(TRACE_DIR, entry.name); + if (!live.has(entry.name)) { + fs.rmSync(dir, { recursive: true, force: true }); + seqCache.delete(entry.name); + continue; + } + const retained = new Set((db.prepare('SELECT raw_path FROM trace_events WHERE run_id = ? AND raw_path IS NOT NULL') + .all(entry.name) as { raw_path: string }[]).map(row => resolve(JAW_HOME, row.raw_path))); + for (const file of fs.readdirSync(dir, { withFileTypes: true })) { + if (file.isFile() && /^\d{6,}\.json$/.test(file.name) && !retained.has(join(dir, file.name))) { + fs.rmSync(join(dir, file.name)); + } + } } } @@ -252,12 +269,9 @@ function pruneOrphanTraceDirs(): void { export function pruneTraceEvents(retentionDays = 7, maxRows = 50_000): { deletedEvents: number; deletedRuns: number } { try { const cutoff = Date.now() - retentionDays * 86_400_000; - let deletedEvents = pruneEventsStmt.run(cutoff).changes; - const deletedRuns = pruneRunsStmt.run(cutoff).changes; - const total = Number((countAllEventsStmt.get() as { c: number }).c); - if (total > maxRows) deletedEvents += trimEventsStmt.run(total - maxRows).changes; + const result = pruneActivityTraceRows(cutoff, maxRows); pruneOrphanTraceDirs(); - return { deletedEvents, deletedRuns }; + return result; } catch (error) { console.error('[trace] prune failed:', error instanceof Error ? error.message : String(error)); return { deletedEvents: 0, deletedRuns: 0 }; @@ -313,10 +327,18 @@ export function listToolEntriesForMessage( for (const run of runs) { if (wantAudience === 'public' && run.audience === 'internal') continue; const events = listToolEventsForRunStmt.all(run.id, perRunLimit) as - { seq: number; event_type: string; raw_json: string | null; raw_path: string | null }[]; + { seq: number; event_type: string; raw_json: string | null; raw_path: string | null; + bytes: number; retention_status: TraceRetentionStatus }[]; for (const ev of events) { const tool = traceToolEventToEntry(ev); - if (tool) out.push(tool); + if (tool) out.push({ + ...tool, + traceRunId: run.id, + traceSeq: ev.seq, + detailAvailable: run.audience !== 'internal', + detailBytes: ev.bytes, + rawRetentionStatus: run.audience === 'internal' ? 'internal' : ev.retention_status, + }); } } return out; @@ -334,3 +356,15 @@ export function getTraceEvent(runId: string, seq: number): (TraceEventRow & { ra } return { ...row, raw }; } + +/** Best-effort parser recovery after a tool leaves RAM; tracing cannot stop its provider. */ +export function getTraceToolEntry(runId: string, seq: number): ToolEntry | null { + if (!TRACE_ID_RE.test(runId) || !Number.isSafeInteger(seq) || seq < 1) return null; + try { + const row = getEventStmt.get(runId, seq) as TraceEventRow | undefined; + if (!row || row.source !== 'tool') return null; + const tool = traceToolEventToEntry({ raw_json: row.raw_json ?? null, raw_path: row.raw_path ?? null }); + return tool && typeof tool.label === 'string' && typeof tool.icon === 'string' && typeof tool.toolType === 'string' + ? tool : null; + } catch { console.warn('[trace] tool_recovery_unavailable'); return null; } +} diff --git a/src/trace/types.ts b/src/trace/types.ts index 29d0e80c4..fdcd22bdb 100644 --- a/src/trace/types.ts +++ b/src/trace/types.ts @@ -10,6 +10,8 @@ export interface TraceRunInput { agentLabel?: string | null; audience?: TraceAudience; parentRunId?: string | null; + sessionId?: string; + scopeKey?: string; } export interface TraceEventInput { diff --git a/src/types/agent.ts b/src/types/agent.ts index aedaec29c..b52d364fc 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -4,6 +4,8 @@ import type { WatchdogHandle } from '../agent/watchdog.js'; import type { TracePointer } from '../trace/types.js'; import type { RuntimeTurnOutcome } from '../shared/runtime-contract.js'; +import type { ActivityIdentity } from '../shared/presentation.js'; +import type { PrintActivityProjection } from '../agent/runtime/print-projection.js'; export interface ToolEntry { icon: string; @@ -46,8 +48,13 @@ export type AgyLastActivitySource = 'stdout' | 'stderr' | 'transcript' | 'none'; /** Context object created per spawnAgent() invocation. */ export interface SpawnContext { + /** Captured jaw owner for presentation; sessionId below remains the provider ID. */ + activityIdentity?: ActivityIdentity; + printActivity?: PrintActivityProjection; /** Explicit native result; never inferred from compatibility text or Activity. */ runtimeOutcome?: RuntimeTurnOutcome; + /** Private once-only compatibility publication marker, never serialized as an event field. */ + runtimeTerminalAttempted?: boolean; fullText: string; /** Set when fullText hit FULLTEXT_MAX_CHARS and later output was dropped. */ fullTextTruncated?: boolean; diff --git a/structure/AGENTS.md b/structure/AGENTS.md index 1e5ba97e5..436beb257 100644 --- a/structure/AGENTS.md +++ b/structure/AGENTS.md @@ -2,6 +2,12 @@ # structure/ — Sync Guide +- Print observation and tool convergence changes sync `runtime-integration.md`, `agent_spawn.md`, `prompt_flow.md` and `stream-events.md`. The lifecycle remains the final selector; native outcome/salvage and channel ACK/queue owners are unchanged. Never let raw trace failures interrupt accepted provider output. + +- Journal changes synchronize nullable trace ownership/backfill, strict replay/raw reads, whole-prefix retention and caller session capture. Keep immutable runtime rows distinct from mutable tool/control rows; finalization uses the DB-only control leaf. Source ownership and limits are documented in `runtime-integration.md`. + +- Activity identity and display settings: `shared/presentation.ts`, config/settings-merge, runtime-settings and orchestrate snapshot share the contract in `runtime-integration.md` and `server_api.md`. Mode is independent of transport; snapshot identity is server-owned, including when multi-session is disabled. + - Keep this folder aligned with the live `cli-jaw` tree. The current hub covers 19 Markdown docs plus 5 support files. - Update `INDEX.md` whenever a doc is added, removed, renamed, or re-scoped. Keep the doc map, tier list, and quick links in sync. - Update `str_func.md` and `verify-counts.sh` together when source counts, `server.ts`, `src/routes/*`, `src/cli/handlers*.ts`, `src/cli/api-auth.ts`, `src/manager/*` (multi-instance dashboard), `bin/commands/*`, `bin/star-prompt.ts`, `tests/`, `public/`, or generated-dist exclusions change. The verifier now checks every file-tree `(NNNL)` entry in `str_func.md`, not only curated hotspots. @@ -23,14 +29,17 @@ When refreshing docs from recent non-strict commits, check these first: - `src/shared/tool-log-sanitize.ts`: bounded tool-log storage/delivery protects Web UI and Manager ProcessBlock hydration. - `src/core/platform-kind.ts`: canonical platform classification (`windows-native | wsl | linux | darwin | other`). `process.platform` decides first, so a `win32` process is never `wsl`, and `WSLENV` must never be treated as a WSL signal — Microsoft shares it with the Windows host, which is what made `doctor`/`postinstall` misfire on native Windows. `browser-open.ts`, `browser-open-default.ts`, `browser/connection.ts`, and `bin/commands/doctor.ts` delegate to it; `bin/postinstall.ts` asks the separate launch-origin question via `isWindowsNodeLaunchedFromWsl` + `resolveInvocationCwd`. `src/lib/tui/terminal.ts` is excluded on purpose (vendored, outside the root `tsconfig`). Do not add a new hand-rolled WSL check. - `src/messaging/send.ts` + `src/routes/messaging.ts`: `/api/channel/send` is canonical outbound channel delivery. Concurrent inbound gateway: `messaging.enabledChannels` (array) + `messaging.homeChannel`; legacy `settings.channel` is a deprecated read-only alias for one major version; restart only affects changed channels. -- Mid-run steer: `multiSession.midRunPolicy` defaults to `'steer'`; in-band same-turn steer for jwc and codex-app (`turn/steer` via `MainRunState.steerTurnInBand`), queue fallback otherwise, and kill-path `/steer` salvages the interrupted partial output into the follow-up prompt (`withSteerContext` + exit-settle barrier in `src/agent/spawn.ts`). Decision order and runtime matrix: `structure/prompt_flow.md` §Mid-run 메시지 정책. +- Mid-run steer defaults to `'steer'`: JWC/Codex App use in-band input; native Cursor uses original-cancelled-response/drain/idle followed by same-session cancel-reprompt. Its separate `replaceTurn` hook and local-dispatch commit barrier keep one logical final and never queue fatal failures. Preserve the remaining runtimes' existing kill/salvage behavior and explicit followup/collect queues; `/queue steer` remains a separate forced interrupt. Sync `prompt_flow.md` and `commands.md`. - `src/prompt/conversation-context.ts` + `src/agent/spawn.ts`: Slack Boss turns get explicit `channel_id` and parent `thread_ts` in the per-turn user prompt regardless of multi-session state; keep this separate from the cache-stable system prompt and internal session labels. - `src/slack/mention-watch.ts` + `src/memory/heartbeat-mention-watch.ts` + `src/memory/heartbeat.ts` + `src/messaging/forwarder-origin.ts`: Slack mention watching is an opt-in `mentionWatch` mode inside `runHeartbeatJob`, not a daemon. It uses bot-token `conversations.history` because `search.messages` is user-token-only, keeps per-channel frontier/resume state, rotates channels, stops a tick on 429, reports overflow beyond 60 channels, and re-intersects the configured non-empty channel subset with the live allowlist. Each hit yields to PABCD/agent/message-queue/pending-replay work; the agent returns answer text only, the server sends it to the source thread and records seen after success, and producer-owned `heartbeat` output bypasses channel forwarders. Delivery is at-least-once. The answer turn runs with the answered thread's own `chatSessionId` but in a dedicated `mention-watch:` execution scope, never the thread's inbound scope: sharing it would make the background turn visible as busy to the next human message, which is then steered into it instead of starting its own run. That per-thread placement also replaces the global-only yield with `getState(remoteKey) === 'IDLE'` + `hasChatSessionWork(chatSessionId)` + a non-blocking `sessionLanes.hasPending` check, and the session is minted only once a hit is actually being answered because a remote-bound session row cannot be deleted afterwards. - `src/memory/mention-watch-ledger.ts` + `src/memory/legacy-mention-watch-quarantine.ts` + `src/slack/verified-workspace.ts`: The receipt/cursor ledger is keyed by (jobId, workspaceId, userId): Slack identifies a person as (team_id, id) and one runtime can re-authenticate against a different workspace, so a job-only key hands one person's cursor to another. The workspace id comes from a bot-token `auth.test` cached per token, never from mutable `settings.slack.teamId`, and a failed lookup skips the tick rather than guessing. Pre-v2 ledger rows carry no workspace or user, so a job holding them is HELD out of scheduling until an operator restarts it with a fresh `since` through `POST /api/heartbeat/:jobId/mention-watch-fresh-start`; the hold is durable in SQLite because `heartbeat.json` is operator intent while the hold is the system's judgement, and a downgrade that writes v1 rows again re-quarantines. A duplicate job id in one PUT is a 400, since two jobs under one id share one namespace. - `src/core/config.ts` + `src/routes/settings.ts` + `bin/commands/init.ts` / `slack.ts` + Slack Settings UIs: configured `SLACK_*` variables own their matching fields at runtime. API snapshots expose variable names only; Settings/reset and CLI setup stay conservatively locked while any are present, generic mutation rejects only env-owned paths, and persistence strips only those paths so effective env values never enter `settings.json` or delete unrelated file-backed credentials. - `src/core/event-bus.ts` + `src/routes/events.ts` + `public/js/event-channel.ts`: Web event delivery is SSE-first through `GET /api/events` with WebSocket fallback for legacy servers. +- `src/agent/runtime/requests.ts` + `acp/callbacks.ts` + `src/routes/runtime-requests.ts`: exact-session native decision GET/POST, opaque handles, canonical pre-insertion sanitization/32KiB event budget,128-entry/120s registry and32-callback limits. Preserve captured currentness and cancellation latches after answer resolution; retire an unflushed selected write on cancel. Existing instance auth includes loopback/LAN policy and is not a tenant ACL. Keep `runtime-integration.md`, `server_api.md` and root README/AGENTS/CLAUDE synchronized; do not imply provider/UI activation or messaging changes. - `src/shared/runtime-contract.ts` + `src/agent/runtime/*`: canonical Codex/Pi projection bypasses internal messaging listeners; explicit native outcome is optional and preserves null/empty finality and partial MESSAGE salvage. Keep terminal finality/status/trace correlation aligned across lifecycle, pipeline/collector and web/TUI. Native formatter-empty guards must not alter untagged delivery/ACK/queue behavior. Sync `runtime-integration.md` and `stream-events.md` before adapter activation. - `src/agent/runtime/selection.ts`: Cursor/Grok/Claude native/print selection, independent main/worker implementation flags and isolated native-v1 buckets. Keep boot/init/watch validation, captured spawn/lifecycle identity, exact reset/compact semantics and additive CLI-status diagnostics synchronized in `runtime-integration.md`. Existing mode absence is print, not a fresh-default opt-in; Codex App/Pi retain legacy keys. +- Native Cursor main uses `runtime/acp/runtime-session.ts` plus `native-runtime-run.ts`; explicit auto only, no worker activation. Early guard precedes all preparation, private I/O liveness never carries text to messaging, and explicit execution bindings survive the global multi-session toggle. Claims/finalizers, live trace ownership, retirement fences and exact exit-settler cleanup are separate from provider wire completion. +- `runtime/acp/replacement*.ts` owns private prompt-attempt epochs and one logical result; `runtime/replace-turn.ts` maps local-dispatch/no-start/fatal receipts. Cursor's captured prompt closure restores bounded original/accepted/partial context and active rules. Main object and canonical generation must still match at input commit; trace/log observer failure must not cause inference retry. Anonymous same-session frames after B admission depend on provider ordering and cannot be identified by a local epoch alone. - `src/browser/runtime-*`, `src/browser/tab-lifecycle.ts`, `src/browser/web-ai/session*.ts`: browser docs should mention runtime diagnostics, orphan cleanup, tab lifecycle, and web-ai session reattach. - `src/browser/adaptive-fetch/*`, `src/routes/browser.ts`, `bin/commands/browser.ts`: browser docs should keep `browser fetch ` scoped as an adaptive URL/search-result reader, not generic search, with browser escalation and third-party reader opt-in boundaries explicit. - `src/routes/traces.ts` / `src/trace/*`: server docs should include public trace read routes and related WebSocket/event surfaces such as `alert_escalation`. diff --git a/structure/INDEX.md b/structure/INDEX.md index 0af46381c..c8d92a58e 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -6,6 +6,12 @@ aliases: [CLI-JAW Architecture Reference, cli-jaw 구조 허브, structure index # CLI-JAW Architecture Reference +Activity backend: `shared/presentation.ts` owns display/identity decoding; +`trace/activity-journal.ts` owns bounded durable replay on existing trace storage. +`activity-control.ts` and `activity-retention.ts` keep finalization and pruning independent +of runtime projections. Snapshot/trace contracts are in `server_api.md`; ownership, +loss and delivery separation are in `runtime-integration.md` and `stream-events.md`. + > cli-jaw 프로젝트의 내부 구조를 기술한 아키텍처 문서 허브. 시스템 전체 흐름부터 개별 모듈까지, 이 파일에서 시작하세요. > > Planning state lives separately under `devlog/_plan/README.md`. The latest GitHub-issue triage snapshot (2026-05-16) is in that file's "Triage Snapshot" section. diff --git a/structure/agent_spawn.md b/structure/agent_spawn.md index 1bfb0eb1a..e5fd9513f 100644 --- a/structure/agent_spawn.md +++ b/structure/agent_spawn.md @@ -8,6 +8,13 @@ aliases: [CLI-JAW Agent Spawn, agent runtime, ACP orchestration] # Agent Spawn — agent/ · orchestrator/ · cli/acp-client · goal/ +Activity captures the durable chat/scope at trace admission. Native Pi/Codex retain their +own projections; Copilot and ordinary print create one print observer. Accepted parser +text precedes legacy resets and the existing lifecycle callback supplies application-final. +Error and AGY stale-retry paths that bypass lifecycle close observer/trace independently, +without another message or retry. `agent_output` and `agent_tool` stamp captured identity +after incidental payload fields, including when multi-session is disabled. + > CLI spawn + ACP 분기 + Pi RPC + 스트림 + 큐 + 메모리 flush + PABCD 오케스트레이션 + goal-mode autonomy > 현재 기준: `src/agent/` 46개 TS 파일, `src/orchestrator/` 15개 파일 (+`attestation.ts`), `src/goal/` 5개 파일 (+`pause-gate.ts`), `src/cli/acp-client.ts` diff --git a/structure/commands.md b/structure/commands.md index 78c798d0f..26bd883f2 100644 --- a/structure/commands.md +++ b/structure/commands.md @@ -215,6 +215,14 @@ JWC is not bundled with the default npm install or Electron sidecar. Use `jaw jw ### `/steer ` +- Native Cursor는 `replaceTurn` 훅을 쓴다. 원래 prompt의 취소 응답과 drain 뒤 + 같은 native session에 재요청하는 `cancel-reprompt`이며, native-input은 아니다. + 원래 요청·수락된 추가 지시·제한된 부분 출력을 문맥으로 복원한다. 입력 기록은 + 로컬 전송 완료와 소유권 재검사 뒤 한 번만 한다. 진행 중인 replacement는 후속 입력을 + 큐로 보낼 수 있지만, 불확실한 전송·기록 실패는 자동 재시도하지 않는다. + Stop으로 취소된 대기 지시는 `cancelled`로 끝내며, 큐에 되살리지 않는다. +- `/queue steer `은 별도 큐 항목 우선 실행이다. 기존 항목 제거·interrupt·exit-settle· + salvage 뒤 새 run을 시작하며, 같은 세션 replacement로 바꾸지 않는다. - Web/Telegram/Discord/Slack에서 실행 가능. CLI slash registry에는 노출되지 않는다. - 실행 중 agent가 없으면 에러. - 런타임이 in-band steer를 지원하면(jwc, 또는 steer 가능한 turn이 진행 중인 codex-app) diff --git a/structure/infra.md b/structure/infra.md index c54490d40..c037a17e6 100644 --- a/structure/infra.md +++ b/structure/infra.md @@ -8,6 +8,13 @@ aliases: [CLI-JAW Infra, infrastructure modules, core runtime] # 인프라 모듈 — core/ · messaging/ · telegram/ · discord/ · memory/ · browser/ · routes/ · security/ · http/ · lib/mcp-sync +Activity storage extends trace_runs with nullable session/scope owners. Its original +message-link backfill runs after messages.session_id migration. `activity-control.ts` +is a DB-only leaf shared by journal, finalization and retention; `activity-retention.ts` +expires whole runtime prefixes and protects active owners. One corrupt control cannot +roll back unrelated retention. Symlink trace roots are never traversed for spill cleanup. +See `runtime-integration.md` for budgets and loss semantics. + > 의존 0 모듈 + 데이터 레이어 + 멀티 채널 메시징 + 외부 도구 통합 > 현재 tree 기준으로 `src/core/`는 support cluster, `src/messaging/`는 Telegram/Discord 공통 런타임, `src/telegram/`·`src/discord/`는 각 채널 transport 구현으로 분리됨 diff --git a/structure/prompt_flow.md b/structure/prompt_flow.md index 7b1ca92a2..2b99bb1ad 100644 --- a/structure/prompt_flow.md +++ b/structure/prompt_flow.md @@ -8,6 +8,12 @@ aliases: [Prompt Injection Flow, CLI-JAW prompt flow, prompt pipeline] # 프롬프트 삽입 흐름 — Prompt Injection Flow +Activity is an observation sink, not a prompt or delivery source. Print parsers retain +accepted intermediate text before legacy resets, while final text still comes from the +existing lifecycle decision. Journal/link failures do not roll back an already-written +MESSAGE, re-enter the handler, or introduce a new inference/send. Semantic SSE bypasses +collectors and messaging listeners; request replay remains non-actionable. + > cli-jaw의 프롬프트 조립 + 주입 전체 흐름. 현재 기준 소스는 `src/prompt/builder.ts` 1040L, `src/memory/injection.ts`, `src/agent/spawn.ts` 2011L, `src/prompt/templates/*` (a1-system 388L, a2-default 25L, orchestration 120L, employee 73L, control-system 56L, worker-context 11L, skills 24L, heartbeat-jobs 4L, heartbeat-default 4L, vision-click 3L). --- @@ -182,13 +188,21 @@ gateway가 정책을 적용한다 (src/orchestrator/gateway.ts). 결정 순서: |--------|-------------------|-----------| | jwc | in-band (pi `session.prompt` streamingBehavior 'steer') | 완전 (같은 턴) | | codex-app | in-band (app-server `turn/steer` — `MainRunState.steerTurnInBand` 훅이 active-turn 동안만 설치됨) | 완전 (같은 턴) | -| 그 외 (codex legacy exec, claude, cursor, grok, opencode, pi, agy, copilot, kiro) | **kill-steer**: 진행 턴을 kill하고 새 run — 단, 중단된 부분 출력이 `withSteerContext`로 재주입됨 | 부분 출력 보존 | - -큐로 물러나는 것은 in-band 시도가 race(턴 종료)나 turn kind(review/compact)로 -실패한 경우뿐이다. 큐 대기를 원하면 `followup`/`collect` 정책을 쓴다. -명시적 `/steer`·`/queue steer`도 같은 kill-path를 쓰며, 중단된 턴의 부분 출력이 -exit-settle 배리어 + `withSteerContext`로 follow-up 프롬프트에 주입된다 -(structure/commands.md `/steer` 참고). +| cursor native | `cancel-reprompt`: 원래 prompt 취소 응답·callback·업데이트 drain 뒤 같은 native session에 재요청. jaw 논리 run과 최종 정산은 하나 | 원래 요청·수락된 추가 지시·제한된 부분 출력과 현재 운영 지침을 복원 | +| 그 외 (codex legacy exec, claude, cursor print, grok, opencode, pi, agy, copilot, kiro) | **kill-steer**: 진행 턴을 중단하고 새 run. `withSteerContext`가 제한된 부분 출력을 재주입 | 제한된 부분 출력 보존 | + +기존 in-band 경로의 큐 처리는 유지한다. Native Cursor는 replacement가 진행 중이면 +다음 입력을 큐로 보낼 수 있지만, 취소·전송·입력 기록 실패를 큐 재시도로 바꾸지 않는다. +Stop으로 무효화된 미전송 지시는 별도 `cancelled` 결과로 끝내고 다시 제출하지 않는다. +로컬 write 완료는 모델의 수락 ACK가 아니다. 입력 기록 직전에도 main 객체·세대· +정규 소유권을 확인하며, 빠른 최종 응답도 그 기록보다 먼저 정산되지 않는다. +기다리려면 `followup`/`collect`를 선택한다. + +명시적 `/steer`는 같은 런타임 훅을 사용한다. `/queue steer `은 별도 우선 실행 +명령으로, 기존 interrupt + 새 run을 유지한다. Kill 경로에서는 pre-kill MAX(id)와 +정확한 exit-settle 배리어 뒤 부분 출력을 복원한다. 복원 범위는 제한되며 전체 맥락을 +보장하지 않는다. B 시작 이후 무상관 ACP 프레임은 provider의 전송 순서 준수를 +전제로 한다. 로컬 epoch만으로 늦은 A 프레임을 식별할 수는 없다. ### PABCD evidence gate (`--attest`) diff --git a/structure/runtime-integration.md b/structure/runtime-integration.md index 7319a34c2..2ee3821e9 100644 --- a/structure/runtime-integration.md +++ b/structure/runtime-integration.md @@ -8,6 +8,77 @@ tags: [cli-jaw, codex-app, pi, opencodex, runtime-pool] ## Shared event contract foundation +`presentation.mode` selects `activity` (fresh and upgraded absent setting) or `legacy`. +Explicit legacy survives save/reload. It does not select a provider transport. A +presentation-only settings PATCH preserves execution configuration, session selection, +fallback state and external delivery; mixed patches retain their existing behavior. + +`GET /api/orchestrate/snapshot?session=` returns a bare snapshot with +`activityIdentity: {sessionId, scope}` alongside the existing fields. The same captured +scope selects its orchestrator, live run, workers and queue. Malformed selectors are400; +unknown selectors are404 when multi-session is enabled. With that feature disabled the +existing resolver returns the actual active chat and default execution scope. Omitting +the selector also uses the active chat. Clients use `parseActivityIdentity` before +semantic admission and never derive a native session ID or scope from UI state. +`parseRuntimeRequestView` exposes the existing request-view validator without widening +the RuntimeEvent schema. Snapshot responses are no-store; auth remains instance-level. + +Activity history uses the existing trace allocator and immutable `source=runtime` rows. +Nullable `trace_runs.session_id/scope_key` capture the jaw chat and execution scope at +admission, including internal workers. Historical backfill uses only the original +`trace_runs.message_id` link and leaves unknown scopes null; copied fork pointers do +not grant access. Deleting a chat removes its owned traces. Clearing messages alone +does not securely erase retained trace history. + +Native Cursor main captures these same owner fields at its trace admission, alongside +the four existing Copilot/Pi/Codex App/ordinary print admissions. Explicit captured +execution bindings remain authoritative even when multi-session is disabled. + +`trace/activity-journal.ts` commits a bounded body and one mutable control row atomically +before SSE publication. Limits are32KiB/body,4096 rows/4MiB/run,20000 rows/32MiB global, +plus configured trace row admission. Loss closes projection admission without interrupting +existing final delivery or MESSAGE salvage. Internal audience stays private. The DB-only +control/retention modules avoid a store-to-journal import cycle; finalization closes +metadata best effort. Corrupt control reads are bounded and cannot stop unrelated pruning. + +Replay captures a fixed through cursor and scans at most40 events/256KiB per page. +Sequence gaps are valid; corrupt rows advance nextAfter and mark incomplete. Whole-prefix +retention preserves a loss watermark; active owners survive even after projection expiry. +Closed metadata may be evicted entirely under row pressure. Raw spill cleanup refuses +symlink roots and child links. Missing journals never justify retrying inference or sending +another answer. Replay request views are historical and non-actionable. + +Legacy print observation uses `print-projection.ts` (scalar identities only) and the +server factory `print-activity.ts`. The factory reuses RuntimeProjection redaction, +preview bounds and one failure-gap latch. Capacity/truncation is explicit loss rather +than a silently complete journal. Dedicated parsers observe accepted text/reasoning +before resets; plain Claude incremental blocks append, complete streamed snapshots +replace. Copilot ACP replay remains muted; generic hooks observe accepted display text +only. Existing final selection is unchanged and its callback supplies application-final. +Compatibility completion can precede the canonical terminal; clients must deduplicate +both orders and preserve compatibility when the canonical terminal is absent. + +The captured ctx ActivityIdentity never reads provider sessionId. Existing legacy tool +and output packets carry its sessionId/scope plus traceRunId after payload overrides, +including when multi-session is disabled. Internal audience remains private. Normal +print and Copilot create one observer; native Pi/Codex retain their existing projection. +Spawn errors and AGY's existing stale retry close the failed attempt independently of +the ordinary lifecycle. Observer and trace finalization failures are caught separately. + +`mergeLatestTools` preserves primary order, latest status/detail and explicit empty +detail; ref and numeric trace-seq keys are distinct domains within a run. Unknown worker +owners and identityless entries are never guessed. A terminal tool cannot regress to a +late running update. One authoritative omission marker stays at the head. Snapshot +hydration reads bounded durable rows even at equal counts and keeps missing RAM fallback; +history hydration synthesizes existing trace pointers. `getTraceToolEntry` safely reuses +the trace decoder when a tool leaves RAM, preserving its stable ref even on read failure. + +Print alone opts into explicit terminal-to-terminal tool refreshes. The latest explicit +legacy terminal status/detail wins; a running or missing status never reopens a terminal. +Native defaults retain terminal error/output/detail ownership, missing-metadata enrichment, +budget preservation and published-item lookup. No new event schema or display setting +controls this internal distinction. + `src/shared/runtime-contract.ts` defines native/print capabilities, distinct native-input/cancel-reprompt/queued/restart controls, and versioned presentation events. A jaw chat session and routing scope are separate from private provider session IDs. `RuntimeTurnOutcome` keeps authoritative `finalText` (null means absent; an empty string is intentional) separate from partial text. `src/agent/runtime/events.ts` records a validated, redacted body through the existing trace writer before publishing `agent_runtime` on the agent event topic. The trace writer owns sequence allocation; sequence gaps are valid. The tuple codec in `src/trace/runtime-body-codec.ts` preserves numeric usage without weakening raw-trace secret masking. Known structured fragments must be sanitized before clipping by their producer. Recording failure returns null, never a fabricated event or another inference. @@ -22,16 +93,66 @@ Telegram hub-member native target replies require the hub's additive bodyDeliver ## Transport selection and session identity -`perCli..transport` accepts `print` or `native` only for Cursor, Grok and Claude. This layer still defaults all three to print and rejects native selection with an actionable code78 error before fallback, saved-session reads, bootstrap consumption or worker isolation. Their ACP/SDK adapters are separate follow-on layers. Builtin Codex App and Pi retain their existing native paths and keys. Main-adapter support and worker support are independent flags. +`perCli..transport` accepts `print` or `native` only for Cursor, Grok and Claude. All three still default to print. Cursor main has a native ACP path with literal auto permissions; restrictive Cursor and unsupported Grok/Claude/worker selections are rejected with code78 before prompt-file regeneration, bucket/bootstrap/snapshot, fallback or pool work. Builtin Codex App and Pi retain their existing paths and keys. Main-adapter support and worker support are independent flags. Existing documents with no transport field are pinned to print before fresh defaults are merged. Existing corrupt/unreadable or established missing-file homes also stay print; persistence-blocked behavior is unchanged. A genuinely fresh init records factory choices explicitly. Invalid API fields are rejected; watcher input drops only the invalid transport and preserves current mode/siblings. A real watcher mode change invalidates existing ownership generations after settings commit and before publication. -Print keys are byte-for-byte unchanged. Future switchable native sessions use `native-v1:` before the entire opaque legacy key and never update the print singleton. Spawn captures this identity once; both lifecycle saves and all compact paths receive the captured transport/bucket. Scoped new/reset removes exact print/native keys only, including bare/default aliases for the default scope; colon-containing scopes are not a hierarchy. Instance-wide clear retains its existing Codex all-lane exception. Automatic native compaction preserves the print singleton; explicit CLI switching retains its prior fresh-start semantics. +Print keys are byte-for-byte unchanged. Switchable native sessions use `native-v1:` before the entire opaque legacy key and never update the print singleton. Spawn captures this identity once; lifecycle saves and explicit compact paths receive the captured transport/bucket. Scoped new/reset removes exact print/native keys only, including bare/default aliases for the default scope; colon-containing scopes are not a hierarchy. Instance-wide clear retains its existing Codex all-lane exception. Explicit native outcomes skip print-era automatic compact/count/high-turn-reset heuristics; native providers manage their own context. Explicit CLI switching retains its prior fresh-start semantics. `GET /api/cli-status` adds `runtimeSelection: {transport, nativeAdapterImplemented, nativeWorkerImplemented}` only to those three engines plus builtin Codex App/Pi. These are compiled implementation flags, not authentication/binary/probe readiness. Existing status evidence and non-participating rows remain unchanged. Display preferences and runtime transport choice are separate controls; the Activity default is not enabled by this layer. +## ACP v1 transport boundary + +`src/agent/runtime/acp/wire.ts` owns the shared single-envelope decoder. The legacy Copilot `AcpClient` routes a method-bearing peer request before looking up a pending client request, so equal bidirectional IDs cannot consume each other's work. Existing Copilot spawn arguments, permissions, activity timers and callbacks remain unchanged. Malformed stdout is still ignored there, without logging the raw malformed line. + +`src/agent/runtime/acp/connection.ts` is the native transport primitive; the session/factory/main bridge owns activation and process reaping. It delivers callbacks and notifications while an outgoing prompt is pending. `request()` exposes `dispatched` (local Writable completion) separately from `result` (the RPC response). An id-less `session/cancel` write is not a remote cancellation acknowledgement: for the targeted v1 lifecycle, the original prompt response establishes completion. The newer v2 lifecycle is not mixed into this adapter. See the versioned [ACP v1 transport](https://agentclientprotocol.com/protocol/v1/transports) and [prompt-turn contract](https://agentclientprotocol.com/protocol/v1/prompt-turn). + +The native connection accepts string/safe-integer IDs and individual envelopes; null/unsafe-number IDs and batches fail closed. These are explicit interoperability limits, not claims that JSON-RPC forbids null IDs. Incoming UTF-8 is validated after reassembling split bytes. Payloads are capped at4MiB excluding LF/CRLF, with geometrically grown bounded carry storage (one extra byte only for a split CR delimiter). Outgoing active+queued work is capped at8MiB including LF and1024 entries; at most64 outgoing requests may await results. Each write has a30-second deadline from admission, independent of its RPC result deadline. A stalled notification/reply or an early response cannot strand dispatch indefinitely. + +Malformed frames, I/O failure, timeout, EOF or child exit close once and reject all pending results and active/queued writes. Late errors are consumed; late replies/callbacks cannot reopen the connection. Diagnostics never include malformed payloads or provider error text/data. The caller must synchronously admit frames into a bounded consumer and retire/reap its own child when notified of failure. Consumer queues, real provider lifetime, permissions and remote cancel-reprompt acceptance are verified in their adapter layers, not inferred from these transport tests. + +## Native pending decisions + +`src/agent/runtime/requests.ts` holds ephemeral, exact-bound decisions: runId, jaw sessionId, scope and turnId must all match, and the captured ownership predicate must remain current. It retains at most128 entries for120seconds, prunes expired/stale entries and settles once. Responses are validated synchronously before a second ownership/entry check. Invalid choices remain correctable; asynchronous validators cannot orphan the request. Cancellation data is an independent bounded, deeply frozen JSON snapshot, never a mutable caller alias or a timer closure retaining raw input. + +Cancellation snapshots accept plain JSON trees only, not shared references, cycles, accessors or executable values. Copying charges JSON UTF-8 bytes before allocation grows beyond32KiB, with32-level/32768-node traversal bounds. No unrestricted clone or graph-expanding whole-object serialization precedes that check. + +Admission uses the canonical request-view sanitizer and the same encode/redact/decode plus32KiB byte gate as public runtime events, including optional parent identity. The immutable stored view is cloned for GET; raw provider option identifiers and validators never enter the DTO. The response promise alone carries the mapped native decision. Restart cannot recreate executable request handles from history. + +`acp/permissions.ts` validates core v1 permission params, including nullable titles, and chooses unattended options by protocol kind, not localized labels. Only literal auto selects allow_once (then allow_always); safe/custom arrays, including[] and['auto'], wait for a decision. `acp/callbacks.ts` owns at most32 callbacks per connection, while human waits remain independent of notification parsing. It maps fresh jaw handles to native option IDs only in live closures and refuses unsupported filesystem/terminal/question extensions without executing host operations. + +Cancellation latches outlive a resolved registry answer. cancelRun/dispose prevent a later selected reply; cancelling an already-admitted but unflushed selected reply retires the connection because transport bytes cannot be retracted. Previously delivered bytes cannot be undone. The current-run fence rejects late grants without growing historical state or disposing the reusable dispatcher on every turn. The session adapter must dispose it on connection retirement and provide emitters/currentness bound to captured turns. Failed publication cancels the invisible request, and every mapping is disposed after its callback. + +The two request routes use existing instance auth, including loopback and configured LAN bypass; they are not an OS sandbox or per-session tenant ACL. Exact IDs prevent misrouting and replay, not authority escalation between already-authorized instance users. API accepted means decision recorded, not tool completion. Provider activation, Activity controls and channel interactions remain separate layers; Slack final/ACK/queue behavior is untouched. + ## Resident Runtime Pool (`src/agent/runtime-pool.ts`) +### Cursor main native bridge + +`runtime/acp/runtime-session.ts` captures one immutable application identity per send and maps ACP through the existing bounded RuntimeProjection. Active message segments are closed at real tool/thought/ID boundaries; only an eligible end_turn segment is an answer, never concatenated earlier commentary. Consecutive anonymous same-type chunks remain indistinguishable without a protocol boundary. Raw final/partial use the existing8,388,608-code-unit bound independently of3,000-character display previews and journal failure. Optional usage/observer failure does not become a new inference. + +`native-runtime-run.ts` separates protocol result, immutable claim and application finalization. Main claims before lifecycle; onRuntimeEnd publishes the policy-selected terminal even after the main map has been removed. A private pre-broadcast attempt marker and captured outcome prevent duplicate terminal/lifecycle calls after a listener failure. Owned cancellation/cleanup/retirement/release and the captured exit barrier have independent finalization stages. Native compatibility events expose finality/status, never partialText or the full outcome object. + +Server execution bindings are frozen once and forwarded through collector/pipeline/spawn. Explicit scope/chat values or captured server context survive multi-session off, including dedicated mention-watch; only automatic derivation follows the toggle. Pipeline derivation overwrites arbitrary persistedScopeId with the owned remoteKey or null, so a stale caller hint cannot select another scope. Private non-text I/O callbacks renew only the matching live collector and are removed before pooled reuse. Native live-state reads/clear compare the captured trace, so an older finalizer cannot erase a replacement. Display/history integration remains a separate layer. + +### Cursor cancel-reprompt + +`acp/replacement.ts` and `acp/replacement-turn.ts` keep one logical send while private protocol attempts cancel and restart. The replacement waits for the original cancelled RPC response, all old callbacks/notifications, prompt finally and idle. Intermediate cancellation does not publish a logical turn-end, save an interrupted assistant MESSAGE or release the lease. A final Stop retains the existing stopped/salvage policy. + +Cursor injects a captured preparation closure: original raw request plus previously accepted redirects are read-only context (the existing10-row/8000-character context budget); incomplete assistant output uses the existing4000-character tail; current operational rules remain active. Headers, separators and omission markers count against the context budget and clipping does not split surrogate pairs. Only successful input commits update the copied accepted history. Grok can reuse the neutral controller without this Cursor-specific reinjection. + +The separate `MainRunState.replaceTurn` hook is recognized by `canSteerAgent` and explicit `/steer`. A local-dispatch callback records the original incoming text once, before a rapid logical final. Exact main object, run generation and canonical ownership are rechecked before commit; returned thenables and uncertain cancellation/dispatch/recording failures are fatal, never an automatic queued retry. Concurrent replacement admission is single-flight; busy/natural-race no-start input may queue. Stop-invalidated input is a distinct cancelled receipt and settles the existing request as cancelled without resubmission. A new-run outcome is not submitted again. `/queue steer` remains its separate forced interrupt/priority-run operation. + +Wire limits are explicit: terminal-plus-late-content in one chunk and content in the idle gap are detectable violations; stale RPC and opaque request identities remain fenced. After B starts, anonymous same-session content carries no attempt provenance, so correct attribution relies on ACP v1 flush-before-terminal ordering. Native-input or unconditional provider memory retention is not claimed. Existing login is reused; a host using Keychain may select it with its existing credential-store environment option without changing stored credentials. + +`steer-input-guard.ts` retains only transient pending-input cancellation tokens per scope. Native no-start handling and both slash/gateway fallback consumers hold a token until their actual enqueue decision. Scoped/aggregate Stop invalidates pending tokens even after the main run has disappeared; later new input gets a fresh set, and an old release cannot remove it. Every consumer releases in finally. This closes busy-result and return-to-consumer races without mistaking natural completion for Stop or retracting a successful dispatch. + +`runtime/acp/session.ts` owns one child/session with initialize/auth/new-or-load, negotiated configuration, prompt response fencing, cancellation and callback/notification drain. Its matched-response observer closes new content and callback admission before later frames in the same chunk. Setup requires an actual load response; replay is not a live turn. Notifications are bounded at256/8MiB and human callbacks remain independent. Drain and cancellation each have bounded deadlines after dispatch; stopped or failed connections are never reused. Stderr is continuously discarded with only a byte count retained. + +`runtime/acp/cursor-session.ts` launches the resolved Cursor executable with acp via the existing safe Windows launch resolver. It uses existing cursor_login authentication and no print/Copilot setup path. A startup abort has a captured-child owner; successful setup removes that acquisition-only listener. `config.ts` validates bounded select metadata, sets model first, reads refreshed options and applies only a supported explicit effort. An unsupported effort is an error, not an implicit no-op. In the observed Cursor build, Composer2.5 removes the effort selector; native configuration must leave effort unset for that model. + +`acquireCursorRuntime` extends the existing engine-partitioned pool. Keys include scope/canonical cwd/binary/model/effort/permission snapshot; reset generation and caller admission are checked before mutation and after creation. The total acquisition deadline aborts in-progress startup. Every borrow has a lease token: stale cancellation/retirement cannot interrupt the next borrower. Busy logical leases settle before normal replacement; release/reaper/replacement retain an entry-owned retirement fence until physical close, and a rejected close cannot admit a replacement prompt. Code/Pi policies are unchanged. + - boss/main 실행은 메시지당 spawn 대신 상주 런타임 풀을 탄다. 키 = 엔진별 독립 스토어 + `chat:${getActiveChatSession()}` + cwd + 모델/effort/(pi는 profile/endpoint/apiKind/profileFp). - employee는 풀링하지 않는다 (매 턴 timestamped cwd + cleanup과 모순 — per-turn spawn 유지). - 엔트리 상태기: `creating` → `ready`(busy/dead) + 대기자 큐. 조회/마킹은 동기 임계 구역, `drainWaiters`가 splice→clearTimeout→resolve/reject 순서를 보장. diff --git a/structure/server_api.md b/structure/server_api.md index 0dcc4c7e5..22001ccf6 100644 --- a/structure/server_api.md +++ b/structure/server_api.md @@ -6,10 +6,34 @@ aliases: [CLI-JAW Server API, server.ts reference, server_api] > 📚 [INDEX](INDEX.md) · [체크리스트 ↗](AGENTS.md) · [커맨드 ↗](commands.md) · **서버 API** -# server.ts — Glue + Route Registration (640L) +# server.ts — Glue + Route Registration (757L) + +Activity identity: `GET /api/orchestrate/snapshot?session=` adds +`activityIdentity: {sessionId,scope}` to the existing bare snapshot. Its runtime and +orchestrator data use the same captured scope. Optional selectors must be nonblank +scalar strings of at most240 characters; malformed400, enabled unknown-session404. +Disabled multi-session preserves the existing active-chat/default-scope policy. +Responses are no-store. `presentation.mode` is `activity` by default or explicit +`legacy`, independent of transport; PUT `/api/settings` validates and merges it. + +Activity discovery returns `{ok:true,data:{runs,pageSize:40}}` from +`GET /api/traces/activity-runs?session=&after=`. Each run is +`{id,messageId,status,startedAt}`; IDs are opaque ascending cursors, not time order. +Reconnect discovery starts again from the beginning. Replay is +`GET /api/traces/:runId/activity?session=&after=&through=&limit=40` +with `{ok:true,data:{runId,sessionId,scope,status,events,nextAfter,through,hasMore,incomplete,loss}}`. +The initial page chooses through; later pages hold it fixed. Limits are40 rows/256KiB; +corrupt rows advance the cursor and mark incomplete. Unknown query keys, non-scalar +selectors or invalid cursors return400; future cursors409; unavailable storage503. +Wrong/internal/deleted/fork ownership returns404. Discovery for an unknown chat is empty. + +All trace responses are no-store. Raw summary/list/detail also require `session` when +either owner column exists, including historical session-only backfills. Truly ownerless +legacy raw traces retain instance-level access. Clients capture the owner at drawer open +and reuse it for every request. Scope and copied message pointers do not grant access. > Express/SSE bootstrap + localhost/LAN opt-in 보안 가드 + `src/routes/*` registrar + mounted sub-router 등록. -> 현재 라이브 surface는 총 256개 route handler이며, 이 중 `/`를 제외한 API 엔드포인트는 255개다. +> Route-module inventory and the endpoint contracts below describe the surface; aggregate handler counts are not maintained by hand. > mutation route(`POST`/`PUT`/`DELETE`)는 모두 `requireAuth`를 거친다. 단, `requireAuth()`는 loopback 요청을 토큰 없이 통과시키고, `lanAllowed()`가 true일 때 private IP도 LAN bypass로 통과시킨다. > `GET /api/auth/token`은 Bearer bootstrap 전용이며 `Sec-Fetch-Site`가 `same-origin` 또는 `none`이 아닐 때 `403`을 반환한다. @@ -41,7 +65,8 @@ aliases: [CLI-JAW Server API, server.ts reference, server_api] | `src/routes/employees.ts` | 123L | 5 | employee CRUD + reset | | `src/routes/skills.ts` | 89L | 5 | skills list/read/enable/disable/reset | | `src/routes/avatar.ts` | 146L | 4 | avatar summary + agent/user image upload/delete/read | -| `src/routes/traces.ts` | 80L | 3 | public trace summary/event read routes | +| `src/routes/traces.ts` | 167L | 5 | owner-bound raw traces and bounded Activity discovery/replay | +| `src/routes/runtime-requests.ts` | 33L | 2 | exact-bound ephemeral native decisions; existing instance auth | | `src/routes/link-preview.ts` | 319L | 2 | Rich link preview metadata fetch + guarded image proxy | | `src/routes/heartbeat.ts` | 289L | 4 | heartbeat GET + validated PUT + mention-watch hold read/fresh-start | | `src/routes/jaw-ceo.ts` | 321L | 20 | Jaw CEO coordinator: state/message/query/docs-edit/settings/events/pending/watch/audit/voice/confirmations | @@ -68,13 +93,26 @@ static → employees → heartbeat → skills → jaw-memory → orchestrate → goal → task → events(SSE) → instance → chat-sessions → messages → system → agent-control → command → goal-run → memory → settings → messaging → avatar → traces → link-preview → jaw-ceo → runtime-context -→ security-audit → dashboard board/schedule → browser → i18n +→ security-audit → dashboard board/schedule → browser → code → runtime-requests → i18n ``` 라우트 모듈은 `server.ts:298-396` 부근에서 등록된다. --- +## Native Runtime Decisions + +| Method | Path | Contract | +| --- | --- | --- | +| `GET` | `/api/runtime/requests?sessionId=` | Explicit nonempty session ID, at most240 characters. Returns `{ok:true,data:{requests:[...]}}`; never substitutes the active session. | +| `POST` | `/api/runtime/requests/:id` | Body contains only `runId`, `sessionId`, `scope`, `turnId`, `response`. Exact stored binding and current ownership required. Returns `{ok:true,data:{accepted:true}}`. | + +Both routes use the same existing requireAuth and global Host/Origin guards as the worker. Loopback and configured LAN bypass remain unchanged; these routes do not introduce per-session user ACLs. Missing/malformed input or an invalid choice returns400; missing, expired, stale, already answered or mismatched requests return409 `request_not_current`. A bad choice does not consume a current request. No top-level requests alias exists. + +Each listed request has the four binding fields, `requestId`, `requestType`, `view`, and `expiresAt`. Views contain canonical sanitized labels and opaque jaw field/option handles. For ACP permissions, send `response:{optionId:}` or `{optionId:null}` to cancel, never a provider-native ID. Raw provider mappings and callbacks are private. Limits are128 pending requests globally,120-second expiry and32 per ACP connection; view admission must fit the32KiB event record. Accepted records a decision, not a provider tool result, and concurrent cancellation can still win before dispatch. + +Cursor/Grok activation and Activity controls are separate from this API foundation. Unsupported client-side filesystem, terminal and unproven question extensions are refused; existing JWC permissions and messaging final/ACK/queue paths are unchanged. + ## Base Route Surface (`server.ts`) | Method | Path | 설명 | @@ -144,14 +182,14 @@ static → employees → heartbeat → skills → jaw-memory → orchestrate | Messaging | `POST /api/upload` `POST /api/file/open` `POST /api/voice` `POST /api/telegram/send` `POST /api/channels/validate` `POST /api/channel/send` `POST /api/discord/send` `POST /api/slack/send` `GET /api/slack/history` `GET /api/slack/members` `GET /api/slack/users` | | Wiki | `GET /api/wiki/status` `GET /api/wiki/entities` `POST /api/wiki/enable` `POST /api/wiki/configure` | | Avatar | `GET /api/avatar` `POST /api/avatar/:target/upload` `DELETE /api/avatar/:target/image` `GET /api/avatar/:target/image` | -| Traces | `GET /api/traces/:runId` `GET /api/traces/:runId/events` `GET /api/traces/:runId/events/:seq` | +| Traces | `GET /api/traces/activity-runs?session=...&after=...` `GET /api/traces/:runId/activity?session=...&after=...&through=...&limit=40` `GET /api/traces/:runId` `GET /api/traces/:runId/events` `GET /api/traces/:runId/events/:seq` | | Debug | `GET /api/debug/mem` | | Link Preview | `GET /api/link-preview?url=` `GET /api/link-preview/image?url=` | | Dashboard Board | `GET /api/dashboard/board/tasks` `POST /api/dashboard/board/tasks` `PATCH /api/dashboard/board/tasks/:id` `DELETE /api/dashboard/board/tasks/:id` `POST /api/dashboard/board/tasks/from-message` | | Dashboard Schedule | `GET /api/dashboard/schedule/work` `POST /api/dashboard/schedule/work` `PATCH /api/dashboard/schedule/work/:id` `DELETE /api/dashboard/schedule/work/:id` `POST /api/dashboard/schedule/work/:id/dispatch` | | i18n | `GET /api/i18n/languages` `GET /api/i18n/:lang` | -> 실제 코드(`server.ts` + `src/routes/*.ts` + mounted runtime/security/Jaw CEO/dashboard sub-router)에서 추출한 총 256개 route handler 기준이다. 이 중 API 엔드포인트는 255개이고, 나머지 1개는 `/` 엔트리이다. Browser API 43개는 `src/routes/browser.ts`에서 등록된다. Jaw CEO 20개는 `src/routes/jaw-ceo.ts`에서 sub-router로 등록된다. +> 실제 코드(`server.ts` + `src/routes/*.ts` + mounted runtime/security/Jaw CEO/dashboard sub-router)에서 추출한 총 260개 route handler 기준이다. 이 중 API 엔드포인트는 259개이고, 나머지 1개는 `/` 엔트리이다. Browser API 43개는 `src/routes/browser.ts`에서 등록된다. Jaw CEO 20개는 `src/routes/jaw-ceo.ts`에서 sub-router로 등록된다. `PUT /api/heartbeat`의 job은 `mentionWatch: { channel: "slack", userId: "U...", channelIds: ["C..."], maxHits?, since? }`를 선택적으로 받는다. `channelIds`는 비어 있지 않아야 하고 저장 시 `slack.channelIds` allowlist의 부분집합이어야 하며, 실행 tick 직전 현재 allowlist와 다시 교집합한다. job id가 같은 기존 값에 대해 필드가 없으면 상속하고, `null`이면 삭제하며, 잘못된 값은 `400 invalid heartbeat mention watch`다. 파일 로드 정규화에서 잘못된 `mentionWatch`는 해당 job을 `enabled: false`로 내린다. 기본 운영값은 비활성이고, 설정된 watch는 별도 daemon이 아니라 기존 `runHeartbeatJob`에서 실행된다. diff --git a/structure/str_func.md b/structure/str_func.md index 7bc21cd43..04c79b1fc 100644 --- a/structure/str_func.md +++ b/structure/str_func.md @@ -19,7 +19,7 @@ aliases: [CLI-JAW Source Structure, str_func, source structure reference] ```text cli-jaw/ -├── server.ts ← Express 라우트 base + auth/CORS/rate-limit + WS bootstrap + `register*Routes()` glue + startup stale orc_state guard + graceful shutdown(closeDb) + employee migration + seed defaults + registerAvatarRoutes + async listen bootstrap (await initActiveMessagingRuntime) + orphaned jaw-emp-* cleanup + clearAllEmployeeSessions startup + no-store Vite index serving (755L) +├── server.ts ← Express 라우트 base + auth/CORS/rate-limit + WS bootstrap + `register*Routes()` glue + startup stale orc_state guard + graceful shutdown(closeDb) + employee migration + seed defaults + registerAvatarRoutes + async listen bootstrap (await initActiveMessagingRuntime) + orphaned jaw-emp-* cleanup + clearAllEmployeeSessions startup + no-store Vite index serving (757L) ├── lib/ ← 외부 통합/공용 헬퍼 (5 root files + mcp/ 8 files) │ ├── mcp-sync.ts ← MCP 통합 + 스킬 복사 + softResetSkills + runSkillReset + trusted repair gate + clone cooldown (83L) │ ├── mcp/ ← MCP 모듈 분리 (8 files) @@ -38,23 +38,23 @@ cli-jaw/ │ └── mime-detect.ts ← MIME 타입 감지 헬퍼 (67L) ├── src/ │ ├── core/ ← 의존 0 인프라 계층 (31 files, 3847L) -│ │ ├── config.ts ← JAW_HOME, settings, APP_VERSION + migrateSettings legacy Claude model normalization + avatar settings deep merge + default `settings.pi` + corrupt settings backup + CLI 탐지 re-export hub (1522L) +│ │ ├── config.ts ← JAW_HOME, settings, APP_VERSION + migrateSettings legacy Claude model normalization + avatar settings deep merge + default `settings.pi` + corrupt settings backup + CLI 탐지 re-export hub (1528L) │ │ ├── cli-detection.ts ← CLI 탐지 + `pi` npm-exec fallback + `kiro-code`(`kiro-cli` binary)/`claude-e`/`ai-e` helper `--idle-timeout-ms` compatibility probe + local package release/debug candidates (280L) │ │ ├── compact.ts ← compact 헬퍼 (COMPACT_MARKER_CONTENT, managed summary builder, cutoff logic, harvestGitGrep + harvestChatGrep 1KB/1KB budget split) (784L) │ │ ├── instance.ts ← 인스턴스 ID, node/jaw 경로, 유닛명 sanitize (61L) │ │ ├── session-generation.ts ← persistent chat_sessions.generation (not process-local spawn tokens) (94L) -│ │ ├── db.ts ← SQLite 스키마 + prepared statements + trace + tool_log + working_dir migration + closeDb() WAL checkpoint + checkOrphanedWal + busy_timeout + clearMessagesScoped + queued_messages table + model-aware clearEmployeeSession + getRecentMessagesLite + searchMessages(days+recent scope) + getMessageContext(±N range) (1156L) +│ │ ├── db.ts ← SQLite 스키마 + prepared statements + trace + tool_log + working_dir migration + closeDb() WAL checkpoint + checkOrphanedWal + busy_timeout + clearMessagesScoped + queued_messages table + model-aware clearEmployeeSession + getRecentMessagesLite + searchMessages(days+recent scope) + getMessageContext(±N range) (1171L) │ │ ├── db-maintenance.ts ← legacy tool_log 재살균 1회 마이그레이션(schema_migrations 마커) + page/freelist 통계 + checkpoint+VACUUM (`jaw db maintain`) (49L) -│ │ ├── chat-sessions.ts ← 채팅 세션 CRUD + 활성 세션 전환 (233L) +│ │ ├── chat-sessions.ts ← 채팅 세션 CRUD + 활성 세션 전환 (234L) │ │ ├── rate-limit.ts ← 클라이언트 클래스별(cli/manager/browser/lan/remote) 슬라이딩 윈도 리미터 + atomic peek/commit + Retry-After 미들웨어 팩토리 (217L) -│ │ ├── bus.ts ← public SSE publish + 내부 리스너 fan-out (70L) +│ │ ├── bus.ts ← public SSE publish + 내부 리스너 fan-out (76L) │ │ ├── logger.ts ← 로거 유틸 + structured log.event (100L) │ │ ├── i18n.ts ← 서버사이드 번역 (90L) │ │ ├── employees.ts ← Employee 시드/CRUD 공용 로직 + 정적 직원 등록(Control: codex `gpt-5.6-luna` + `codex-imagegen`) + virtual synthetic row/preset helpers + DEFAULT_EMPLOYEES (437L) │ │ ├── main-session.ts ← 메인 세션 authoritative CLI/clear-state helper + clearBossSessionOnly (232L) │ │ ├── message-summary.ts ← message preview/summary helper (55L) │ │ ├── path-expand.ts ← shell-style path expansion helper (12L) -│ │ ├── runtime-settings.ts ← settings side effects 통합 helper (468L) +│ │ ├── runtime-settings.ts ← settings side effects 통합 helper (469L) │ │ ├── runtime-settings-gate.ts ← settings mutation in-flight gate (41L) │ │ ├── codex-config.ts ← Codex config.toml context window sync (96L) │ │ ├── runtime-path.ts ← buildServicePath() PATH 보강 (nvm/fnm/homebrew/volta/asdf/cargo/bun/yarn/pnpm 14+ dirs) + win32 MSYS/Cygwin 항목 정규화 (230L) @@ -68,31 +68,50 @@ cli-jaw/ │ │ ├── launchd-cleanup.ts ← launchd stale plist / runtime cleanup (16L) │ │ ├── launchd-plist.ts ← launchd plist 생성 helper (89L) │ │ ├── tcc.ts ← macOS TCC / screen-recording 권한 점검 (55L) -│ │ ├── settings-merge.ts ← perCli/activeOverrides/pi deep merge (243L) +│ │ ├── settings-merge.ts ← perCli/activeOverrides/pi deep merge (258L) │ │ └── skill-cache.ts ← 활성 스킬 슬래시 커맨드 캐시 (registerSkillLoader, getSkillCommandsCache, invalidateSkillCommandsCache) (44L) │ ├── agent/ ← CLI 에이전트 런타임 (32 root files + events/ 12 files + spawn/ 3 files) +│ │ ├── merge-tool-log.ts ← run-bound latest tool state and omission marker merge (78L) │ │ ├── runtime/ ← shared native contract foundation (provider activation follows separately) +│ │ │ ├── acp/ ← shared v1 wire boundary and bounded native transport +│ │ │ │ ├── runtime-session.ts ← captured native turn, raw outcome and passive claim/finalize (221L) +│ │ │ │ ├── replacement.ts ← provider-neutral cancel/drain/dispatch controller (139L) +│ │ │ │ ├── replacement-turn.ts ← one logical result and synchronous input-commit barrier (161L) +│ │ │ │ ├── projection.ts ← structural message boundaries over shared projection (142L) +│ │ │ │ ├── content.ts ← bounded ACP content extraction without retaining binary payloads (56L) +│ │ │ │ ├── session.ts ← protocol session, prompt/cancel fences and drain (289L) +│ │ │ │ ├── cursor-session.ts ← existing-login factory and owned startup abort (80L) +│ │ │ │ ├── config.ts ← bounded negotiated model/effort selection (185L) +│ │ │ │ ├── notification-queue.ts ← per-turn serialized work with byte/count caps (87L) +│ │ │ │ ├── wire.ts ← strict single-envelope decoder shared with Copilot (43L) +│ │ │ │ ├── connection.ts ← bounded full-duplex framing, dispatch/result and close ownership (214L) +│ │ │ │ ├── permissions.ts ← native kind policy and opaque decision handles (137L) +│ │ │ │ └── callbacks.ts ← bounded permission wait, cancellation latches and private replies (184L) +│ │ │ ├── requests.ts ← ephemeral exact-bound decision registry and safe-view admission (158L) +│ │ │ ├── replace-turn.ts ← typed local-dispatch/no-start/fatal main receipt (43L) │ │ │ ├── pi-projection.ts ← Pi raw tool snapshots and accepted text/reasoning projection (97L) │ │ │ ├── pi-raw-trace.ts ← bounded delta-only raw retention with explicit control summaries (81L) -│ │ │ ├── projection.ts ← bounded redaction-before-clip snapshots and per-run failure latch (231L) +│ │ │ ├── projection.ts ← bounded redaction-before-clip snapshots and per-run failure latch (249L) +│ │ │ ├── print-projection.ts ← accepted print observation with scalar identities (47L) +│ │ │ ├── print-activity.ts ← canonical print factory and isolated bypass closure (33L) │ │ │ ├── codex-projection.ts ← owned Codex notification mapping (98L) │ │ │ ├── outcome.ts ← non-journal native result handoff and stop precedence (31L) -│ │ │ ├── session.ts ← native session/turn/control port (23L) -│ │ │ └── events.ts ← validated trace-first semantic emitter (39L) -│ │ ├── spawn.ts ← CLI spawn + ACP/Codex App/Pi RPC/AGY/Kiro plain text/log session capture/claude-e helper 분기 + v2 SQLite session resume + 큐 + 메모리 flush + 429 retry timer + isAgentBusy/isSteerInProgress + buildHistoryBlock compact cutoff + working_dir scoping + enqueue→processQueue race fix + QueueItem persistent DB queue + makeCleanEnv PATH augment (3593L) +│ │ │ ├── session.ts ← native session/turn/control port (27L) +│ │ │ └── events.ts ← validated trace-first semantic emitter (43L) +│ │ ├── spawn.ts ← CLI spawn + ACP/Codex App/Pi RPC/AGY/Kiro plain text/log session capture/claude-e helper 분기 + v2 SQLite session resume + 큐 + 메모리 flush + 429 retry timer + isAgentBusy/isSteerInProgress + buildHistoryBlock compact cutoff + working_dir scoping + enqueue→processQueue race fix + QueueItem persistent DB queue + makeCleanEnv PATH augment (3914L) │ │ ├── spawn/ ← spawn 서브모듈 (3 files) │ │ │ ├── queue.ts ← QueueItem persistent DB queue + processQueue race fix + enqueue/dequeue + drainRecoveredQueue (부팅 시 복구 큐 기동, server.ts가 transport 준비 후 호출) + `_fromQueue` 표식 (대기자 없는 턴을 채널이 답할 수 있게) (689L) │ │ │ ├── resume.ts ← session resume logic + stale resume detection (117L) │ │ │ └── process-kill.ts ← child process kill helper (195L) │ │ ├── events/ ← NDJSON 이벤트 파서 모듈 분리 (12 files) -│ │ │ ├── index.ts ← 이벤트 라우터 + logEventSummary + stepRef correlation + compact event parsing + duplicate suppression + claude message_start 경계 (393L) -│ │ │ ├── helpers.ts ← summarizeToolInput(type-safe) + toolType/detail 필드 + flushClaudeBuffers (403L) -│ │ │ ├── claude.ts ← Claude thinking_delta/input_json_delta 버퍼 + content_block_stop flush + 메시지 경계 LAST-WINS (368L) -│ │ │ ├── opencode.ts ← OpenCode event adapter + step 경계 last-step-wins (214L) -│ │ │ ├── grok.ts ← Grok throttled visible thinking + event adapter (369L) -│ │ │ ├── codex.ts ← Codex item.started/completed + toolLog running→done dedup (130L) +│ │ │ ├── index.ts ← 이벤트 라우터 + logEventSummary + stepRef correlation + compact event parsing + duplicate suppression + claude message_start 경계 (403L) +│ │ │ ├── helpers.ts ← summarizeToolInput(type-safe) + toolType/detail 필드 + flushClaudeBuffers (412L) +│ │ │ ├── claude.ts ← Claude thinking_delta/input_json_delta 버퍼 + content_block_stop flush + 메시지 경계 LAST-WINS (371L) +│ │ │ ├── opencode.ts ← OpenCode event adapter + step 경계 last-step-wins (276L) +│ │ │ ├── grok.ts ← Grok throttled visible thinking + event adapter (391L) +│ │ │ ├── codex.ts ← Codex item.started/completed + toolLog running→done dedup (133L) │ │ │ ├── acp.ts ← ACP session/update 이벤트 + agent_message_chunk messageId 보존 (225L) -│ │ │ ├── cursor.ts ← Cursor event adapter + tool/message-boundary LAST-WINS (265L) +│ │ │ ├── cursor.ts ← Cursor event adapter + tool/message-boundary LAST-WINS (290L) │ │ │ ├── gemini.ts ← Gemini event adapter (117L) │ │ │ ├── summary.ts ← event summary formatters (118L) │ │ │ ├── tool-labels.ts ← tool name→label mapping (315L) @@ -103,7 +122,7 @@ cli-jaw/ │ │ ├── agy-capabilities.ts ← AGY `--help`/`--version` capability probe + cached optional flag support map + legacy emit-all fallback marker (124L) │ │ ├── agy-transcript-watcher.ts ← AGY transcript/log watcher and session-id extraction support (291L) │ │ ├── pi-runtime.ts ← Pi profile 정규화 + isolated `PI_CODING_AGENT_DIR` models/settings 생성 + `pi --offline --list-models` discovery + `pi --mode rpc` JSONL parser/spawner (820L) ✨ -│ │ ├── lifecycle-handler.ts ← child lifecycle + fallback/retry + queue resume orchestration + clearEmployeeSession on resume failure + stale resume fresh retry + kickGoalContinuation export + clearGoalTimers + goal continuation boundary row (1398L) +│ │ ├── lifecycle-handler.ts ← child lifecycle + fallback/retry + queue resume orchestration + clearEmployeeSession on resume failure + stale resume fresh retry + kickGoalContinuation export + clearGoalTimers + goal continuation boundary row (1393L) │ │ ├── jwc-runtime.ts ← resident/in-process JWC runtime bridge and event handling (222L) │ │ ├── kiro-auth.ts ← Kiro CLI auth store reader (resolveKiroDataPath, readKiroAuthFromStore, resolveKiroProfileArn, regionFromProfileArn, listKiroConversationIdsForCwd, resolveKiroSessionIdAfterSpawn, extractKiroSessionIdFromV2Store) (253L) │ │ ├── kiro-models.ts ← Kiro live model inventory (KiroModelEntry, KiroModelInventory, parseKiroModelListJson, fetchKiroModelInventory) (98L) @@ -167,13 +186,13 @@ cli-jaw/ │ │ └── slack-target.ts ← Slack target helper (74L) │ ├── orchestrator/ ← 직원 오케스트레이션 + 인터페이스 통합 (19 files) │ │ ├── state-machine.ts ← IPABCD 상태 머신 (I=Interview pre-plan) + broadcast(state,title) + worklog 타이틀 파싱 + employee terminology + OrcContext.workingDir + OrcContext.interview + Project root dispatch contract + Phase60 actor-aware canTransition(GateInput) form-only evidence gate + STATE_PROMPTS --attest instructions (806L) -│ │ ├── pipeline.ts ← IPABCD orchestration (explicit entry only) + interview first-turn detection + plan context persistence + memorySnapshot injection + reset clears boss session + OrcContext workingDir init + Approved Plan Project root guard + remote-channel elicitation guard + bounded delayed worker replay notice + Phase60 phase_attestation strip/fallback + no-state narration warn (794L) +│ │ ├── pipeline.ts ← IPABCD orchestration (explicit entry only) + interview first-turn detection + plan context persistence + memorySnapshot injection + reset clears boss session + OrcContext workingDir init + Approved Plan Project root guard + remote-channel elicitation guard + bounded delayed worker replay notice + Phase60 phase_attestation strip/fallback + no-state narration warn (808L) │ │ ├── distribute.ts ← runSingleAgent + buildPlanPrompt + parallel helpers + tiered findEmployee + employee resume diagnostics + virtual employee session-skip (475L) │ │ ├── parser.ts ← triage + subtask JSON + verdict 파싱 + isResetIntent (176L) -│ │ ├── gateway.ts ← submitMessage 통합 진입점 (WebUI+CLI+TG+Discord 공통) + working_dir scoped insertMessage (329L) -│ │ ├── collect.ts ← orchestrateAndCollect + orchestrateAndCollectData (116L) +│ │ ├── gateway.ts ← submitMessage 통합 진입점 (WebUI+CLI+TG+Discord 공통) + working_dir scoped insertMessage (330L) +│ │ ├── collect.ts ← orchestrateAndCollect + orchestrateAndCollectData (155L) │ │ ├── session-work.ts ← hasChatSessionWork — 세션 삭제 전 진행중 작업 관측 (활성 run·큐·replay는 정확 매칭, drain/retry/hold/worker/lane은 scope 단위 보수적 판정) (42L) ✨ -│ │ ├── scope.ts ← 현재 단일 'default' scope를 반환하는 stub (65L) +│ │ ├── scope.ts ← 현재 단일 'default' scope를 반환하는 stub (86L) │ │ ├── worker-monitor.ts ← Worker stall detection — activity timestamps + stall/disconnect/timeout callbacks (58L) │ │ ├── worker-progress.ts ← 직원 progress safe-summary sanitizer + runId-aware current/previous snapshot types │ │ ├── worker-registry.ts ← Worker 프로세스 레지스트리 + runId progress current/previous memory retention + pending replay metadata + durable worker-run hook (431L) @@ -195,7 +214,7 @@ cli-jaw/ │ ├── cli/ ← 커맨드 시스템 (18 root files + tui/ 19 files) │ │ ├── commands.ts ← 슬래시 커맨드 레지스트리 + workflow metadata + 디스패처 + 파일경로 필터 + /commands alias /cmd + /settings fullscreen transition + /orchestrate alias /pabcd + /compact + /plan + /search + /gd force-done alias + artifact persistence (688L) │ │ ├── handlers.ts ← core command handlers + runtime/completion re-export hub + compact re-export + unknown command recovery payload (480L) -│ │ ├── handlers-runtime.ts ← memory/browser/prompt/quit/file/steer/forward/fallback/flush/ide/orchestrate 핸들러 + `LEGACY_MODEL_CLI_HINTS` (561L) +│ │ ├── handlers-runtime.ts ← memory/browser/prompt/quit/file/steer/forward/fallback/flush/ide/orchestrate 핸들러 + `LEGACY_MODEL_CLI_HINTS` (565L) │ │ ├── handlers-completions.ts ← `/model` `/cli` `/skill` `/employee` `/browser` `/fallback` `/flush` 인자 자동완성 헬퍼 (121L) │ │ ├── handlers-workflows.ts ← `/plan` PABCD P 안내 + `/interview` `/deliberate` `/planaudit` prompt handlers + `/review` project-dir workflow + `/goal` gated stub + `/goal run` preflight gate + `/gd` force-done alias (510L) │ │ ├── handlers-search.ts ← `/search` search-skill routing handler + steer prompt submit/remote-safe result split (39L) @@ -207,7 +226,7 @@ cli-jaw/ │ │ ├── registry.ts ← 13개 CLI/모델 단일 소스 + canonical defaults + top-level `pi`/`agy`/`cursor`/`ai-e`/`claude-e`/`kiro-code` (334L) │ │ ├── registry-live.ts ← buildLiveCliRegistry — Kiro inventory + ocx 모델/모델별 effort 동적 병합 (effortsByModel/defaultEffortByModel) (136L) │ │ ├── readiness.ts ← CLI별 인증/설치 상태 점검 + Pi npm-exec readiness + AGY runtime auth hint + `claude-e` underlying Claude auth/readiness bridge (CliReadiness[]) (174L) -│ │ ├── acp-client.ts ← Copilot ACP JSON-RPC 클라이언트 (394L) +│ │ ├── acp-client.ts ← Copilot ACP JSON-RPC 클라이언트 (391L) │ │ ├── command-context.ts ← 공유 커맨드 컨텍스트 팩토리 + runSkillReset 위임 + regenerateB 유지 (160L) │ │ ├── connector.ts ← dashboard connector CLI API bridge (board/notes/reminders/audit) (73L) │ │ ├── reminders.ts ← local reminders CLI action helpers (35L) @@ -336,7 +355,7 @@ cli-jaw/ │ │ ├── jaw-memory.ts ← jaw memory search/read/list/save/init/reflect/flush/soul/soul-activate/bootstrap 라우트 (362L) │ │ ├── jaw-ceo.ts ← Jaw CEO channel/session support routes (321L) ✨ │ │ ├── i18n.ts ← locale bundle 라우트 (35L) -│ │ ├── orchestrate.ts ← IPABCD reset/state/workers/worker-runs/snapshot/queue cancel/queue steer async accept/dispatch/virtual dispatch/batch safe summary/worker result/state PUT 라우트 + Phase60 boss-token actor distinction + --attest body gate + single-use pendingAttestation null-clear (1213L) +│ │ ├── orchestrate.ts ← IPABCD reset/state/workers/worker-runs/snapshot/queue cancel/queue steer async accept/dispatch/virtual dispatch/batch safe summary/worker result/state PUT 라우트 + Phase60 boss-token actor distinction + --attest body gate + single-use pendingAttestation null-clear (1223L) │ │ ├── memory.ts ← memory status/KV/files/settings 라우트 (191L) │ │ ├── settings.ts ← settings/prompt/project pick/git summary/heartbeat-md/MCP/registry/status/quota/copilot + Pi profile register/model discovery 라우트 + CLI_KEYS 기반 quota parity/status-only metadata (754L) │ │ ├── messaging.ts ← upload/file-open/voice/telegram/channel/discord send 라우트 (513L) @@ -349,7 +368,8 @@ cli-jaw/ │ │ ├── goal-run.ts ← goal-run execution routes (83L) │ │ ├── runtime-context.ts ← runtime context route helpers (46L) │ │ ├── security-audit.ts ← security audit route registrar (18L) -│ │ ├── traces.ts ← public trace summary/events read routes (80L) +│ │ ├── traces.ts ← public trace summary/events read routes (167L) +│ │ ├── runtime-requests.ts ← exact-bound ephemeral native decision GET/POST (33L) │ │ └── browser.ts ← 브라우저 API 라우트 + `cdpPort(req)` 포트 우선순위 + primitive/tab/debug/doctor/cleanup/web-ai routes (489L) │ ├── security/ ← 보안 입력 검증 (4 files) │ │ ├── path-guards.ts ← assertSkillId, assertFilename, assertMemoryRelPath, assertSendFilePath, safeResolveUnder + sendFileAllowedRoots (가드와 진단이 공유하는 허용 루트 단일 소스, 거절 시 detail.allowedRoots로 반환) (330L) @@ -361,7 +381,7 @@ cli-jaw/ │ │ ├── async-handler.ts ← asyncHandler 래퍼 (14L) │ │ └── error-middleware.ts ← notFoundHandler, errorHandler (26L) │ ├── types/ ← 공유 타입 정의 (3 files, 329L) -│ │ ├── agent.ts ← ToolEntry, SpawnContext, SpawnResult 인터페이스 (206L) +│ │ ├── agent.ts ← ToolEntry, SpawnContext, SpawnResult 인터페이스 (213L) │ │ ├── cli-engine.ts ← CliEngine union + registry key tuple + `agy`/`ai-e`/`claude-e`/`kiro-code` discriminators (58L) │ │ └── cli-events.ts ← CLI event record/discriminator helpers (154L) │ ├── command-contract/ ← 커맨드 인터페이스 통합 (3 files) @@ -380,17 +400,21 @@ cli-jaw/ │ │ ├── failure-matrix.ts ← goal-run failure classification (37L) │ │ ├── policy.ts ← goal-run preflight gates + budget check (56L) │ │ └── types.ts ← GoalRunMode, GoalRunBudget, GoalRunSafetyGate, GoalRunState 타입 (36L) -│ ├── trace/ ← Trace 이벤트 영속화 (5 files) +│ ├── trace/ ← Trace 이벤트 영속화 (8 files) +│ │ ├── activity-journal.ts ← bounded Activity append, owner gate and fixed-through replay (153L) +│ │ ├── activity-control.ts ← bounded control metadata and best-effort closure (75L) +│ │ ├── activity-retention.ts ← whole-prefix expiry and protected active owners (54L) │ │ ├── runtime-body-codec.ts ← canonical runtime body tuples + contextual redaction (144L) -│ │ ├── store.ts ← startTraceRun + appendTraceEvent + stampTraceTool + finalizeTraceRun + pruneTraceEvents (336L) +│ │ ├── store.ts ← startTraceRun + appendTraceEvent + stampTraceTool + finalizeTraceRun + pruneTraceEvents (370L) │ │ ├── retention.ts ← startTraceRetention: boot prune + 6h sweep, {stop(), stopped} 핸들 (server.ts shutdown 이 소유) (22L) -│ │ ├── types.ts ← TraceRunInput, TraceEventInput, TracePointer, TraceRunRow 타입 (36L) +│ │ ├── types.ts ← TraceRunInput, TraceEventInput, TracePointer, TraceRunRow 타입 (38L) │ │ └── redact.ts ← trace event redaction helpers (48L) │ ├── shared/ ← 공유 유틸리티 (6 files + reminders helper) ✨ │ │ ├── elicitation-spec.ts ← structured elicitation schema + validation helper (167L) │ │ ├── runtime-observability.ts ← worker-run/background-task shared runtime status category vocabulary (40L) -│ │ ├── runtime-contract.ts ← native session capabilities, turn outcome and presentation event types (50L) -│ │ ├── runtime-event-parse.ts ← versioned presentation boundary decoder (90L) +│ │ ├── runtime-contract.ts ← native session capabilities, turn outcome and presentation event types (58L) +│ │ ├── runtime-event-parse.ts ← versioned presentation boundary decoder (92L) +│ │ ├── presentation.ts ← display mode accessor and server identity decoder (24L) │ │ ├── shell-command-display.ts ← shell command display sanitization helper (48L) │ │ ├── structured-fence.ts ← structured renderer fence scanner/parser helper (80L) │ │ ├── tool-log-sanitize.ts ← tool log sanitization helpers (247L) diff --git a/structure/stream-events.md b/structure/stream-events.md index e2de65151..3fbdd8be4 100644 --- a/structure/stream-events.md +++ b/structure/stream-events.md @@ -6,6 +6,20 @@ aliases: [CLI Stream Event Reference, stream events, SSE event channel, NDJSON p # CLI Stream Event Reference (SSE + Legacy WS + Provider Streams) +Activity events are committed to the bounded trace journal before direct SSE publication. +`agent_runtime` and `agent_runtime_gap` bypass messaging/collect listeners, including the +defense in `broadcast`. Runtime seq is monotonic but noncontiguous because raw/tool/control +rows share the allocator. Reconnect clients refresh server identity and durable replay +even if the in-memory SSE ring still accepts a cursor. Loss never substitutes a final or +triggers another send; replay requests are historical. Contracts and limits are in +`runtime-integration.md` and `server_api.md`. + +Print events share that journal through the existing RuntimeProjection sink. Accepted +provider text precedes legacy resets; lifecycle-selected final remains authoritative. +Legacy `agent_output`/`agent_tool` explicitly stamp captured chat/scope/run identity. +Semantic events never become collect/forwarder/ACK/queue input. On projection failure, +one gap signals a degraded projection and the existing compatibility final remains valid. + > 각 CLI의 NDJSON/ACP/stream-json 이벤트를 `src/agent/events/`가 파싱하고, AGY plain-text output은 `spawn.ts`가 직접 처리한다. X-01 이후 current server의 public Web delivery는 `src/core/event-bus.ts` + `GET /api/events` SSE channel이 담당한다. WebSocket은 current server broadcast path가 아니라 `/api/events`가 한 번도 열리지 않는 pre-X-01 server용 client/TUI fallback이다. > 마지막 코드 대조: 2026-06-27 (`src/core/event-bus.ts`, `src/agent/lifecycle-handler.ts`, `src/goal/heartbeat.ts`, `src/agent/events/claude.ts`, `public/js/features/process-block.ts`, `public/js/ws.ts`) @@ -77,6 +91,12 @@ SSE behavior: ### Canonical runtime side channel +Cursor native main sends tool/message/reasoning previews through the same canonical emitter, never provisional `agent_output`. Its raw authoritative result is claimed before application persistence; `onRuntimeEnd` finalizes the captured turn after output policy/stop precedence. A private `SpawnLifecycle.onActivity` identity callback connects owned native I/O to its collector independently of journal success. It carries no text, is not an SSE/messaging event, cannot refresh another request or a completed collector, and retains explicit mention-watch scope/chat ownership with multi-session off. + +Cursor cancel-reprompt keeps that same logical run/turn identity while native prompt attempts change. Intermediate cancellation drains old updates and callbacks without emitting another logical `turn-end` or assistant MESSAGE. The existing `steer_started` receipt reports `mode: cancel-reprompt` and `localDispatch: true`; it is a local-write fact, not a model ACK. User input is committed once before the final lifecycle, with current main/canonical ownership rechecked. No-start may queue, fatal failure does not. Input context is application-reinjected and bounded; anonymous frames after the next attempt starts still rely on ACP v1 ordering. + +An undispatched redirect invalidated by Stop is not a queueable no-start. It settles the existing `request_settled` outcome as `cancelled`, without a B user row or another prompt; slash and gateway consumers must not recreate it after Stop's queue purge. Already-dispatched input and its one commit remain distinct from this cancellation case. + Pi RPC also feeds this channel: prompt-owned raw callbacks provide tool start/update/end snapshots, while accepted legacy text/reasoning callbacks preserve echo suppression. Its raw trace retention is delta-only for repeated message snapshots and bounded separately from canonical delivery. Budget omission is explicitly recorded in a small trace control row; it does not send a canonical gap or interfere with the existing final/abort lifecycle. Native steer behavior is not added by this observer. Codex app-server's accepted owner-scoped notifications also feed `src/agent/runtime/codex-projection.ts`. Bounded/redacted snapshots are stored as immutable `source=runtime` trace rows before `agent_runtime` is published directly on the SSE agent topic. This side channel deliberately bypasses `addBroadcastListener`, so it does not send Slack/Telegram/Discord progress messages. `agent_runtime_gap` marks the first projection failure and stops further canonical writes in that run; legacy lifecycle delivery remains independent. TUI already uses SSE through its channel adapter; no new WebSocket server path is required. diff --git a/tests/acp-client.test.ts b/tests/acp-client.test.ts index 86edd8f19..5b2f68e6e 100644 --- a/tests/acp-client.test.ts +++ b/tests/acp-client.test.ts @@ -96,6 +96,64 @@ test('AcpClient request resolves from matching response id', async () => { assert.deepEqual(out, { ok: true }); }); +test('AcpClient replies to colliding permission request without settling pending client request', async () => { + const acp = new AcpClient(); + const writes: string[] = []; + // Only stdin is consumed here; keep the real request, dispatcher and permission handler. + acp.proc = { + stdin: { + writable: true, + write: (line: string) => { writes.push(line); return true; }, + }, + } as unknown as NonNullable; + + const promise = acp.request('session/prompt', { sessionId: 'collision-fixture' }, 1000); + const sent = JSON.parse(writes[0]!); + const originalPending = acp._pending.get(sent.id); + let settled = false; + const observed = promise.then( + () => { settled = true; }, + () => { settled = true; }, + ); + const permissionParams = { options: [ + { id: 'deny_this', name: 'Deny' }, + { id: 'approve_this', name: 'Approve' }, + ] }; + const permissionEvents: unknown[] = []; + acp.on('session/request_permission', params => permissionEvents.push(params)); + + try { + assert.ok(originalPending); + acp._handleLine(JSON.stringify({ + jsonrpc: '2.0', id: sent.id, + method: 'session/request_permission', params: permissionParams, + })); + await Promise.resolve(); + + assert.equal(writes.length, 2, 'peer permission reply must precede the prompt response'); + assert.deepEqual(JSON.parse(writes[1]!), { + jsonrpc: '2.0', id: sent.id, + result: { outcome: { outcome: 'selected', optionId: 'approve_this' } }, + }); + assert.deepEqual(permissionEvents, [permissionParams]); + assert.equal(settled, false, 'peer request must not settle the client promise'); + assert.equal(acp._pending.size, 1); + assert.equal(acp._pending.get(sent.id), originalPending); + + acp._handleLine(JSON.stringify({ + jsonrpc: '2.0', id: sent.id, result: { stopReason: 'end_turn' }, + })); + assert.deepEqual(await promise, { stopReason: 'end_turn' }); + assert.equal(acp._pending.size, 0); + assert.equal(writes.length, 2); + } finally { + if (originalPending?.timer) clearTimeout(originalPending.timer); + acp._pending.delete(sent.id); + originalPending?.resolve(undefined); + await observed; + } +}); + test('AcpClient request rejects immediately when stdin is not writable', async () => { const acp = new AcpClient(); await assert.rejects( @@ -184,3 +242,125 @@ test('_handleLine resets idle timer via _activityPing on valid JSON', async () = acp._handleLine('not json at all'); assert.equal(pingCount, 2); }); + +function createCapturedClient() { + const acp = new AcpClient(); + const writes: string[] = []; + // Fake only the child stdin boundary; exercise the real serializer and handlers. + acp.proc = { + stdin: { + writable: true, + write: (line: string) => { writes.push(line); return true; }, + }, + } as unknown as NonNullable; + return { acp, writes }; +} + +test('AcpClient ignores malformed, invalid-version and ambiguous envelopes without pending or activity effects', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { acp, writes } = createCapturedClient(); + const promise = acp.request('session/prompt', {}, 1000); + const id = JSON.parse(writes[0]!).id; + const pending = acp._pending.get(id); + let settled = false; + void promise.then(() => { settled = true; }, () => { settled = true; }); + let pings = 0; + acp._activityPing = () => { pings++; }; + const emitted = t.mock.method(acp, 'emit'); + const logged = t.mock.method(console, 'log', () => {}); + const previousDebug = process.env['DEBUG']; + process.env['DEBUG'] = '1'; + const response = { jsonrpc: '2.0', id, result: 'must-not-resolve' }; + const callback = { jsonrpc: '2.0', id, method: 'session/request_permission' }; + const invalidLines = [ + '', ' ', 'not-json secret-payload', '{', 'null', 'true', '42', '[]', + JSON.stringify([response]), + JSON.stringify({ id, result: 'missing-version' }), + JSON.stringify({ ...response, jsonrpc: '1.0' }), + JSON.stringify({ ...response, id: null }), + JSON.stringify({ ...response, id: Number.MAX_SAFE_INTEGER + 1 }), + JSON.stringify({ ...callback, method: '' }), + JSON.stringify({ ...callback, method: 7 }), + JSON.stringify({ ...callback, result: null }), + JSON.stringify({ ...callback, error: { code: -1, message: 'ambiguous' } }), + JSON.stringify({ ...response, error: { code: -1, message: 'ambiguous' } }), + JSON.stringify({ jsonrpc: '2.0', id }), + JSON.stringify({ jsonrpc: '2.0', id, error: { code: 'bad', message: 'bad' } }), + JSON.stringify({ jsonrpc: '2.0', id, error: { code: -1, message: 7 } }), + ]; + try { + assert.ok(pending); + for (const line of invalidLines) { + assert.doesNotThrow(() => acp._handleLine(line)); + await Promise.resolve(); + assert.equal(settled, false); + assert.equal(acp._pending.get(id), pending); + assert.equal(acp._pending.size, 1); + assert.equal(writes.length, 1); + assert.equal(pings, 0); + assert.equal(emitted.mock.callCount(), 0); + assert.equal(logged.mock.callCount(), 0); + } + acp._handleLine(JSON.stringify({ jsonrpc: '2.0', id, result: null })); + assert.equal(await promise, null); + assert.equal(acp._pending.size, 0); + assert.equal(pings, 1); + } finally { + if (previousDebug === undefined) delete process.env['DEBUG']; + else process.env['DEBUG'] = previousDebug; + if (pending?.timer) clearTimeout(pending.timer); + acp._pending.delete(id); + pending?.resolve(undefined); + } +}); + +test('AcpClient ignores late, duplicate and unmatched responses while a new request remains pending', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { acp, writes } = createCapturedClient(); + const expired = acp.request('session/prompt', {}, 1000); + const expiredId = JSON.parse(writes[0]!).id; + const rejected = assert.rejects(expired, /ACP request timeout: session\/prompt/); + t.mock.timers.tick(1000); + await rejected; + assert.equal(acp._pending.size, 0); + + const promise = acp.request('session/prompt', {}, 1000); + const id = JSON.parse(writes[1]!).id; + const pending = acp._pending.get(id); + let settled = false; + void promise.then(() => { settled = true; }, () => { settled = true; }); + const emitted = t.mock.method(acp, 'emit'); + try { + assert.ok(pending); + for (const lateId of [expiredId, expiredId, 'unmatched', String(id)]) { + acp._handleLine(JSON.stringify({ jsonrpc: '2.0', id: lateId, result: 'late' })); + acp._handleLine(JSON.stringify({ + jsonrpc: '2.0', id: lateId, error: { code: -1, message: 'late' }, + })); + } + await Promise.resolve(); + assert.equal(settled, false); + assert.equal(acp._pending.get(id), pending); + assert.equal(acp._pending.size, 1); + assert.equal(emitted.mock.callCount(), 0); + assert.equal(writes.length, 2); + + const update = { update: { sessionUpdate: 'plan' } }; + acp._handleLine(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: update })); + assert.deepEqual(emitted.mock.calls[0]?.arguments, ['session/update', update]); + assert.equal(settled, false); + assert.equal(acp._pending.get(id), pending); + + acp._handleLine(JSON.stringify({ jsonrpc: '2.0', id, result: null })); + assert.equal(await promise, null); + acp._handleLine(JSON.stringify({ jsonrpc: '2.0', id, result: 'duplicate' })); + assert.equal(await promise, null); + assert.equal(acp._pending.size, 0); + assert.equal(emitted.mock.callCount(), 1); + assert.equal(writes.length, 2); + } finally { + if (pending?.timer) clearTimeout(pending.timer); + acp._pending.delete(id); + pending?.resolve(undefined); + } +}); diff --git a/tests/fixtures/grok-acp-read-file.json b/tests/fixtures/grok-acp-read-file.json new file mode 100644 index 000000000..249a11ec9 --- /dev/null +++ b/tests/fixtures/grok-acp-read-file.json @@ -0,0 +1,45 @@ +{ + "provenance": {"provider":"grok", "version":"1.0.13 (5e9a58528b76)", "capturedAt":"2026-09-05T17:39:22.629Z", "scenario":"dedicated safe ACP reads generated scratch file", "normalization":"native session/tool IDs and absolute scratch path replaced; thought/user chunks omitted; message chunks unchanged"}, + "expectedFinal": "GROK_TOOL_7689_CEDAR\n[ -f file ]", + "frames": [ + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "I'll"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-25", "agentTimestampMs": 1788629966908, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 23}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " read"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-26", "agentTimestampMs": 1788629966908, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 24}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " `"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-27", "agentTimestampMs": 1788629966954, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 25}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "fixture"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-28", "agentTimestampMs": 1788629966954, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 26}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": ".txt"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-29", "agentTimestampMs": 1788629966954, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 27}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "`"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-30", "agentTimestampMs": 1788629966954, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 28}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " with"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-31", "agentTimestampMs": 1788629967005, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 29}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " the"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-32", "agentTimestampMs": 1788629967005, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 30}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " file"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-33", "agentTimestampMs": 1788629967005, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 31}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "-"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-34", "agentTimestampMs": 1788629967005, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 32}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "reading"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-35", "agentTimestampMs": 1788629967054, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 33}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " tool"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-36", "agentTimestampMs": 1788629967054, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 34}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " and"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-37", "agentTimestampMs": 1788629967054, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 35}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " reply"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-38", "agentTimestampMs": 1788629967054, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 36}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " with"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-39", "agentTimestampMs": 1788629967102, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 37}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " its"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-40", "agentTimestampMs": 1788629967103, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 38}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " exact"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-41", "agentTimestampMs": 1788629967151, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 39}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " contents"}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-42", "agentTimestampMs": 1788629967151, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 40}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "."}}, "_meta": {"totalTokens": 1801, "eventId": "fixture-native-43", "agentTimestampMs": 1788629967151, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 41}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "tool_call", "toolCallId": "fixture-read", "title": "read_file", "rawInput": {"target_file": "fixture.txt"}, "_meta": {"x.ai/tool": {"version": 1, "name": "read_file", "kind": "read", "namespace": "grok_build", "label": "Read", "read_only": true}}}, "_meta": {"totalTokens": 17587, "eventId": "fixture-native-45", "agentTimestampMs": 1788629967398, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "ToolCall", "updateParams": {"toolCallId": "fixture-read", "title": "read_file", "kind": "Other", "status": "Pending"}}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "fixture-read", "kind": "read", "title": "Read `fixture.txt`", "locations": [{"path": "fixture.txt"}], "rawInput": {"variant": "ReadFile", "target_file": "fixture.txt"}, "_meta": {"x.ai/tool": {"version": 1, "name": "read_file", "kind": "read", "namespace": "grok_build", "label": "Read", "read_only": true, "input": {"path": "fixture.txt"}}}}, "_meta": {"totalTokens": 17587, "eventId": "fixture-native-46", "agentTimestampMs": 1788629967399, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "ToolCallUpdate", "updateParams": {"toolCallId": "fixture-read", "status": null}}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "fixture-read", "status": "completed", "content": [{"type": "content", "content": {"type": "text", "text": "1→GROK_TOOL_7689_CEDAR\n[ -f file ]\n"}}], "rawOutput": {"type": "ReadFile", "FileContent": {"content": "1→GROK_TOOL_7689_CEDAR\n[ -f file ]\n", "content_concise": "1→GROK_TOOL_7689_CEDAR\n[ -f file ]\n", "absolute_path": "/fixture/fixture.txt", "offset": null, "raw_output": "GROK_TOOL_7689_CEDAR\n[ -f file ]\n", "total_lines": 3}}}, "_meta": {"totalTokens": 17587, "eventId": "fixture-native-47", "agentTimestampMs": 1788629967404, "promptId": "fixture-prompt", "streamStartMs": 1788629965438, "turnStartMs": 1788629964781, "updateType": "ToolCallUpdate", "updateParams": {"toolCallId": "fixture-read", "status": "Completed"}}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "GRO"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-65", "agentTimestampMs": 1788629968965, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 18}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "K"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-66", "agentTimestampMs": 1788629969012, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 19}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "_"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-67", "agentTimestampMs": 1788629969012, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 20}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "TOOL"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-68", "agentTimestampMs": 1788629969012, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 21}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "_"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-69", "agentTimestampMs": 1788629969013, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 22}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "768"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-70", "agentTimestampMs": 1788629969066, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 23}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "9"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-71", "agentTimestampMs": 1788629969066, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 24}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "_"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-72", "agentTimestampMs": 1788629969066, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 25}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "CED"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-73", "agentTimestampMs": 1788629969066, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 26}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "AR"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-74", "agentTimestampMs": 1788629969110, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 27}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "\n"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-75", "agentTimestampMs": 1788629969110, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 28}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "["}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-76", "agentTimestampMs": 1788629969110, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 29}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " -"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-77", "agentTimestampMs": 1788629969110, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 30}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "f"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-78", "agentTimestampMs": 1788629969168, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 31}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " file"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-79", "agentTimestampMs": 1788629969168, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 32}}}, + {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-native", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " ]"}}, "_meta": {"totalTokens": 17596, "eventId": "fixture-native-80", "agentTimestampMs": 1788629969168, "promptId": "fixture-prompt", "streamStartMs": 1788629968003, "turnStartMs": 1788629964781, "updateType": "AgentMessageChunk", "chunkId": 33}}} + ], + "result": {"stopReason": "end_turn", "_meta": {"sessionId": "fixture-native", "requestId": "fixture-prompt", "promptId": "fixture-prompt", "totalTokens": 17672, "modelId": "grok-4.6", "inputTokens": 17610, "outputTokens": 62, "cachedReadTokens": 17408, "reasoningTokens": 42, "usage": {"inputTokens": 35136, "outputTokens": 123, "totalTokens": 35259, "cachedReadTokens": 17408, "cacheCreationTokens": 0, "reasoningTokens": 67, "modelCalls": 2, "apiDurationMs": 4425, "costUsdTicks": 76326600, "modelUsage": {"grok-4.6-build": {"inputTokens": 35136, "outputTokens": 123, "totalTokens": 35259, "cachedReadTokens": 17408, "cacheCreationTokens": 0, "reasoningTokens": 67, "modelCalls": 2, "apiDurationMs": 4425, "costUsdTicks": 76326600}}, "numTurns": 2}}} +} diff --git a/tests/fixtures/native-acp-control.mjs b/tests/fixtures/native-acp-control.mjs new file mode 100644 index 000000000..a2942c848 --- /dev/null +++ b/tests/fixtures/native-acp-control.mjs @@ -0,0 +1,88 @@ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +const filename = fileURLToPath(import.meta.url); + +// This module is also the owned child executable. Test controls/records use IPC, +// never stdout (which contains only ACP JSON-RPC frames). +export function launchControlAgent(cwd, mode = 'held') { + const child = spawn(process.execPath, [filename, mode], { + cwd, env: { HOME: cwd, TMPDIR: cwd }, stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + const records = [], waiters = new Set(); + child.on('message', value => { + records.push(value); + for (const wake of waiters) wake(); + }); + const waitFor = predicate => new Promise((resolve, reject) => { + const timer = setTimeout(() => { waiters.delete(check); reject(new Error('fixture IPC deadline')); }, 3000); + function check() { + const value = records.find(predicate); + if (!value) return; + clearTimeout(timer); waiters.delete(check); resolve(value); + } + waiters.add(check); check(); + }); + let serial = 0; + const command = async (action, extra = {}) => { + const token = ++serial; + child.send({ action, token, ...extra }); + await waitFor(value => value.kind === 'ack' && value.token === token); + }; + const exited = new Promise(resolve => child.once('exit', resolve)); + return { child, records, waitFor, command, exited }; +} + +if (process.argv[1] === filename) { + // Backstop for a failed parent assertion/teardown; this child never survives indefinitely. + setTimeout(() => process.exit(25), 15000).unref(); + process.once('disconnect', () => process.exit(0)); + const mode = process.argv[2], sid = 'fixture-private-sid'; + let active, index = 0, cancelPending = false; + const report = value => process.send?.(value); + const send = (...frames) => process.stdout.write(frames.map(frame => JSON.stringify(frame) + '\n').join('')); + const reply = (id, result) => ({ jsonrpc: '2.0', id, result }); + const update = value => ({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: sid, update: value } }); + const text = value => update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: value } }); + function cancelResult() { + if (!active) return; + send(update({ sessionUpdate: 'tool_call', toolCallId: 'old-tool', title: 'old read', status: 'in_progress' }), + text(`_LATE_${index}`)); + const terminal = reply(active, { stopReason: mode === 'noncancelled' ? 'end_turn' : 'cancelled' }); + if (mode === 'illegal-chunk') send(terminal, text('ILLEGAL_AFTER_TERMINAL')); + else send(terminal); + active = undefined; cancelPending = false; + report({ kind: 'cancel-result', index }); + } + function finish(value) { + if (!active) return; + send(text(value), reply(active, { stopReason: 'end_turn' })); + active = undefined; + } + process.on('message', control => { + if (control.action === 'release-cancel') cancelResult(); + if (control.action === 'finish') finish(control.text ?? 'FINAL'); + if (control.action === 'illegal') send(text('ILLEGAL_IDLE_GAP')); + if (control.action === 'exit') process.exit(23); + report({ kind: 'ack', token: control.token }); + }); + for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line), { method, id } = request; + if (method === 'initialize') send(reply(id, { protocolVersion: 1, agentCapabilities: { loadSession: true }, authMethods: [{ id: 'cursor_login' }] })); + else if (method === 'authenticate') send(reply(id, {})); + else if (method === 'session/new' || method === 'session/load') send(reply(id, { sessionId: sid, configOptions: [] })); + else if (method === 'session/prompt') { + if (active || cancelPending) { report({ kind: 'overlap' }); process.exit(24); } + active = id; index++; + const prompt = request.params.prompt[0].text; + report({ kind: 'prompt', index, prompt, sid: request.params.sessionId, pid: process.pid }); + if (mode === 'fast' && index > 1) finish('FAST_FINAL'); + else send(text(`PARTIAL_${index}`)); + } else if (method === 'session/cancel') { + cancelPending = true; report({ kind: 'cancel', index }); + if (mode === 'exit-on-cancel') process.exit(23); + if (mode !== 'held' && mode !== 'missing-cancel') cancelResult(); + } else if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unsupported fixture request' } }); + } +} diff --git a/tests/unit/activity-identity-snapshot.test.ts b/tests/unit/activity-identity-snapshot.test.ts new file mode 100644 index 000000000..8d9854c35 --- /dev/null +++ b/tests/unit/activity-identity-snapshot.test.ts @@ -0,0 +1,57 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import express from 'express'; +import { registerOrchestrateRoutes } from '../../src/routes/orchestrate.js'; +import { createChatSession, setActiveChatSession } from '../../src/core/chat-sessions.js'; +import { db } from '../../src/core/db.js'; +import { settings } from '../../src/core/config.js'; +import { beginLiveRun, setLiveRunTraceId, clearLiveRun } from '../../src/agent/live-run-state.js'; + +test('snapshot captures requested/active identity and uses its scope for live state', { timeout: 15_000 }, async () => { + const app = express(); + registerOrchestrateRoutes(app, (_req, _res, next) => next()); + const server = createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const base = `http://127.0.0.1:${address.port}/api/orchestrate/snapshot`; + const get = (query = '') => fetch(base + query, { signal: AbortSignal.timeout(3_000) }); + const before = settings.multiSession.enabled; + settings.multiSession.enabled = true; + const viewed = createChatSession('activity-viewed'); + const active = createChatSession('activity-active'); + const viewedScope = `local:${viewed.id}`; + beginLiveRun(viewedScope, 'codex'); + setLiveRunTraceId(viewedScope, 'tr_1234567890abcdef'); + try { + const response = await get(`?session=${viewed.id}`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body.activityIdentity, { sessionId: viewed.id, scope: viewedScope }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(body.orc.scope, viewedScope); + assert.equal(body.activeRun.traceRunId, 'tr_1234567890abcdef'); + const activeBody = await (await get()).json(); + assert.deepEqual(activeBody.activityIdentity, { sessionId: active.id, scope: `local:${active.id}` }); + assert.equal((await get('?session=deleted-session')).status, 404); + for (const query of ['?session=', '?session=%20', '?session=a&session=b', '?session=' + 'x'.repeat(241)]) { + assert.equal((await get(query)).status, 400, query); + } + const remote = 'jaw:slack:channel:C-activity'; + db.prepare('INSERT INTO remote_session_bindings (remote_key,chat_session_id) VALUES (?,?)').run(remote, viewed.id); + assert.deepEqual((await (await get(`?session=${viewed.id}`)).json()).activityIdentity, + { sessionId: viewed.id, scope: remote }); + settings.multiSession.enabled = false; + const disabled = await (await get('?session=deleted-session')).json(); + assert.deepEqual(disabled.activityIdentity, { sessionId: active.id, scope: 'default' }); + assert.equal(disabled.orc.scope, 'default'); + } finally { + clearLiveRun(viewedScope); + settings.multiSession.enabled = before; + setActiveChatSession('default'); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } +}); diff --git a/tests/unit/activity-journal.test.ts b/tests/unit/activity-journal.test.ts new file mode 100644 index 000000000..5aaaace7e --- /dev/null +++ b/tests/unit/activity-journal.test.ts @@ -0,0 +1,314 @@ +import '../setup/isolated-home.ts'; +import test, { beforeEach, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, renameSync, symlinkSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import Database from 'better-sqlite3'; +import { db } from '../../src/core/db.js'; +import { settings, JAW_HOME } from '../../src/core/config.js'; +import { recordRuntimeEvent, type RuntimeEventContext } from '../../src/agent/runtime/events.js'; +import { readActivityPage, listActivityRuns, getActivityOwner, isTraceSessionOwner, + ACTIVITY_RUN_ROWS, ACTIVITY_RUN_BYTES, ACTIVITY_GLOBAL_ROWS, ACTIVITY_PAGE_BYTES } from '../../src/trace/activity-journal.js'; +import { closeActivity, readActivityControl, writeActivityControl, expireActivityPrefix } from '../../src/trace/activity-control.js'; +import { startTraceRun, appendTraceEvent, finalizeTraceRun, getTraceRun, getTraceEvent, + pruneTraceEvents, stampTraceTool, updateTraceToolRow } from '../../src/trace/store.js'; +import { subscribe, type BusEvent } from '../../src/core/event-bus.js'; +import { addBroadcastListener, removeBroadcastListener, broadcast } from '../../src/core/bus.js'; +import { createChatSession, deleteChatSession, forkChatSession } from '../../src/core/chat-sessions.js'; +import type { ToolEntry } from '../../src/types/agent.js'; + +const traceSettings = structuredClone(settings.trace); +beforeEach(() => { db.prepare('DELETE FROM trace_runs').run(); settings.trace = structuredClone(traceSettings); }); +after(() => { db.close(); rmSync(JAW_HOME, { recursive: true, force: true }); }); + +function run(audience: 'public' | 'internal' = 'public', sessionId = 'default'): RuntimeEventContext { + const scope = 'local:' + sessionId; + return { runId: startTraceRun({ cli: 'fixture', audience, sessionId, scopeKey: scope }), + sessionId, scope, turnId: 'turn-1', audience }; +} +function started(audience: 'public' | 'internal' = 'public', sessionId = 'default') { + const context = run(audience, sessionId); + assert.ok(recordRuntimeEvent(context, { kind: 'turn-start', provider: 'fixture' })); + return context; +} +const text = (context: RuntimeEventContext, value = 'hello') => recordRuntimeEvent(context, + { kind: 'message', itemId: 'message', phase: 'unknown', text: value, operation: 'append' }); +const page = (c: RuntimeEventContext, after = 0, through?: number, limit = 40) => + readActivityPage({ runId: c.runId, sessionId: c.sessionId, after, ...(through === undefined ? {} : { through }), limit }); +const count = (runId: string) => (db.prepare("SELECT COUNT(*) AS n FROM trace_events WHERE run_id=? AND source='runtime'").get(runId) as { n: number }).n; + +test('committed noncontiguous sequence, immutable events and latest tool snapshots coexist', () => { + const c = started(); + const first = text(c)!; + const tool: ToolEntry = { icon: 'x', label: 'command', toolType: 'tool', stepRef: 'command-1', status: 'running' }; + stampTraceTool(tool, { traceRunId: c.runId }); + const second = text(c, 'world')!; + assert.equal(second.seq, first.seq + 2); + const before = getTraceEvent(c.runId, first.seq)?.raw; + tool.status = 'done'; tool.detail = 'finished'; updateTraceToolRow(tool); + assert.equal(getTraceEvent(c.runId, first.seq)?.raw, before); + assert.deepEqual(page(c)?.events.map(e => e.seq), [2, first.seq, second.seq]); + assert.equal(page(c)?.loss, null); + assert.equal(page(c)?.incomplete, false); +}); + +test('frozen high watermark excludes concurrent tail; small pages advance by scanned cursor', () => { + const c = started(); text(c, 'one'); text(c, 'two'); + const first = page(c, 0, undefined, 1)!; + const tail = text(c, 'later')!; + const rest = page(c, first.nextAfter, first.through)!; + assert.ok(rest.events.every(e => e.seq < tail.seq)); + assert.equal(rest.nextAfter, first.through); + assert.equal(rest.hasMore, false); + assert.equal(page(c, first.through)?.events.at(-1)?.seq, tail.seq); + assert.throws(() => page(c, 0, tail.seq + 1), RangeError); + assert.throws(() => page(c, tail.seq + 1), RangeError); +}); + +test('page UTF-8 byte budget bounds valid multi-byte event streams', () => { + const c = started(); + for (let i = 0; i < 40; i++) assert.ok(text(c, '한'.repeat(8000))); + let cursor = 0, total = 0, through: number | undefined; + do { + const p = page(c, cursor, through)!; + assert.ok(Buffer.byteLength(JSON.stringify({ ok: true, data: p })) <= ACTIVITY_PAGE_BYTES); + assert.ok(p.events.length <= 40); + assert.ok(p.nextAfter > cursor); + total += p.events.length; cursor = p.nextAfter; through = p.through; + if (!p.hasMore) break; + } while (total < 100); + assert.equal(total, 41); +}); + +test('corrupt and oversized rows advance cursor; oversized control is distinct from absence', () => { + const c = started(); const bad = text(c)!; const huge = text(c)!; const good = text(c)!; + db.prepare('UPDATE trace_events SET raw_json=? WHERE run_id=? AND seq=?').run('{bad', c.runId, bad.seq); + db.prepare('UPDATE trace_events SET raw_json=? WHERE run_id=? AND seq=?').run('x'.repeat(1_000_000), c.runId, huge.seq); + const p = page(c, bad.seq - 1, undefined, 2)!; + assert.equal(p.events.length, 0); assert.equal(p.nextAfter, huge.seq); + assert.equal(p.loss, 'corrupt'); assert.equal(p.incomplete, true); assert.equal(p.hasMore, true); + assert.equal(page(c, p.nextAfter, p.through)?.events[0]?.seq, good.seq); + const control = readActivityControl(c.runId)!; + db.prepare('UPDATE trace_events SET raw_json=? WHERE run_id=? AND seq=?').run('x'.repeat(1_000_000), c.runId, control.seq); + assert.throws(() => page(c), /control_corrupt/); + assert.equal(text(c), null); + assert.doesNotThrow(() => finalizeTraceRun(c.runId, 'done')); + assert.equal(getTraceRun(c.runId)?.status, 'done'); +}); + +test('null, empty and whitespace finals survive; closure is idempotent and late writes stop', () => { + for (const finalText of [null, '', ' \n']) { + const c = started(); + assert.ok(recordRuntimeEvent(c, { kind: 'turn-end', status: 'done', finalText })); + closeActivity(c.runId); finalizeTraceRun(c.runId, 'done'); closeActivity(c.runId); + const p = page(c)!; + const end = p.events.at(-1); + assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, finalText); + assert.equal(p.incomplete, false); assert.equal(text(c), null); + } + const interrupted = started(); text(interrupted, 'partial'); finalizeTraceRun(interrupted.runId, 'interrupted'); + assert.equal(page(interrupted)?.loss, 'storage_error'); + assert.equal(page(interrupted)?.incomplete, true); +}); + +test('owner, scope, start and audience checks block writes and public reads', () => { + const c = run(); assert.equal(text(c), null); + recordRuntimeEvent(c, { kind: 'turn-start', provider: 'fixture' }); + assert.equal(text({ ...c, sessionId: 'missing' }), null); + assert.equal(text({ ...c, scope: 'foreign' }), null); + assert.equal(text({ ...c, audience: 'internal' }), null); + assert.equal(text({ ...c, sessionId: 'missing' }, 'x'.repeat(33_000)), null); + assert.equal(text({ ...c, scope: 'foreign' }, 'x'.repeat(33_000)), null); + assert.equal(page(c)?.loss, null, 'foreign preflight failures cannot mark another owner'); + assert.equal(getActivityOwner(c.runId, 'missing'), null); + const internal = started('internal'); assert.ok(text(internal)); + assert.equal(page(internal), null); + assert.equal(isTraceSessionOwner(internal.runId, internal.sessionId), false); + assert.equal(text({ ...internal, audience: 'public' }), null); + assert.equal(text({ ...internal, audience: 'public' }, 'x'.repeat(33_000)), null); + assert.equal(readActivityControl(internal.runId)?.state.loss, null); + assert.ok(listActivityRuns('default').every(r => r.id !== internal.runId)); + const unowned = startTraceRun({ cli: 'legacy' }); + assert.equal(getTraceRun(unowned)?.session_id, null); + assert.equal(readActivityPage({ runId: unowned, sessionId: 'default', after: 0, limit: 40 }), null); +}); + +test('limits persist a loss instead of truncating an append or emitting a false terminal', () => { + for (const [key, amount] of [['count', ACTIVITY_RUN_ROWS], ['bytes', ACTIVITY_RUN_BYTES]] as const) { + const c = started(); const control = readActivityControl(c.runId)!; + writeActivityControl(c.runId, control.seq, { ...control.state, [key]: amount }); + assert.equal(text(c), null); assert.equal(count(c.runId), 1); assert.equal(page(c)?.loss, 'run_limit'); + } + const huge = started(); assert.equal(text(huge, 'x'.repeat(33_000)), null); + assert.equal(page(huge)?.loss, 'event_limit'); assert.equal(count(huge.runId), 1); + const cap = started(); settings.trace.maxRows = 1; + assert.equal(text(cap), null); assert.equal(page(cap)?.loss, 'global_limit'); +}); + +test('legacy runtime rows count toward global row and byte admission limits', () => { + for (const [rows, raw] of [[ACTIVITY_GLOBAL_ROWS, '{}'], [1100, 'x'.repeat(32_000)]] as const) { + db.prepare('DELETE FROM trace_runs').run(); + const c = started(); const old = startTraceRun({ cli: 'legacy' }); + db.prepare(`WITH RECURSIVE nums(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM nums WHERE n < ?) + INSERT INTO trace_events (run_id,seq,source,event_type,raw_json,bytes,retention_status,created_at) + SELECT ?,n,'runtime','message',?,?,'available',? FROM nums`) + .run(rows, old, raw, Buffer.byteLength(raw), Date.now()); + assert.equal(text(c), null); assert.equal(page(c)?.loss, 'global_limit'); + } +}); + +test('failed control update rolls back runtime insert and publishes nothing', t => { + t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + const c = started(); const control = readActivityControl(c.runId)!; + const events: BusEvent[] = []; const unsubscribe = subscribe(e => events.push(e)); + db.exec(`CREATE TRIGGER activity_fail BEFORE UPDATE ON trace_events + WHEN old.source='system' AND old.event_type='runtime.control.v1' + BEGIN SELECT RAISE(ABORT, 'fixture control failure'); END`); + try { + assert.equal(text(c), null); assert.equal(count(c.runId), 1); + assert.equal(readActivityControl(c.runId)?.state.lastSeq, control.state.lastSeq); + assert.equal(events.length, 0); + } finally { db.exec('DROP TRIGGER activity_fail'); unsubscribe(); } + const next = text(c)!; assert.ok(next.seq > control.state.lastSeq + 1, 'rolled-back seq is never published/reused in-process'); +}); + +test('SQL busy and failed insert leave final caller usable with no journal publication', t => { + t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + const c = started(); const other = new Database(join(JAW_HOME, 'jaw.db')); + db.pragma('busy_timeout=1'); other.exec('BEGIN IMMEDIATE'); + try { assert.equal(text(c), null); assert.equal(count(c.runId), 1); } + finally { other.exec('ROLLBACK'); other.close(); db.pragma('busy_timeout=5000'); } + db.exec(`CREATE TRIGGER activity_insert_fail BEFORE INSERT ON trace_events WHEN new.source='runtime' + BEGIN SELECT RAISE(ABORT, 'fixture insert failure'); END`); + try { assert.equal(text(c), null); assert.equal(count(c.runId), 1); } + finally { db.exec('DROP TRIGGER activity_insert_fail'); } + assert.equal(page(c)?.loss, 'storage_error'); +}); + +test('retention expires whole prefixes, keeps active owners and eventually reclaims closed metadata', () => { + const admitted = run(); + db.prepare('UPDATE trace_runs SET started_at=0 WHERE id=?').run(admitted.runId); + pruneTraceEvents(7, 0); + assert.ok(getTraceRun(admitted.runId), 'admitted owner survives before first semantic event'); + const c = started(); text(c); const high = page(c)!.through; + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=?').run(c.runId); + db.prepare('UPDATE trace_runs SET started_at=0 WHERE id=?').run(c.runId); + pruneTraceEvents(7, 0); + assert.ok(getTraceRun(c.runId)); assert.equal(text(c), null); + assert.deepEqual(page(c)?.events, []); assert.equal(page(c)?.through, high); + assert.equal(page(c)?.nextAfter, high); assert.equal(page(c)?.hasMore, false); + assert.equal(page(c)?.loss, 'retention'); + finalizeTraceRun(c.runId, 'interrupted'); pruneTraceEvents(7, 0); + assert.equal(getTraceRun(c.runId), null); + const closed = started(); text(closed); recordRuntimeEvent(closed, { kind: 'turn-end', status: 'done', finalText: 'answer' }); + finalizeTraceRun(closed.runId, 'done'); expireActivityPrefix(closed.runId); + assert.equal(page(closed)?.loss, 'retention'); pruneTraceEvents(7, 0); + assert.equal(getTraceRun(closed.runId), null); +}); + +test('retention reclaims obsolete raw spill files within a still-owned run', () => { + const c = started(); + const spill = appendTraceEvent({ runId: c.runId, source: 'cli_raw', eventType: 'large', raw: 'x'.repeat(100_000) })!; + const row = getTraceEvent(c.runId, spill.traceSeq)!; assert.ok(row.raw_path); + const path = join(JAW_HOME, row.raw_path); assert.ok(existsSync(path)); + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=? AND seq=?').run(c.runId, spill.traceSeq); + pruneTraceEvents(7); assert.equal(existsSync(path), false); assert.ok(getTraceRun(c.runId)); +}); + +test('corrupt control cannot block unrelated retention or closed owner reclamation', () => { + const corrupt = started(); text(corrupt); + const control = readActivityControl(corrupt.runId)!; + db.prepare('UPDATE trace_events SET raw_json=? WHERE run_id=? AND seq=?').run('x'.repeat(1_000_000), corrupt.runId, control.seq); + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=?').run(corrupt.runId); + const healthy = started(); text(healthy); + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=?').run(healthy.runId); + const rawRun = startTraceRun({ cli: 'legacy' }); + const raw = appendTraceEvent({ runId: rawRun, source: 'cli_raw', eventType: 'large', raw: 'x'.repeat(100_000) })!; + const rawPath = join(JAW_HOME, getTraceEvent(rawRun, raw.traceSeq)!.raw_path!); + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=?').run(rawRun); + const pruned = pruneTraceEvents(7); + assert.ok(pruned.deletedEvents >= 5); + assert.equal(count(corrupt.runId), 0); assert.ok(getTraceRun(corrupt.runId)); + assert.equal(page(healthy)?.loss, 'retention'); assert.equal(existsSync(rawPath), false); + assert.throws(() => page(corrupt), /control_corrupt/); + finalizeTraceRun(corrupt.runId, 'error'); pruneTraceEvents(7, 0); + assert.equal(getTraceRun(corrupt.runId), null); + assert.ok(getTraceRun(healthy.runId)); +}); + +test('retention refuses a symlinked trace root and preserves an external sentinel', () => { + const c = started(); const root = join(JAW_HOME, 'traces'); const held = join(JAW_HOME, 'traces-held'); + const external = mkdtempSync(join(tmpdir(), 'cli-jaw-activity-external-')); + mkdirSync(join(external, c.runId)); + const sentinel = join(external, c.runId, '999999.json'); writeFileSync(sentinel, 'external sentinel'); + const hadRoot = existsSync(root); if (hadRoot) renameSync(root, held); + symlinkSync(external, root, process.platform === 'win32' ? 'junction' : 'dir'); + try { pruneTraceEvents(7); assert.equal(readFileSync(sentinel, 'utf8'), 'external sentinel'); } + finally { unlinkSync(root); if (hadRoot) renameSync(held, root); rmSync(external, { recursive: true, force: true }); } +}); + +test('fork cannot acquire source journal and deleting the owner removes its trace', () => { + const owner = createChatSession('journal-owner'); const c = started('public', owner.id); text(c); + const fork = forkChatSession(owner.id); + assert.equal(readActivityPage({ runId: c.runId, sessionId: fork.id, after: 0, limit: 40 }), null); + assert.ok(deleteChatSession(owner.id)); assert.equal(getTraceRun(c.runId), null); assert.equal(text(c), null); +}); + +test('semantic records and broadcast defense bypass messaging listeners while legacy final remains', () => { + const legacy: string[] = []; const seen: BusEvent[] = []; + const listener = (type: string) => legacy.push(type); addBroadcastListener(listener); + const unsubscribe = subscribe(e => seen.push(e)); + try { + const c = started(); text(c); + broadcast('agent_runtime_gap', { runId: c.runId, sessionId: c.sessionId, scope: c.scope }); + broadcast('agent_runtime', { runId: 'internal' }, 'internal'); + assert.deepEqual(legacy, []); assert.equal(seen.length, 3); + broadcast('agent_done', { text: 'authoritative legacy final' }); + assert.deepEqual(legacy, ['agent_done']); + } finally { unsubscribe(); removeBroadcastListener(listener); } +}); + +test('another process reads durable journal and marks only stale running traces interrupted', () => { + const c = started(); text(c); + const child = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', + `const s=await import('./src/trace/store.ts');s.markStaleTraceRunsInterrupted(); + const j=await import('./src/trace/activity-journal.ts'); + console.log('RESULT:'+JSON.stringify(j.readActivityPage({runId:${JSON.stringify(c.runId)},sessionId:'default',after:0,limit:40})));`], + { cwd: process.cwd(), env: process.env, encoding: 'utf8', timeout: 10_000 }); + assert.equal(child.status, 0, child.stderr); + const result = JSON.parse(child.stdout.split('RESULT:')[1]!); + assert.equal(result.status, 'interrupted'); assert.equal(result.events.length, 2); + assert.equal(result.incomplete, true); assert.equal(text(c), null); +}); + +test('legacy schema migration backfills only original message ownership and remains idempotent', () => { + for (const originalOwner of ['original-owner', 'default']) { + const home = mkdtempSync(join(tmpdir(), 'cli-jaw-activity-migration-')); + const legacy = new Database(join(home, 'jaw.db')); + legacy.exec(`CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL, + content TEXT NOT NULL, cli TEXT, model TEXT, trace TEXT, cost_usd REAL, duration_ms INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ${originalOwner === 'default' ? '' : 'session_id TEXT,'} trace_run_id TEXT); + CREATE TABLE trace_runs (id TEXT PRIMARY KEY, message_id INTEGER, parent_run_id TEXT, + cli TEXT NOT NULL, model TEXT, working_dir TEXT, agent_label TEXT, audience TEXT DEFAULT 'public', + status TEXT DEFAULT 'done', raw_retention_status TEXT DEFAULT 'available', event_count INTEGER DEFAULT 0, + byte_count INTEGER DEFAULT 0, started_at INTEGER NOT NULL, finished_at INTEGER, last_event_at INTEGER, error TEXT); + INSERT INTO trace_runs(id,message_id,cli,started_at) VALUES('tr_legacy1234567890',1,'legacy',1); + INSERT INTO messages(id,role,content,trace_run_id) VALUES + (1,'assistant','original','tr_legacy1234567890'), + (2,'assistant','copied','tr_legacy1234567890');`); + if (originalOwner !== 'default') legacy.exec("UPDATE messages SET session_id=CASE WHEN id=1 THEN 'original-owner' ELSE 'fork-owner' END"); + legacy.close(); + try { + for (let i = 0; i < 2; i++) { + const child = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', + `const {db}=await import('./src/core/db.ts'); + console.log('RESULT:'+JSON.stringify(db.prepare('SELECT session_id,scope_key FROM trace_runs').all()));db.close();`], + { cwd: process.cwd(), env: { ...process.env, CLI_JAW_HOME: home }, encoding: 'utf8', timeout: 10_000 }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout.split('RESULT:')[1]!), [{ session_id: originalOwner, scope_key: null }]); + } + } finally { rmSync(home, { recursive: true, force: true }); } + } +}); diff --git a/tests/unit/activity-routes.test.ts b/tests/unit/activity-routes.test.ts new file mode 100644 index 000000000..480439ac2 --- /dev/null +++ b/tests/unit/activity-routes.test.ts @@ -0,0 +1,303 @@ +import '../setup/isolated-home.ts'; +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import { createChatSession, deleteChatSession, forkChatSession, setActiveChatSession } from '../../src/core/chat-sessions.js'; +import { db, insertMessageWithTraceRun } from '../../src/core/db.js'; +import { appendTraceEvent, finalizeTraceRun, getTraceRun, linkTraceRunToMessage, startTraceRun } from '../../src/trace/store.js'; +import { recordRuntimeEvent, type RuntimeEventContext } from '../../src/agent/runtime/events.js'; +import type { RuntimeEventBody } from '../../src/shared/runtime-contract.js'; + +// Keep real SQLite ownership/replay; inject only a repository failure at its public seam. +const journal = await import('../../src/trace/activity-journal.js'); +let storageFailure = false; +mock.module('../../src/trace/activity-journal.js', { + namedExports: { + ...journal, + listActivityRuns: (...args: Parameters) => { + if (storageFailure) throw new Error('private fixture storage path'); + return journal.listActivityRuns(...args); + }, + readActivityPage: (...args: Parameters) => { + if (storageFailure) throw new Error('private fixture storage path'); + return journal.readActivityPage(...args); + }, + }, +}); +const { registerTraceRoutes } = await import('../../src/routes/traces.js'); + +type Auth = (req: Request, res: Response, next: NextFunction) => void; + +async function readResponse(base: string, path: string, status = 200) { + const response = await fetch(base + path, { signal: AbortSignal.timeout(3_000) }); + assert.equal(response.status, status, path); + assert.equal(response.headers.get('cache-control'), 'no-store', path); + const body = await response.json(); + assert.equal(body.ok, status === 200, path); + return body; +} + +async function withServer( + fn: (get: (path: string, status?: number) => ReturnType) => Promise, + auth: Auth = (_req, _res, next) => next(), +): Promise { + const app = express(); + app.set('query parser', 'extended'); + registerTraceRoutes(app, auth); + const server = createServer(app); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + try { + await fn((path, status = 200) => readResponse(`http://127.0.0.1:${address.port}/api/traces`, path, status)); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + setActiveChatSession('default'); + } +} + +function ownedRun(sessionId = createChatSession('activity-route-owner').id, audience: 'public' | 'internal' = 'public') { + const scope = `mention-watch:route:${sessionId}`; + const runId = startTraceRun({ cli: 'codex', sessionId, scopeKey: scope, audience }); + const context: RuntimeEventContext = { runId, sessionId, scope, turnId: 'route-turn', audience }; + const emit = (body: RuntimeEventBody) => { + const event = recordRuntimeEvent(context, body); + assert.ok(event, `record ${body.kind}`); + return event; + }; + const start = emit({ kind: 'turn-start', provider: 'codex' }); + return { runId, sessionId, scope, emit, start }; +} + +test('Activity uses owned persisted events, fixed through cursors and exact envelopes', { timeout: 15_000 }, async () => { + const run = ownedRun(); + assert.ok(journal.getActivityOwner(run.runId, run.sessionId)); + assert.equal(journal.getActivityOwner(run.runId, 'wrong-owner'), null); + appendTraceEvent({ runId: run.runId, source: 'cli_raw', eventType: 'gap', raw: { diagnostic: true } }); + const message = run.emit({ kind: 'message', itemId: 'answer', phase: 'final', text: 'original answer', operation: 'replace' }); + const path = `/${run.runId}/activity?session=${run.sessionId}`; + await withServer(async get => { + const first = (await get(path + '&limit=1')).data; + assert.deepEqual(Object.keys(first).sort(), [ + 'runId', 'sessionId', 'scope', 'status', 'events', 'nextAfter', 'through', 'hasMore', 'incomplete', 'loss', + ].sort()); + assert.equal(first.runId, run.runId); + assert.equal(first.sessionId, run.sessionId); + assert.equal(first.scope, run.scope); + assert.equal(first.status, 'running'); + assert.equal(first.through, message.seq); + assert.equal(first.nextAfter, run.start.seq); + assert.equal(first.hasMore, true); + assert.equal(first.incomplete, false); + assert.equal(first.loss, null); + assert.equal(first.events.length, 1); + assert.equal(first.events[0].kind, 'turn-start'); + + const end = run.emit({ kind: 'turn-end', status: 'done', finalText: '' }); + finalizeTraceRun(run.runId, 'done'); + const second = (await get(path + `&after=${first.nextAfter}&through=${first.through}&limit=1`)).data; + assert.equal(second.through, first.through); + assert.equal(second.nextAfter, message.seq); + assert.equal(second.hasMore, false); + assert.deepEqual(second.events.map((event: { kind: string; text?: string }) => [event.kind, event.text]), + [['message', 'original answer']]); + const tail = (await get(path + `&after=${first.through}`)).data; + assert.equal(tail.status, 'done'); + assert.equal(tail.through, end.seq); + assert.equal(tail.hasMore, false); + assert.equal(tail.incomplete, false); + assert.equal(tail.events[0].finalText, ''); + assert.equal(tail.events[0].kind, 'turn-end'); + assert.equal(tail.events[0].sessionId, run.sessionId); + assert.equal(tail.events[0].scope, run.scope); + const empty = (await get(path + `&after=${end.seq}&through=${end.seq}`)).data; + assert.deepEqual(empty.events, []); + assert.equal(empty.nextAfter, end.seq); + assert.equal(empty.hasMore, false); + for (const query of [`&after=${end.seq + 1}`, `&through=${end.seq + 1}`, `&after=${end.seq}&through=${message.seq}`]) { + assert.deepEqual(await get(path + query, 409), { ok: false, error: 'activity_resync_required' }); + } + }); +}); + +test('Activity rejects malformed, repeated, nested, unknown and unsafe query values', { timeout: 15_000 }, async () => { + const run = ownedRun(); + await withServer(async get => { + for (const suffix of ['/activity-runs', `/${run.runId}/activity`]) { + for (const query of ['', '?session=', '?session=a&session=b', '?session[]=a', '?session[x]=a', + '?session=' + 'x'.repeat(241), `?session=${run.sessionId}&unknown=1`, `?session=${run.sessionId}&scope=x`]) { + assert.deepEqual(await get(suffix + query, 400), { ok: false, error: 'invalid_activity_query' }); + } + } + for (const field of ['after', 'through', 'limit']) { + for (const value of ['', '-1', '+1', '1.5', '1e2', '0x10', 'Infinity', 'NaN', '9007199254740992', ' 1']) { + await get(`/${run.runId}/activity?session=${run.sessionId}&${field}=${encodeURIComponent(value)}`, 400); + } + for (const query of [`${field}=1&${field}=2`, `${field}[]=1`, `${field}[x]=1`]) { + await get(`/${run.runId}/activity?session=${run.sessionId}&${query}`, 400); + } + } + for (const limit of ['0', '41']) await get(`/${run.runId}/activity?session=${run.sessionId}&limit=${limit}`, 400); + for (const badRun of ['bad', 'tr_short', 'tr_' + 'a'.repeat(81), 'tr_abcdefghijklmnop!']) { + await get(`/${badRun}/activity?session=${run.sessionId}`, 400); + await get(`/activity-runs?session=${run.sessionId}&after=${badRun}`, 400); + } + for (const query of ['after=a&after=b', 'after[]=x', 'after[x]=x', 'limit=40', 'through=0']) { + await get(`/activity-runs?session=${run.sessionId}&${query}`, 400); + } + // Decimal leading zeroes and an explicitly empty discovery cursor are valid. + await get(`/${run.runId}/activity?session=${run.sessionId}&after=00&limit=01`); + await get(`/activity-runs?session=${run.sessionId}&after=`); + await get('/tr_abcdefghijklmnop/activity?session=unknown', 404); + await get('/tr_' + 'a'.repeat(80) + '/activity?session=unknown', 404); + }); +}); + +test('Activity denies wrong, internal, missing, forked and deleted owners', { timeout: 15_000 }, async () => { + const run = ownedRun(); + const wrong = createChatSession('other-owner').id; + const internal = ownedRun(run.sessionId, 'internal'); + const orphan = ownedRun(); + db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(orphan.sessionId); + assert.ok(getTraceRun(orphan.runId)); + const messageId = Number(insertMessageWithTraceRun.run('assistant', 'fork source', 'codex', '', null, null, '', run.runId, run.sessionId).lastInsertRowid); + linkTraceRunToMessage(run.runId, messageId); + const fork = forkChatSession(run.sessionId); + assert.equal(fork.copiedCount, 1); + await withServer(async get => { + for (const [runId, sessionId] of [[run.runId, wrong], [run.runId, fork.id], [run.runId, 'missing-session'], + [internal.runId, run.sessionId], [orphan.runId, orphan.sessionId]]) { + assert.deepEqual(await get(`/${runId}/activity?session=${sessionId}`, 404), { ok: false, error: 'trace_not_found' }); + } + for (const suffix of ['', '/events', `/events/${orphan.start.seq}`]) { + await get(`/${orphan.runId}${suffix}?session=${orphan.sessionId}`, 404); + } + assert.deepEqual((await get(`/activity-runs?session=${fork.id}`)).data, { runs: [], pageSize: 40 }); + assert.deepEqual((await get('/activity-runs?session=missing-session')).data, { runs: [], pageSize: 40 }); + assert.equal(deleteChatSession(run.sessionId), true); + await get(`/${run.runId}/activity?session=${run.sessionId}`, 404); + assert.deepEqual((await get(`/activity-runs?session=${run.sessionId}`)).data.runs, []); + }); +}); + +test('all raw reads fence either owner column while preserving truly ownerless legacy access', { timeout: 15_000 }, async () => { + const run = ownedRun(); + const raw = appendTraceEvent({ runId: run.runId, source: 'cli_raw', eventType: 'raw', raw: { text: 'owned detail' } }); + assert.ok(raw); + const sessionOnly = startTraceRun({ cli: 'codex', sessionId: run.sessionId }); + const scopeOnly = startTraceRun({ cli: 'codex', scopeKey: run.scope }); + const legacy = startTraceRun({ cli: 'codex' }); + const internal = startTraceRun({ cli: 'codex', audience: 'internal' }); + for (const runId of [sessionOnly, scopeOnly, legacy, internal]) { + assert.ok(appendTraceEvent({ runId, source: 'cli_raw', eventType: 'raw', raw: { text: 'raw detail' } })); + } + assert.equal(journal.getActivityOwner(sessionOnly, run.sessionId), null); + const fork = forkChatSession(run.sessionId); + await withServer(async get => { + for (const [runId, seq] of [[run.runId, raw.traceSeq], [sessionOnly, 1]] as const) { + for (const suffix of ['', '/events', `/events/${seq}`]) { + for (const query of ['', '?session=', '?session=wrong', `?session=${fork.id}`, + `?session=${run.sessionId}&session=${run.sessionId}`, `?session[]=${run.sessionId}`, + `?session[x]=${run.sessionId}`, `?scope=${run.scope}`, `?session=${run.sessionId}%20`]) { + await get(`/${runId}${suffix}${query}`, 404); + } + await get(`/${runId}${suffix}?session=${run.sessionId}`); + } + } + for (const suffix of ['', '/events', '/events/1']) { + await get(`/${scopeOnly}${suffix}?session=${run.sessionId}`, 404); + await get(`/${internal}${suffix}?session=${run.sessionId}`, 404); + await get(`/${legacy}${suffix}`); + await get(`/${legacy}${suffix}?session[]=ignored`); + } + await get(`/${sessionOnly}/activity?session=${run.sessionId}`, 404); + await get(`/${legacy}/activity?session=${run.sessionId}`, 404); + await get(`/${run.runId}/events/invalid?session=${run.sessionId}`, 400); + await get(`/${run.runId}/events/999999?session=${run.sessionId}`, 404); + assert.equal(deleteChatSession(run.sessionId), true); + for (const suffix of ['', '/events', `/events/${raw.traceSeq}`]) await get(`/${run.runId}${suffix}?session=${run.sessionId}`, 404); + }); +}); + +test('Activity event pages default to forty and preserve the cursor across pages', { timeout: 15_000 }, async () => { + const run = ownedRun(); + for (let index = 0; index < 40; index++) { + run.emit({ kind: 'message', itemId: `item-${index}`, phase: 'unknown', text: `text-${index}`, operation: 'replace' }); + } + run.emit({ kind: 'turn-end', status: 'done', finalText: null }); + finalizeTraceRun(run.runId, 'done'); + await withServer(async get => { + const path = `/${run.runId}/activity?session=${run.sessionId}`; + const first = await get(path); + assert.ok(Buffer.byteLength(JSON.stringify(first)) <= 256 * 1024); + assert.equal(first.data.events.length, 40); + assert.equal(first.data.hasMore, true); + const last = (await get(path + `&after=${first.data.nextAfter}&through=${first.data.through}`)).data; + assert.equal(last.events.length, 2); + assert.equal(last.events[0].text, 'text-39'); + assert.equal(last.events[1].kind, 'turn-end'); + assert.equal(last.events[1].finalText, null); + assert.equal(last.nextAfter, first.data.through); + assert.equal(last.hasMore, false); + assert.equal(last.incomplete, false); + }); +}); + +test('literal discovery returns only owned public runs in stable forty-run pages with summary fields', { timeout: 15_000 }, async () => { + const sessionId = createChatSession('discovery-owner').id; + const expected = []; + for (let index = 0; index < 42; index++) { + const run = ownedRun(sessionId); + const messageId = Number(insertMessageWithTraceRun.run('assistant', `answer-${index}`, 'codex', '', null, null, '', run.runId, sessionId).lastInsertRowid); + linkTraceRunToMessage(run.runId, messageId); + run.emit({ kind: 'turn-end', status: 'done', finalText: `answer-${index}` }); + finalizeTraceRun(run.runId, 'done'); + expected.push({ id: run.runId, messageId, status: 'done', startedAt: getTraceRun(run.runId)!.started_at }); + } + expected.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0); + ownedRun(sessionId, 'internal'); + ownedRun(); + startTraceRun({ cli: 'codex', sessionId }); + startTraceRun({ cli: 'codex' }); + await withServer(async get => { + const first = await get(`/activity-runs?session=${sessionId}`); + assert.deepEqual(first, { ok: true, data: { runs: expected.slice(0, 40), pageSize: 40 } }); + const last = await get(`/activity-runs?session=${sessionId}&after=${first.data.runs[39].id}`); + assert.deepEqual(last.data, { runs: expected.slice(40), pageSize: 40 }); + assert.deepEqual((await get(`/activity-runs?session=${sessionId}&after=${last.data.runs[1].id}`)).data, + { runs: [], pageSize: 40 }); + }); +}); + +test('Activity storage failures are no-store 503 envelopes without private error details', { timeout: 10_000 }, async () => { + const run = ownedRun(); + storageFailure = true; + try { + await withServer(async get => { + for (const path of [`/activity-runs?session=${run.sessionId}`, `/${run.runId}/activity?session=${run.sessionId}`]) { + assert.deepEqual(await get(path, 503), { ok: false, error: 'activity_unavailable' }); + } + }); + } finally { + storageFailure = false; + } +}); + +test('every trace route invokes the supplied auth middleware after setting no-store', { timeout: 10_000 }, async () => { + let calls = 0; + const runId = 'tr_abcdefghijklmnop'; + await withServer(async get => { + for (const path of ['/activity-runs?session=owner', `/${runId}/activity?session=owner`, `/${runId}`, `/${runId}/events`, `/${runId}/events/1`]) { + assert.deepEqual(await get(path, 401), { ok: false, error: 'fixture_auth_required' }); + } + }, (_req, res) => { + calls++; + res.status(401).json({ ok: false, error: 'fixture_auth_required' }); + }); + assert.equal(calls, 5); +}); diff --git a/tests/unit/cli-switch-refresh.test.ts b/tests/unit/cli-switch-refresh.test.ts index 00634c16c..ee3d7e3e1 100644 --- a/tests/unit/cli-switch-refresh.test.ts +++ b/tests/unit/cli-switch-refresh.test.ts @@ -1,3 +1,4 @@ +import '../setup/isolated-home.ts'; import { readSource } from './source-normalize.js'; // CLI Switch Session Refresh — Issue #126 // Mostly source-pattern assertions following existing test style (phase31-runtime, employee-session-reuse). @@ -65,8 +66,27 @@ test('CSR-005b: ai-e provider change triggers clean session refresh', () => { assert.match(runtimeSrc, /toProvider:\s*toCli\s*===\s*'ai-e'\s*\?\s*nextAiEProvider\s*:\s*undefined/); }); -test('CSR-006: cli unchanged branch keeps original syncMainSessionToSettings(prevCli)', () => { - assert.match(runtimeSrc, /\}\s*else\s*\{\s*syncMainSessionToSettings\(prevCli\)/); +test('CSR-006: unchanged CLI synchronizes execution settings while display-only writes preserve the session', async t => { + const config = await import('../../src/core/config.ts'); + const { applyRuntimeSettingsPatch } = await import('../../src/core/runtime-settings.ts'); + const { updateSession, getSession } = await import('../../src/core/db.ts'); + const original = config.snapshotSettingsState(); + t.after(() => config.commitCandidate(original)); + const baseline = structuredClone(config.settings); + baseline.cli = 'claude'; baseline.activeOverrides = {}; + baseline.perCli.claude = { model: 'configured-model', effort: 'high' }; + config.commitCandidate({ value: baseline, shape: original.shape }); + updateSession.run('claude', 'captured-session', 'old-model', 'auto', baseline.workingDir, 'low'); + await applyRuntimeSettingsPatch({ perCli: { claude: { model: 'new-model' } } }, { + writeSettings: () => {}, restartMessaging: async () => {}, + }); + assert.equal(getSession()?.model, 'new-model'); + assert.equal(getSession()?.session_id, 'captured-session'); + const selected = getSession(); + await applyRuntimeSettingsPatch({ presentation: { mode: 'legacy' } }, { + writeSettings: () => {}, restartMessaging: async () => {}, + }); + assert.deepEqual(getSession(), selected); }); test('CSR-007: codex-spark bucket targeted via toModel (not null)', () => { @@ -203,14 +223,23 @@ test('CSR-013: no-content switch preserves existing pending bootstrap', () => { assert.doesNotMatch(body, /setPendingBootstrapPromptStrict\(null\)/); }); -test('CSR-012: cli-changed branch does NOT call syncMainSessionToSettings', () => { - // Capture the if(cliChanged){...} block and verify no syncMainSessionToSettings inside - const ifBlock = runtimeSrc.match(/if\s*\(\s*cliChanged\s*\|\|\s*aiEProviderChanged\s*\)\s*\{([\s\S]*?)\}\s*else\s*\{/); - assert.ok(ifBlock, 'cli/provider changed branch must exist'); - assert.ok( - !/syncMainSessionToSettings/.test(ifBlock![1]), - 'cli-changed branch must delegate main-session clearing to cliSwitchRefresh', - ); +test('CSR-012: CLI change leaves session ownership to the explicit refresh boundary', async t => { + const config = await import('../../src/core/config.ts'); + const { applyRuntimeSettingsPatch } = await import('../../src/core/runtime-settings.ts'); + const { updateSession, getSession } = await import('../../src/core/db.ts'); + const original = config.snapshotSettingsState(); + t.after(() => config.commitCandidate(original)); + const baseline = structuredClone(config.settings); baseline.cli = 'claude'; + config.commitCandidate({ value: baseline, shape: original.shape }); + updateSession.run('claude', 'must-survive-harvest', 'original-model', 'auto', baseline.workingDir, 'high'); + const before = getSession(); + let refreshes = 0; + await applyRuntimeSettingsPatch({ cli: 'codex-app' }, { + writeSettings: () => {}, restartMessaging: async () => {}, + cliSwitchRefresh: async () => { refreshes++; assert.deepEqual(getSession(), before); }, + }); + assert.equal(refreshes, 1); + assert.deepEqual(getSession(), before, 'no extra singleton write outside the refresh owner'); }); // ─── Behavioral test: real DB round-trip for the strict setter ─── diff --git a/tests/unit/codex-app-multiplex-spawn.test.ts b/tests/unit/codex-app-multiplex-spawn.test.ts index c57a12019..14e33143d 100644 --- a/tests/unit/codex-app-multiplex-spawn.test.ts +++ b/tests/unit/codex-app-multiplex-spawn.test.ts @@ -264,6 +264,10 @@ test.mock.module('../../src/agent/codex-host-pool.js', { }, }); +function forbiddenProviderAcquire(provider: string): never { + assert.fail(`${provider} runtime acquisition is outside this Codex App fixture`); +} + test.mock.module('../../src/agent/runtime-pool.js', { namedExports: { acquireCodexAppRuntime: async (options: Record) => { @@ -287,7 +291,9 @@ test.mock.module('../../src/agent/runtime-pool.js', { cancel: async () => {}, }; }, - acquirePiRuntime: async () => { throw new Error('Pi is outside this fixture'); }, + acquirePiRuntime: async () => forbiddenProviderAcquire('Pi'), + acquireCursorRuntime: async () => forbiddenProviderAcquire('Cursor'), + acquireGrokRuntime: async () => forbiddenProviderAcquire('Grok'), }, }); @@ -333,7 +339,8 @@ test.mock.module('../../src/trace/store.js', { namedExports: { appendTraceEvent: (entry: Record) => { harness.traceEvents.push(entry); }, stampTraceTool() {}, stampTraceToolEntries() {}, - updateTraceToolRow() {}, getTraceEvent: () => null, linkTraceRunToMessage() {}, + updateTraceToolRow() {}, getTraceEvent: () => null, getTraceToolEntry: () => null, linkTraceRunToMessage() {}, + createTraceId: () => 'tr_multiplexfixture0001', startTraceRun: () => 'tr_multiplexfixture0001', finalizeTraceRun: (runId: string | null | undefined, status: string) => { harness.finalized.push({ runId, status }); diff --git a/tests/unit/codex-runtime-projection.test.ts b/tests/unit/codex-runtime-projection.test.ts index 51de9053c..9b71c34d0 100644 --- a/tests/unit/codex-runtime-projection.test.ts +++ b/tests/unit/codex-runtime-projection.test.ts @@ -1,7 +1,13 @@ -import test from 'node:test'; +import test, { mock } from 'node:test'; import assert from 'node:assert/strict'; -import { RuntimeProjection } from '../../src/agent/runtime/projection.ts'; -import { CodexProjection } from '../../src/agent/runtime/codex-projection.ts'; +// Recorders are injected here; module loading must not initialize shared SQLite. +let unexpectedDefaultWrites = 0; +mock.module('../../src/trace/store.js', { namedExports: { appendTraceEvent: () => { + unexpectedDefaultWrites++; throw new Error('Pure projection test reached the default trace writer'); +} } }); +test.after(() => assert.equal(unexpectedDefaultWrites, 0)); +const { RuntimeProjection } = await import('../../src/agent/runtime/projection.ts'); +const { CodexProjection } = await import('../../src/agent/runtime/codex-projection.ts'); import type { RuntimeEvent, RuntimeEventBody } from '../../src/shared/runtime-contract.ts'; import { subscribe } from '../../src/core/event-bus.ts'; import { encodeRuntimeBody, decodeRuntimeBody } from '../../src/trace/runtime-body-codec.ts'; diff --git a/tests/unit/cursor-acp-pool.test.ts b/tests/unit/cursor-acp-pool.test.ts new file mode 100644 index 000000000..8218fd6a3 --- /dev/null +++ b/tests/unit/cursor-acp-pool.test.ts @@ -0,0 +1,344 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import type { spawn } from 'node:child_process'; +import { createCursorSession, type CursorSessionOptions } from '../../src/agent/runtime/acp/cursor-session.ts'; +import type { AcpSession } from '../../src/agent/runtime/acp/session.ts'; +import { acquireCursorRuntime, poolStats, type CursorAcquireOptions } from '../../src/agent/runtime-pool.ts'; + +type Wire = Record; +function factoryFixture(t: TestContext) { + const calls: Array<{ command: string; args: string[]; options: Wire }> = [], wire: Wire[] = []; + const child = Object.assign(new EventEmitter(), { pid: 44001, exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, stdout: new PassThrough(), stderr: new PassThrough(), stdin: new Writable() }); + let heldInitialize = false, model = 'm1', effort = 'low', kills = 0, heldWrites = false; + const writeCallbacks: Array<() => void> = []; + let duringSpawn: (() => void) | undefined; + const config = () => [{ id: 'model', name: 'Model', category: 'model', type: 'select', currentValue: model, + options: [{ value: 'm1', name: 'M1' }, { value: 'm2', name: 'M2' }] }, + { id: 'effort', name: 'Effort', category: 'thought_level', type: 'select', currentValue: effort, + options: [{ value: 'low', name: 'Low' }, { value: 'high', name: 'High' }] }]; + const reply = (id: unknown, result: unknown) => setImmediate(() => { + if (child.exitCode === null) child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); + }); + child.stdin = new Writable({ write(chunk, _encoding, callback) { + const message = JSON.parse(String(chunk)) as Wire; wire.push(message); + if (message['method'] === 'initialize' && !heldInitialize) reply(message['id'], { + protocolVersion: 1, authMethods: [{ id: 'cursor_login' }], agentCapabilities: { loadSession: true } }); + if (message['method'] === 'authenticate') reply(message['id'], {}); + if (message['method'] === 'session/new' || message['method'] === 'session/load') reply(message['id'], { + sessionId: message['params'].sessionId ?? 'native-factory', configOptions: config() }); + if (message['method'] === 'session/set_config_option') { + if (message['params'].configId === 'model') model = message['params'].value; + else effort = message['params'].value; + reply(message['id'], { configOptions: config() }); + } + if (heldWrites) writeCallbacks.push(callback); else callback(); + } }); + const exit = () => { if (child.exitCode !== null) return; child.exitCode = 143; child.emit('exit', 143, null); child.emit('close', 143, null); }; + const options: CursorSessionOptions = { binary: 'cursor-agent', env: { PATH: '/fixture' }, cwd: process.cwd(), + permissions: ['read'], promptTimeoutMs: 10_000, model: 'm2', effort: 'high', + spawnImpl: ((command: string, args: string[], opts: Wire) => { + calls.push({ command, args, options: opts }); duringSpawn?.(); return child; + }) as unknown as typeof spawn, + ownedProcessOptions: { terminateTree: () => { kills++; queueMicrotask(exit); } } }; + t.after(() => { exit(); for (const done of writeCallbacks.splice(0)) done(); child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); }); + return { options, calls, wire, child, exit, get kills() { return kills; }, + holdInitialize: () => { heldInitialize = true; }, holdWrites: () => { heldWrites = true; }, + duringSpawn: (fn: () => void) => { duringSpawn = fn; } }; +} + +test('factory uses acp stdin protocol and refreshes model/effort without print argv', { timeout: 5000 }, async t => { + const f = factoryFixture(t); + const session = await createCursorSession(f.options); + assert.deepEqual(f.calls[0]!.args, ['acp']); + assert.equal(f.calls[0]!.options['shell'], false); + assert.deepEqual(f.wire.map(x => x['method']), ['initialize', 'authenticate', 'session/new', 'session/set_config_option', 'session/set_config_option']); + assert.equal(f.wire[0]!['params'].clientCapabilities._meta.parameterizedModelPicker, true); + assert.equal((session.getConfigOptions() as Wire[])[0]!['currentValue'], 'm2'); + assert.equal((session.getConfigOptions() as Wire[])[1]!['currentValue'], 'high'); + assert.equal(session.idle, true); + await session.close(); + assert.equal(f.kills, 1); +}); +test('factory validates options and pre-abort before spawning', { timeout: 5000 }, async t => { + const f = factoryFixture(t); const controller = new AbortController(); controller.abort(); + for (const patch of [{ promptTimeoutMs: 0 }, { permissions: 'bad' }, { cwd: '/fixture/does-not-exist' }, { signal: controller.signal }]) { + await assert.rejects(createCursorSession({ ...f.options, ...patch })); + } + assert.equal(f.calls.length, 0); +}); +test('factory abort covers both spawn-to-listener race and held initialization', { timeout: 5000 }, async t => { + const early = factoryFixture(t); const before = new AbortController(); + early.duringSpawn(() => before.abort()); + await assert.rejects(createCursorSession({ ...early.options, signal: before.signal }), /acquire_aborted/); + assert.equal(early.kills, 1); + const f = factoryFixture(t); f.holdInitialize(); const controller = new AbortController(); + const pending = createCursorSession({ ...f.options, signal: controller.signal }); + const rejected = assert.rejects(pending, /acquire_aborted/); + assert.equal(f.wire[0]!['method'], 'initialize'); + controller.abort(); await rejected; + assert.equal(f.kills, 1); + assert.equal(f.child.exitCode, 143); +}); +test('post-spawn abort waits for the owned child to exit before rejecting acquisition', { timeout: 5000 }, async t => { + const f = factoryFixture(t), controller = new AbortController(); let terminations = 0, settled = false; + f.duringSpawn(() => controller.abort()); + const pending = createCursorSession({ ...f.options, signal: controller.signal, + ownedProcessOptions: { terminateTree: () => { terminations++; } } }); + void pending.then(() => { settled = true; }, () => { settled = true; }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(terminations, 1); assert.equal(f.wire.length, 0); + assert.equal(settled, false); assert.equal(f.child.exitCode, null); + f.exit(); await assert.rejects(pending, /cursor_acp_acquire_aborted/); + assert.equal(f.child.exitCode, 143); +}); +test('post-spawn abort reports failed cleanup if the owned child never exits', { timeout: 5000 }, async t => { + const f = factoryFixture(t), controller = new AbortController(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + f.duringSpawn(() => controller.abort()); + const pending = createCursorSession({ ...f.options, signal: controller.signal, + ownedProcessOptions: { terminateTree: () => {} } }); + const rejected = assert.rejects(pending, /cursor_acp_startup_cleanup_failed/); + t.mock.timers.tick(6000); await rejected; + assert.equal(f.wire.length, 0); assert.equal(f.child.exitCode, null); +}); +test('unsupported requested configuration retires startup without a prompt or fallback', { timeout: 5000 }, async t => { + const f = factoryFixture(t); + await assert.rejects(createCursorSession({ ...f.options, model: 'unadvertised' }), /acp_config_unsupported_model/); + assert.equal(f.kills, 1); + assert.equal(f.wire.some(x => x['method'] === 'session/prompt'), false); +}); +test('factory loads an explicit native ID and detaches acquisition abort after success', { timeout: 5000 }, async t => { + const f = factoryFixture(t); const controller = new AbortController(); + const session = await createCursorSession({ ...f.options, resumeSessionId: 'stored-native', signal: controller.signal }); + assert.equal(session.nativeSessionId, 'stored-native'); + assert.equal(f.wire.find(x => x['method'] === 'session/load')!['params'].sessionId, 'stored-native'); + assert.equal(f.wire.some(x => x['method'] === 'session/new'), false); + controller.abort(); + assert.equal(session.alive, true); + await session.close(); +}); +test('Windows direct launch uses the resolver and unknown native wrappers fail before spawn', { timeout: 5000 }, async t => { + const f = factoryFixture(t); + const session = await createCursorSession({ ...f.options, platform: 'win32', binary: 'C:\\cursor-agent.exe' }); + assert.equal(f.calls[0]!.command, 'C:\\cursor-agent.exe'); + assert.deepEqual(f.calls[0]!.args, ['acp']); + await session.close(); + const refused = factoryFixture(t); + await assert.rejects(createCursorSession({ ...refused.options, platform: 'win32', binary: 'C:\\unknown.ps1' }), /launch_unsupported/); + assert.equal(refused.calls.length, 0); +}); + +let nextScope = 0, nextSession = 0; +class PoolSession { + child = new EventEmitter(); + alive = true; idle = true; nativeSessionId = 'pool-native-' + (++nextSession); + cancellations = 0; retirements = 0; + cancelImpl: () => Promise = () => Promise.resolve(); + cancel(): Promise { this.cancellations++; return this.cancelImpl(); } + retire(): void { if (!this.alive) return; this.alive = false; this.idle = false; this.retirements++; this.child.emit('exit', 1); } + async close(): Promise { this.retire(); } +} +function poolFixture(t: TestContext) { + const sessions: PoolSession[] = [], creations: CursorSessionOptions[] = []; + let generation = 0, admitted = true; + const options: CursorAcquireOptions = { key: { scopeKey: 'cursor-pool-test-' + (++nextScope), cwd: process.cwd(), model: 'm1', effort: '', permissions: ['read'] }, + binary: 'fixture-cursor', env: {}, promptTimeoutMs: 10_000, persistenceOwner: { global: 0, scope: 0 }, waitMs: 1000, + isCurrentOwner: owner => owner.global === generation && owner.scope === 0, canAcquire: () => admitted, + createSession: async input => { + creations.push(input); const session = new PoolSession(); + if (input.resumeSessionId) session.nativeSessionId = input.resumeSessionId; + sessions.push(session); return session as unknown as AcpSession; + } }; + t.after(() => { for (const session of sessions) session.retire(); }); + return { options, sessions, creations, invalidate: () => { generation++; }, rejectAdmission: () => { admitted = false; } }; +} + +test('registered idle reaper expires only released Cursor entries and terminates their owned child', { timeout: 5000 }, async t => { + let now = 100, sweep: (() => void) | undefined; + t.mock.method(Date, 'now', () => now); + const originalInterval = globalThis.setInterval; + t.mock.method(globalThis, 'setInterval', (callback: () => void, delay: number) => { + assert.equal(delay, 60_000); sweep = callback; + return originalInterval(callback, delay); + }); + const f = poolFixture(t), children: ReturnType[] = []; + const options: CursorAcquireOptions = { ...f.options, createSession: input => { + const child = factoryFixture(t); children.push(child); + return createCursorSession({ ...input, spawnImpl: child.options.spawnImpl, ownedProcessOptions: child.options.ownedProcessOptions }); + } }; + assert.equal(poolStats().size, 0); + const released = await acquireCursorRuntime(options); released.release(); + const busy = await acquireCursorRuntime({ ...options, key: { ...options.key, scopeKey: options.key.scopeKey + '-busy' } }); + assert.ok(sweep); assert.equal(poolStats().size, 2); + now += 15 * 60_000 - 1; sweep(); + assert.equal(released.session.alive, true); assert.equal(children[0]!.kills, 0); + now++; sweep(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(poolStats().size, 1); assert.equal(poolStats().busy, 1); + assert.equal(released.session.alive, false); assert.equal(children[0]!.kills, 1); + assert.equal(children[0]!.child.exitCode, 143); + assert.equal(busy.session.alive, true); assert.equal(children[1]!.kills, 0); + busy.release(); await busy.session.close(); await released.session.close(); + assert.equal(poolStats().size, 0); assert.equal(children[0]!.kills, 1); +}); +test('cursor pool reuses a drained scoped session and old released cancellation cannot touch its borrower', async t => { + const f = poolFixture(t); const a = await acquireCursorRuntime(f.options); a.release(); + const b = await acquireCursorRuntime(f.options); + assert.equal(b.reused, true); assert.equal(a.session, b.session); + await a.cancel(); assert.equal(f.sessions[0]!.cancellations, 0); + b.release(); assert.equal(f.creations.length, 1); +}); +test('scope, model and permission identity isolate or replace sessions', async t => { + const f = poolFixture(t); const a = await acquireCursorRuntime(f.options); a.release(); + const other = await acquireCursorRuntime({ ...f.options, key: { ...f.options.key, scopeKey: 'other-' + f.options.key.scopeKey } }); + assert.notEqual(a.session, other.session); other.release(); + const changed = await acquireCursorRuntime({ ...f.options, key: { ...f.options.key, model: 'm2', permissions: 'auto' } }); + assert.notEqual(changed.session, a.session); assert.equal(f.sessions[0]!.retirements, 1); + changed.release(); +}); +test('generation and caller admission checks run before stale replacement side effects', async t => { + const f = poolFixture(t); const a = await acquireCursorRuntime(f.options); a.release(); + f.rejectAdmission(); + await assert.rejects(acquireCursorRuntime({ ...f.options, forceNew: true }), /ownership invalidated/); + assert.equal(f.sessions[0]!.alive, true); + const g = poolFixture(t); const first = await acquireCursorRuntime(g.options); first.release(); g.invalidate(); + await assert.rejects(acquireCursorRuntime(g.options), /ownership invalidated/); + const second = await acquireCursorRuntime({ ...g.options, persistenceOwner: { global: 1, scope: 0 } }); + assert.notEqual(first.session, second.session); assert.equal(g.sessions[0]!.retirements, 1); second.release(); +}); +test('release-before-idle retires and late cancel rejection cannot kill the next borrower', async t => { + const f = poolFixture(t); const a = await acquireCursorRuntime(f.options); + f.sessions[0]!.idle = false; a.release(); + assert.equal(f.sessions[0]!.retirements, 1); + const b = await acquireCursorRuntime(f.options); + let reject!: (error: Error) => void; + f.sessions[1]!.cancelImpl = () => new Promise((_resolve, no) => { reject = no; }); + const cancellation = b.cancel(); b.release(); + const c = await acquireCursorRuntime(f.options); + reject(new Error('late cancel failure')); await cancellation; + assert.equal(f.sessions[1]!.alive, true); assert.equal(c.session, b.session); c.release(); +}); +test('startup expiry aborts the creating child and late completion cannot remove a replacement', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; t.mock.method(performance, 'now', () => now); + const f = poolFixture(t); const started = deferred(); let release!: (session: AcpSession) => void; + const old = new PoolSession(); f.sessions.push(old); + let signal: AbortSignal | undefined; + const pending = acquireCursorRuntime({ ...f.options, waitMs: 20, createSession: async input => { + signal = input.signal; signal!.addEventListener('abort', () => old.retire(), { once: true }); started.resolve(); + return new Promise(resolve => { release = resolve; }); + } }); + const rejected = assert.rejects(pending, /timed out/); + await started.promise; now = 20; t.mock.timers.tick(20); await rejected; + assert.equal(signal?.aborted, true); assert.equal(old.retirements, 1); + const replacement = await acquireCursorRuntime(f.options); + release(old as unknown as AcpSession); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(replacement.runtime.alive, true); replacement.release(); + const reused = await acquireCursorRuntime(f.options); assert.equal(reused.session, replacement.session); reused.release(); +}); +test('force-new creation replacement and caller abort close only the captured attempt', async t => { + const f = poolFixture(t); const started = deferred(); const controller = new AbortController(); + let release!: (session: AcpSession) => void; + const old = new PoolSession(); f.sessions.push(old); + const pending = acquireCursorRuntime({ ...f.options, signal: controller.signal, createSession: async input => { + input.signal!.addEventListener('abort', () => old.retire(), { once: true }); started.resolve(); + return new Promise(resolve => { release = resolve; }); + } }); + const rejected = assert.rejects(pending, /replaced|aborted/); + await started.promise; + const replacement = await acquireCursorRuntime({ ...f.options, forceNew: true }); + controller.abort(); await rejected; + release(old as unknown as AcpSession); await new Promise(resolve => setImmediate(resolve)); + assert.equal(old.retirements, 1); assert.equal(replacement.runtime.alive, true); replacement.release(); +}); +test('busy acquisition abort removes its waiter without cancelling the current lease', async t => { + const f = poolFixture(t); const current = await acquireCursorRuntime(f.options); const controller = new AbortController(); + const pending = acquireCursorRuntime({ ...f.options, signal: controller.signal }); + const rejected = assert.rejects(pending, /aborted/); controller.abort(); await rejected; + assert.equal(f.sessions[0]!.alive, true); current.release(); + const next = await acquireCursorRuntime(f.options); assert.equal(next.session, current.session); next.release(); +}); +test('repeated wakeups use the original total acquisition deadline', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; t.mock.method(performance, 'now', () => now); + const f = poolFixture(t); const first = await acquireCursorRuntime(f.options); + const waiting = acquireCursorRuntime({ ...f.options, waitMs: 20 }); + let error: unknown; + void waiting.catch(value => { error = value; }); + now = 10; t.mock.timers.tick(10); first.release(); + const second = await acquireCursorRuntime(f.options); + now = 19; t.mock.timers.tick(9); second.release(); + const third = await acquireCursorRuntime(f.options); + now = 20; t.mock.timers.tick(1); + await new Promise(resolve => setImmediate(resolve)); + assert.ok(error instanceof Error); + await assert.rejects(waiting, /timed out/); + assert.equal(f.sessions[0]!.alive, true); third.release(); +}); +test('pool deadline aborts the actual factory while its initialize RPC is held', { timeout: 5000 }, async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; t.mock.method(performance, 'now', () => now); + const f = poolFixture(t), child = factoryFixture(t); child.holdInitialize(); + const pending = acquireCursorRuntime({ ...f.options, waitMs: 20, createSession: options => createCursorSession({ + ...options, spawnImpl: child.options.spawnImpl, ownedProcessOptions: child.options.ownedProcessOptions, + }) }); + const rejected = assert.rejects(pending, /timed out/); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(child.wire[0]!['method'], 'initialize'); + now = 20; t.mock.timers.tick(20); await rejected; + assert.equal(child.kills, 1); + assert.equal(child.child.exitCode, 143); +}); +test('invalid new options cannot discard an existing healthy entry', async t => { + const f = poolFixture(t); const lease = await acquireCursorRuntime(f.options); lease.release(); + await assert.rejects(acquireCursorRuntime({ ...f.options, forceNew: true, promptTimeoutMs: 0 }), /acp_invalid_timeout/); + assert.equal(f.sessions[0]!.alive, true); + assert.equal(f.creations.length, 1); +}); +test('missing logical admission cannot force-new a healthy generation-valid entry', async t => { + const f = poolFixture(t); const first = await acquireCursorRuntime(f.options); first.release(); + const missing: Partial = { ...f.options, forceNew: true }; + delete missing.canAcquire; + await assert.rejects(acquireCursorRuntime(missing as CursorAcquireOptions), /admission|ownership/); + assert.equal(f.sessions[0]!.alive, true); + assert.equal(f.creations.length, 1); +}); +test('a backpressured real-session refusal between release and borrow cannot be reused', { timeout: 5000 }, async t => { + const f = poolFixture(t), children: ReturnType[] = []; + const opts: CursorAcquireOptions = { ...f.options, createSession: options => { + const child = factoryFixture(t); children.push(child); + return createCursorSession({ ...options, spawnImpl: child.options.spawnImpl, ownedProcessOptions: child.options.ownedProcessOptions }); + } }; + const first = await acquireCursorRuntime(opts); first.release(); + children[0]!.holdWrites(); + children[0]!.child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 'unexpected', method: 'session/request_permission', params: {} }) + '\n'); + assert.equal(first.session.alive, true); assert.equal(first.session.idle, false); + const second = await acquireCursorRuntime(opts); + assert.notEqual(second.session, first.session); + assert.equal(children[0]!.kills, 1); + second.release(); await second.session.close(); +}); +test('a new generation supersedes held creation without inheriting its wait', { timeout: 5000 }, async t => { + const f = poolFixture(t), started = deferred(); + let release!: (session: AcpSession) => void; + const old = new PoolSession(); f.sessions.push(old); + const pending = acquireCursorRuntime({ ...f.options, waitMs: 100, createSession: async options => { + options.signal!.addEventListener('abort', () => old.retire(), { once: true }); started.resolve(); + return new Promise(resolve => { release = resolve; }); + } }); + const rejected = assert.rejects(pending, /replaced|invalidated|superseded|timed out/); + await started.promise; f.invalidate(); + const next = await acquireCursorRuntime({ ...f.options, persistenceOwner: { global: 1, scope: 0 } }); + await rejected; + release(old as unknown as AcpSession); await new Promise(resolve => setImmediate(resolve)); + assert.equal(old.retirements, 1); assert.equal(next.runtime.alive, true); next.release(); +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} diff --git a/tests/unit/cursor-acp-retirement.test.ts b/tests/unit/cursor-acp-retirement.test.ts new file mode 100644 index 000000000..4119c2815 --- /dev/null +++ b/tests/unit/cursor-acp-retirement.test.ts @@ -0,0 +1,225 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import type { AcpSession } from '../../src/agent/runtime/acp/session.ts'; +import { acquireCursorRuntime, poolStats, type CursorAcquireOptions, type CursorLease } from '../../src/agent/runtime-pool.ts'; + +function deferred() { + let resolve!: () => void, reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const checkpoint = () => new Promise(resolve => setImmediate(resolve)); +let sequence = 0; +class FencedSession { + child = new EventEmitter(); + alive = true; idle = true; + nativeSessionId = `retirement-session-${++sequence}`; + closeCalls = 0; retireCalls = 0; cancelCalls = 0; dispatched = 0; + closeGate = deferred(); closeStarted = deferred(); + retire(): void { this.retireCalls++; this.alive = false; this.idle = false; } + close(): Promise { this.closeCalls++; this.closeStarted.resolve(); return this.closeGate.promise; } + async cancel(): Promise { this.cancelCalls++; } + async prompt(): Promise { assert.equal(this.alive, true); this.dispatched++; } + exit(): void { this.alive = false; this.child.emit('exit', 0); } +} +function fixture(t: TestContext) { + const sessions: FencedSession[] = []; + let generation = 0; + const options: CursorAcquireOptions = { + key: { scopeKey: `retirement-scope-${++sequence}`, cwd: process.cwd(), model: 'a', effort: '', permissions: 'auto' }, + binary: 'fake-cursor', env: {}, promptTimeoutMs: 1000, waitMs: 1000, + persistenceOwner: { global: 0, scope: 0 }, isCurrentOwner: owner => owner.global === generation, + canAcquire: () => true, + createSession: async () => { const session = new FencedSession(); sessions.push(session); return session as unknown as AcpSession; }, + }; + t.after(() => { for (const s of sessions) { s.exit(); s.closeGate.resolve(); } }); + return { options, sessions, reset: () => ++generation, + changed: (model: string): CursorAcquireOptions => ({ ...options, key: { ...options.key, model } }) }; +} +async function dispatch(lease: CursorLease): Promise { + // Only the pool boundary is under test: no provider/CLI, but actual observable prompt admission. + await (lease.session as unknown as FencedSession).prompt(); +} + +test('idle reaper and repeated release retain one close fence until observed completion', { timeout: 5000 }, async t => { + let sweep!: () => void; + const interval = globalThis.setInterval; + t.mock.method(globalThis, 'setInterval', (callback: () => void, delay: number) => { + sweep = callback; return interval(callback, delay); + }); + let now = 0; t.mock.method(Date, 'now', () => now); + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + a.release(); now = 15 * 60_000; sweep(); sweep(); a.release(); + assert.equal(old.closeCalls, 1); assert.equal(poolStats().size, 1); + let admitted = false; + const next = acquireCursorRuntime(f.options).then(async lease => { admitted = true; await dispatch(lease); return lease; }); + await checkpoint(); assert.equal(admitted, false); assert.equal(f.sessions.length, 1); + old.closeGate.resolve(); const b = await next; + assert.equal(f.sessions[1]!.dispatched, 1); b.release(); +}); + +test('different-key replacement cannot become ready or dispatch before old close resolves', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + a.release(); + let admitted = false; + const next = acquireCursorRuntime(f.changed('b')).then(async lease => { admitted = true; await dispatch(lease); return lease; }); + await old.closeStarted.promise; await checkpoint(); + assert.equal(admitted, false); assert.equal(f.sessions.length, 1); assert.equal(poolStats().size, 1); + old.closeGate.resolve(); const b = await next; + assert.equal(f.sessions[1]!.dispatched, 1); b.release(); +}); + +test('rejected close keeps the fence despite alive=false; actual captured exit admits replacement', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + const retiring = a.retire(new Error('cleanup uncertain')); + const rejected = assert.rejects(retiring, /reap failed/); + assert.equal(old.alive, false); a.release(); + old.closeGate.reject(new Error('reap failed')); await rejected; + let admitted = false; + const next = acquireCursorRuntime(f.changed('b')).then(async lease => { admitted = true; await dispatch(lease); return lease; }); + await checkpoint(); assert.equal(admitted, false); assert.equal(poolStats().size, 1); assert.equal(old.closeCalls, 1); + old.exit(); const b = await next; + assert.equal(f.sessions[1]!.dispatched, 1); assert.equal(old.child.listenerCount('exit'), 0); b.release(); +}); + +test('normal different-key change waits for full logical release even after protocol idle or child exit', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + const next = acquireCursorRuntime(f.changed('b')); + await checkpoint(); assert.equal(old.closeCalls, 0); assert.equal(f.sessions.length, 1); + old.exit(); await checkpoint(); + assert.equal(old.closeCalls, 0); assert.equal(f.sessions.length, 1); + a.release(); await old.closeStarted.promise; old.closeGate.resolve(); + const b = await next; await dispatch(b); b.release(); +}); + +test('parallel different-key waiters rescan the scope after close and cannot double-admit', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + a.release(); + const admitted: CursorLease[] = []; + const bPending = acquireCursorRuntime(f.changed('b')).then(async lease => { admitted.push(lease); await dispatch(lease); return lease; }); + const cPending = acquireCursorRuntime(f.changed('c')).then(async lease => { admitted.push(lease); await dispatch(lease); return lease; }); + await old.closeStarted.promise; old.closeGate.resolve(); await checkpoint(); + assert.equal(admitted.length, 1); assert.equal(f.sessions.length, 2); + assert.equal(f.sessions[1]!.closeCalls, 0); assert.equal(f.sessions[1]!.dispatched, 1); + admitted[0]!.release(); await f.sessions[1]!.closeStarted.promise; + assert.equal(f.sessions.length, 2); + f.sessions[1]!.closeGate.resolve(); const leases = await Promise.all([bPending, cPending]); + assert.equal(f.sessions.length, 3); assert.equal(f.sessions[2]!.dispatched, 1); + for (const lease of leases) lease.release(); +}); + +for (const invalidation of ['forceNew', 'generation'] as const) { + test(`${invalidation} may retire a busy stale entry but preserves the physical fence`, { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + const next = acquireCursorRuntime(invalidation === 'forceNew' ? { ...f.options, forceNew: true } + : { ...f.options, persistenceOwner: { global: f.reset(), scope: 0 } }); + await old.closeStarted.promise; await checkpoint(); assert.equal(f.sessions.length, 1); + old.closeGate.resolve(); const b = await next; + await a.retire(); a.release(); await a.cancel(); + assert.equal(f.sessions[1]!.closeCalls, 0); assert.equal(f.sessions[1]!.cancelCalls, 0); + await dispatch(b); b.release(); + }); +} + +test('released lease A cannot retire the reused borrower B', async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options); a.release(); + const b = await acquireCursorRuntime(f.options); assert.equal(b.session, a.session); + await a.retire(); await a.cancel(); a.release(); + assert.equal(f.sessions[0]!.retireCalls, 0); assert.equal(f.sessions[0]!.closeCalls, 0); + await dispatch(b); b.release(); +}); + +test('retire synchronously fences reuse and repeated retire/release close exactly once', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + const first = a.retire(); const again = a.retire(); a.release(); a.release(); + assert.equal(old.alive, false); assert.equal(old.closeCalls, 1); + const next = acquireCursorRuntime(f.options); await checkpoint(); assert.equal(f.sessions.length, 1); + old.closeGate.resolve(); await Promise.all([first, again]); const b = await next; b.release(); +}); + +test('total monotonic deadline covers busy release then close wait without erasing its fence', { timeout: 5000 }, async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; t.mock.method(performance, 'now', () => now); + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + const next = acquireCursorRuntime({ ...f.changed('b'), waitMs: 20 }); + const rejected = assert.rejects(next, /timed out/); + now = 12; t.mock.timers.tick(12); a.release(); await old.closeStarted.promise; + now = 20; t.mock.timers.tick(8); await rejected; + assert.equal(poolStats().size, 1); assert.equal(f.sessions.length, 1); assert.equal(old.closeCalls, 1); + old.closeGate.resolve(); await checkpoint(); + const b = await acquireCursorRuntime(f.options); await dispatch(b); b.release(); +}); + +test('abort during close wait only removes that waiter, not fence or other acquisition', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; a.release(); + const controller = new AbortController(); + const aborted = acquireCursorRuntime({ ...f.changed('b'), signal: controller.signal }); + const rejected = assert.rejects(aborted, /aborted/); + await old.closeStarted.promise; + const next = acquireCursorRuntime(f.changed('c')); + controller.abort(); await rejected; + assert.equal(old.closeCalls, 1); assert.equal(old.cancelCalls, 0); assert.equal(poolStats().size, 1); + old.closeGate.resolve(); const c = await next; await dispatch(c); c.release(); +}); + +test('abort while busy on a different key never interrupts the foreign logical owner', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + const controller = new AbortController(); + const next = acquireCursorRuntime({ ...f.changed('b'), signal: controller.signal }); + const rejected = assert.rejects(next, /aborted/); controller.abort(); await rejected; + assert.equal(old.closeCalls, 0); assert.equal(old.retireCalls, 0); assert.equal(old.cancelCalls, 0); + await dispatch(a); a.release(); +}); + +test('old close completion after captured exit cannot delete a same-key successor', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + const retiring = a.retire(); const next = acquireCursorRuntime(f.options); + old.exit(); const b = await next; + old.closeGate.resolve(); await retiring; a.release(); + await dispatch(b); b.release(); + const c = await acquireCursorRuntime(f.options); assert.equal(c.session, b.session); c.release(); +}); + +test('release-before-idle observes rejected retirement and preserves the fence for later waiters', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + old.idle = false; + assert.equal(a.release(), undefined); assert.equal(old.closeCalls, 1); + old.closeGate.reject(new Error('release reap failed')); await checkpoint(); + const controller = new AbortController(); + const refused = acquireCursorRuntime({ ...f.options, signal: controller.signal }); + const rejected = assert.rejects(refused, /aborted/); + await checkpoint(); assert.equal(f.sessions.length, 1); assert.equal(poolStats().size, 1); + controller.abort(); await rejected; a.release(); assert.equal(old.closeCalls, 1); + old.exit(); const b = await acquireCursorRuntime(f.options); await dispatch(b); b.release(); +}); + +test('synchronous close throw is a rejected retirement, never synchronous release failure or eviction', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + t.mock.method(old, 'close', () => { old.closeCalls++; throw new Error('sync close failed'); }); + const retiring = a.retire(); a.release(); + await assert.rejects(retiring, /sync close failed/); + assert.equal(poolStats().size, 1); assert.equal(old.closeCalls, 1); + const next = acquireCursorRuntime(f.options); await checkpoint(); assert.equal(f.sessions.length, 1); + old.exit(); const b = await next; await dispatch(b); b.release(); +}); + +test('cancel failure uses the same observed fence and release cannot make it reusable', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; + t.mock.method(old, 'cancel', async () => { old.cancelCalls++; throw new Error('cancel failed'); }); + const cancelling = a.cancel(); + const rejected = assert.rejects(cancelling, /cancel reap failed/); + await old.closeStarted.promise; a.release(); + old.closeGate.reject(new Error('cancel reap failed')); await rejected; + const next = acquireCursorRuntime(f.options); await checkpoint(); + assert.equal(f.sessions.length, 1); assert.equal(old.closeCalls, 1); + old.exit(); const b = await next; await dispatch(b); b.release(); +}); + +test('ownership invalidation during a retirement wait cannot launch after close wakes it', { timeout: 5000 }, async t => { + const f = fixture(t), a = await acquireCursorRuntime(f.options), old = f.sessions[0]!; a.release(); + const next = acquireCursorRuntime(f.changed('b')); + const rejected = assert.rejects(next, /ownership invalidated/); + await old.closeStarted.promise; f.reset(); old.closeGate.resolve(); await rejected; + assert.equal(f.sessions.length, 1); assert.equal(old.dispatched, 0); +}); diff --git a/tests/unit/cursor-acp-session.test.ts b/tests/unit/cursor-acp-session.test.ts new file mode 100644 index 000000000..79d324d91 --- /dev/null +++ b/tests/unit/cursor-acp-session.test.ts @@ -0,0 +1,330 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { AcpSession, type AcpSessionOptions, type AcpTurnOwner } from '../../src/agent/runtime/acp/session.ts'; +import { RuntimeRequests } from '../../src/agent/runtime/requests.ts'; +import type { RpcFrame } from '../../src/agent/runtime/acp/wire.ts'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +type Wire = Record; +function sessionFixture(t: TestContext, options: Partial = {}) { + const writes: Wire[] = [], kills: string[] = [], failures: string[] = []; + const changes = new EventEmitter(); + const child = Object.assign(new EventEmitter(), { pid: 43001, exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, stdout: new PassThrough(), stderr: new PassThrough(), stdin: new Writable() }); + const configs = (model = 'm1') => [{ id: 'model', name: 'Model', category: 'model', type: 'select', currentValue: model, + options: [{ value: 'm1', name: 'M1' }, { value: 'm2', name: 'M2' }] }]; + const send = (value: unknown) => child.stdout.write(JSON.stringify(value) + '\n'); + const reply = (id: unknown, result: unknown) => setImmediate(() => send({ jsonrpc: '2.0', id, result })); + const update = (kind = 'agent_message_chunk') => ({ jsonrpc: '2.0', method: 'session/update', params: { + sessionId: 'native-session', update: { sessionUpdate: kind, content: { type: 'text', text: 'message' } }, + } }); + let onPrompt: (message: Wire) => void = message => setImmediate(() => { + send(update()); send({ jsonrpc: '2.0', id: message['id'], result: { stopReason: 'end_turn' } }); + }); + let onCancel: () => void = () => {}; + let onReply: (message: Wire) => void = () => {}; + let onInitialize: (message: Wire) => void = message => reply(message['id'], { + protocolVersion: 1, authMethods: [{ id: 'cursor_login' }], agentCapabilities: { loadSession: true }, + }); + let onNew: (message: Wire) => void = message => reply(message['id'], { sessionId: 'native-session', configOptions: configs() }); + let onLoad: (message: Wire) => void = message => setImmediate(() => { + send(update('user_message_chunk')); send(update()); + send({ jsonrpc: '2.0', id: message['id'], result: { configOptions: configs() } }); + }); + child.stdin = new Writable({ write(chunk, _encoding, callback) { + const message = JSON.parse(String(chunk)) as Wire; + writes.push(message); changes.emit('change'); + switch (message['method']) { + case 'initialize': onInitialize(message); break; + case 'authenticate': reply(message['id'], {}); break; + case 'session/new': onNew(message); break; + case 'session/load': onLoad(message); break; + case 'session/set_config_option': reply(message['id'], { configOptions: configs(message['params'].value) }); break; + case 'session/prompt': onPrompt(message); break; + case 'session/cancel': onCancel(); break; + default: onReply(message); + } + callback(); + changes.emit('change'); + } }); + const exit = () => { + if (child.exitCode !== null) return; + child.exitCode = 143; child.emit('exit', 143, null); child.emit('close', 143, null); + }; + const session = new AcpSession(child as unknown as ChildProcessWithoutNullStreams, { + permissions: 'auto', promptTimeoutMs: 10_000, requestTimeoutMs: 10_000, + controlTimeoutMs: 50, drainTimeoutMs: 50, registry: new RuntimeRequests(), + ownedProcessOptions: { terminateTree: (_pid, signal) => { kills.push(signal ?? 'SIGTERM'); queueMicrotask(exit); } }, + failed: error => failures.push(error.message), ...options, + }); + const owner: AcpTurnOwner = { binding: { runId: 'run', sessionId: 'chat', scope: 'scope', turnId: 'turn' }, + isCurrent: () => true, emit: body => ({ version: 1, seq: 1, runId: 'run', sessionId: 'chat', scope: 'scope', turnId: 'turn', ...body }) }; + t.after(async () => { await session.close(); changes.removeAllListeners(); }); + const waitFor = (predicate: () => boolean) => predicate() ? Promise.resolve() : new Promise(resolve => { + const check = () => { if (predicate()) { changes.off('change', check); resolve(); } }; changes.on('change', check); + }); + return { session, child, writes, kills, failures, owner, send, reply, update, exit, configs, waitFor, + start: () => session.start({ cwd: process.cwd(), authMethodId: 'cursor_login' }), + prompt: (consume: Parameters[2] = () => {}) => session.prompt([{ type: 'text', text: 'probe' }], owner, consume), + onPrompt: (handler: typeof onPrompt) => { onPrompt = handler; }, onCancel: (handler: typeof onCancel) => { onCancel = handler; }, + onReply: (handler: typeof onReply) => { onReply = handler; }, onInitialize: (handler: typeof onInitialize) => { onInitialize = handler; }, + onLoad: (handler: typeof onLoad) => { onLoad = handler; }, onNew: (handler: typeof onNew) => { onNew = handler; }, + }; +} + +test('session performs init/auth/new, refreshes config and reuses one session across turns', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); + assert.deepEqual(f.writes.map(x => x['method']), ['initialize', 'authenticate', 'session/new']); + assert.equal(f.session.nativeSessionId, 'native-session'); + await f.session.setConfigOption('model', 'm2'); + assert.equal((f.session.getConfigOptions() as Wire[])[0]!['currentValue'], 'm2'); + const notifications: RpcFrame[] = []; + for (let i = 0; i < 3; i++) { + assert.deepEqual(await f.prompt(frame => { notifications.push(frame); }), { stopReason: 'end_turn' }); + assert.equal(f.session.idle, true); + } + assert.equal(notifications.length, 3); + assert.equal(f.writes.filter(x => x['method'] === 'session/new').length, 1); + assert.equal(f.kills.length, 0); +}); +test('load waits for an actual reply and never sends replay into a live consumer', { timeout: 5000 }, async t => { + const f = sessionFixture(t); let loadId: unknown; + f.onLoad(message => { loadId = message['id']; f.send(f.update()); }); + const start = f.session.start({ cwd: process.cwd(), authMethodId: 'cursor_login', resumeSessionId: 'native-session' }); + void start.catch(() => undefined); // keep the original rejection observed while the reply is held + await f.waitFor(() => loadId !== undefined); + assert.equal(f.session.idle, false); + await assert.rejects(f.prompt(), /acp_prompt_unavailable/); + f.reply(loadId, { configOptions: f.configs() }); await start; + let updates = 0; + await f.prompt(() => { updates++; }); + assert.equal(updates, 1); + assert.equal(f.writes.some(x => x['method'] === 'session/new'), false); +}); +test('unsupported protocol/auth/load retire without a prompt', { timeout: 5000 }, async t => { + for (const mode of ['version', 'auth', 'load'] as const) { + const f = sessionFixture(t); + f.onInitialize(message => f.reply(message['id'], { protocolVersion: mode === 'version' ? 2 : 1, + authMethods: mode === 'auth' ? [] : [{ id: 'cursor_login' }], agentCapabilities: { loadSession: mode !== 'load' } })); + await assert.rejects(f.session.start({ cwd: process.cwd(), authMethodId: 'cursor_login', resumeSessionId: 'native-session' }), + /acp_(protocol_unsupported|auth_method_unavailable|resume_unsupported)/); + assert.equal(f.session.alive, false); + assert.equal(f.writes.some(x => x['method'] === 'session/prompt'), false); + assert.equal(f.kills.length, 1); + } +}); +test('stderr is drained continuously without retaining text or blocking startup', { timeout: 5000 }, async t => { + const f = sessionFixture(t); + f.child.stderr.write(Buffer.alloc(1024 * 1024, 120)); + await f.start(); await f.prompt(); + assert.equal(f.session.stderrBytes, 1024 * 1024); + assert.equal(f.child.stderr.readableLength, 0); + f.child.stderr.emit('error', new Error('private-stderr')); + assert.equal(f.session.alive, false); + assert.equal(f.failures[0], 'acp_stderr_error'); +}); +test('permission callbacks have an owner before write returns and bypass stalled notification work', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); + const held = deferred(), permissionSeen = deferred(); let promptId: unknown; + f.onPrompt(message => { + promptId = message['id']; f.send(f.update()); + f.send({ jsonrpc: '2.0', id: 'permission', method: 'session/request_permission', params: { + sessionId: 'native-session', toolCall: { toolCallId: 'tool', title: null }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + } }); + }); + f.onReply(message => { if (message['id'] === 'permission') permissionSeen.resolve(); }); + const prompt = f.prompt(async () => held.promise); + await permissionSeen.promise; + assert.equal(f.writes.find(x => x['id'] === 'permission')!['result'].outcome.optionId, 'allow'); + assert.equal(f.session.idle, false); + held.resolve(); f.reply(promptId, { stopReason: 'end_turn' }); + await prompt; +}); +test('cancel can arrive before the prompt handle is assigned and reuse follows full drain', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); + let cancellation: Promise | null = null, promptId: unknown; + f.onPrompt(message => { promptId = message['id']; cancellation = f.session.cancel(); }); + f.onCancel(() => f.reply(promptId, { stopReason: 'cancelled' })); + const result = f.prompt(); + await f.waitFor(() => cancellation !== null); + await cancellation; + assert.deepEqual(await result, { stopReason: 'cancelled' }); + assert.equal(f.session.idle, true); + const cancel = f.writes.find(x => x['method'] === 'session/cancel')!; + assert.equal(Object.hasOwn(cancel, 'id'), false); + f.onPrompt(message => f.reply(message['id'], { stopReason: 'end_turn' })); + await f.prompt(); assert.equal(f.session.idle, true); +}); +test('cancel deadline retires pending work and does not admit a second prompt', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + f.onPrompt(() => {}); + const prompt = f.prompt(); const cancel = f.session.cancel(); + assert.equal(f.session.cancel(), cancel); + t.mock.timers.tick(50); + await assert.rejects(cancel, /acp_cancel_timeout/); + await assert.rejects(prompt, /acp_cancel_timeout/); + await assert.rejects(f.prompt(), /acp_prompt_unavailable/); + assert.equal(f.kills.length, 1); +}); +for (const kind of ['content', 'permission'] as const) test(`post-terminal ${kind} in the same chunk cannot enter a new owner`, { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); + f.onPrompt(message => setImmediate(() => { + const late = kind === 'content' ? f.update() : { jsonrpc: '2.0', id: 'late', method: 'session/request_permission', params: {} }; + f.child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message['id'], result: { stopReason: 'end_turn' } }) + '\n' + JSON.stringify(late) + '\n'); + })); + await assert.rejects(f.prompt(), /acp_(content_without_active_turn|request_after_terminal)/); + assert.equal(f.session.alive, false); + assert.equal(f.writes.some(x => x['id'] === 'late'), false); +}); +test('notification drain has its own deadline after the prompt reply', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const started = deferred(); + const prompt = f.prompt(async (_frame, signal) => { started.resolve(); await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true })); }); + await started.promise; + await new Promise(resolve => setImmediate(resolve)); // reply and its microtasks have run + t.mock.timers.tick(50); + await assert.rejects(prompt, /acp_drain_timeout/); + assert.equal(f.session.alive, false); +}); +test('natural completion racing explicit cancellation retires instead of reporting a cancel acknowledgement', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); let id: unknown; + f.onPrompt(message => { id = message['id']; }); + f.onCancel(() => f.reply(id, { stopReason: 'end_turn' })); + const prompt = f.prompt(); + const cancel = f.session.cancel(); + await assert.rejects(cancel, /acp_cancel_raced_completion/); + await assert.rejects(prompt, /acp_cancel_raced_completion/); + assert.equal(f.session.alive, false); +}); +test('early child exit rejects the active turn and cleanup is idempotent', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); f.onPrompt(() => {}); + const prompt = f.prompt(); f.exit(); + await assert.rejects(prompt, /acp_child_exit/); + await f.session.close(); await f.session.close(); + assert.equal(f.session.idle, false); + assert.equal(f.failures.length, 1); +}); +test('only the exact v1 stop-reason enum is accepted without coercion', { timeout: 5000 }, async t => { + const good = sessionFixture(t); await good.start(); + for (const stopReason of ['end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled']) { + good.onPrompt(message => good.reply(message['id'], { stopReason })); + assert.deepEqual(await good.prompt(), { stopReason }); + } + for (const stopReason of ['max_turns', ['end_turn'], {}, undefined, null]) { + const bad = sessionFixture(t); await bad.start(); + bad.onPrompt(message => bad.reply(message['id'], { stopReason })); + await assert.rejects(bad.prompt(), /acp_invalid_stop_reason/); + } +}); + +test('terminal cancels a pending human decision and drains its cancellation reply before reuse', { timeout: 5000 }, async t => { + const registry = new RuntimeRequests(), f = sessionFixture(t, { permissions: 'safe', registry }); + await f.start(); let promptId: unknown; + f.onPrompt(message => { + promptId = message['id']; + f.send({ jsonrpc: '2.0', id: 'human', method: 'session/request_permission', params: { + sessionId: 'native-session', toolCall: { toolCallId: 'tool', title: null }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + } }); + }); + const prompt = f.prompt(); + assert.equal(registry.list('chat').length, 1); + assert.equal(f.writes.some(x => x['id'] === 'human'), false); + f.send({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'end_turn' } }); + assert.equal(registry.list('chat').length, 0); + assert.equal(f.session.idle, false); + await prompt; + assert.deepEqual(f.writes.find(x => x['id'] === 'human')!['result'], { outcome: { outcome: 'cancelled' } }); + assert.equal(f.session.idle, true); assert.equal(f.kills.length, 0); + f.onPrompt(message => f.reply(message['id'], { stopReason: 'end_turn' })); + await f.prompt(); +}); +test('permission arriving while pre-terminal notification work drains retires without a grant', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); + const draining = deferred(); let abortSeen = false; + const prompt = f.prompt(async (_frame, signal) => { + draining.resolve(); + await new Promise(resolve => signal.addEventListener('abort', () => { abortSeen = true; resolve(); }, { once: true })); + }); + const rejected = assert.rejects(prompt, /acp_request_after_terminal/); + await draining.promise; await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.session.idle, false); assert.equal(f.session.alive, true); + f.send({ jsonrpc: '2.0', id: 'during-drain', method: 'session/request_permission', params: {} }); + await rejected; + assert.equal(abortSeen, true); assert.equal(f.kills.length, 1); + assert.equal(f.writes.some(x => x['id'] === 'during-drain'), false); +}); +test('foreign session notification retires the active owner without consuming its content', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); let consumed = 0; + f.onPrompt(message => { + const update = f.update(); update.params.sessionId = 'foreign-session'; + f.child.stdout.write(JSON.stringify(update) + '\n' + + JSON.stringify({ jsonrpc: '2.0', id: message['id'], result: { stopReason: 'end_turn' } }) + '\n'); + }); + await assert.rejects(f.prompt(() => { consumed++; }), /acp_frame_hook_failed/); + assert.equal(consumed, 0); assert.equal(f.kills.length, 1); +}); +test('configuration metadata refreshes snapshots without becoming live turn content', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); let consumed = 0; + f.onPrompt(message => { + f.send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'native-session', + update: { sessionUpdate: 'config_option_update', configOptions: f.configs('m2') } } }); + f.reply(message['id'], { stopReason: 'end_turn' }); + }); + await f.prompt(() => { consumed++; }); + assert.equal((f.session.getConfigOptions() as Wire[])[0]!['currentValue'], 'm2'); + assert.equal(consumed, 0); assert.equal(f.session.idle, true); +}); +test('explicit replay content is suppressed during an otherwise active live turn', { timeout: 5000 }, async t => { + const f = sessionFixture(t); await f.start(); const consumed: RpcFrame[] = []; + f.onPrompt(message => { + const replay: Wire = f.update(); replay.params._meta = { isReplay: true }; + replay.params.update.content.text = 'old'; f.send(replay); + f.send(f.update()); f.reply(message['id'], { stopReason: 'end_turn' }); + }); + await f.prompt(frame => { consumed.push(frame); }); + assert.equal(consumed.length, 1); + assert.equal((consumed[0]!.params as Wire)['update'].content.text, 'message'); + assert.equal(f.session.idle, true); +}); +for (const load of [false, true]) test(`malformed ${load ? 'load' : 'new'} config response retires once before any prompt`, { timeout: 5000 }, async t => { + const f = sessionFixture(t); + const setup = (message: Wire) => f.reply(message['id'], { sessionId: 'native-session', configOptions: [{ type: 'select', id: 'model' }] }); + f.onNew(setup); f.onLoad(setup); + await assert.rejects(f.session.start({ cwd: process.cwd(), authMethodId: 'cursor_login', + ...(load ? { resumeSessionId: 'native-session' } : {}) }), /acp_response_observer_failed/); + await f.session.close(); + assert.equal(f.session.alive, false); assert.equal(f.kills.length, 1); assert.equal(f.child.exitCode, 143); + assert.equal(f.writes.some(x => x['method'] === 'session/prompt'), false); + await assert.rejects(f.prompt(), /acp_prompt_unavailable/); +}); +test('executable callback during load replay is refused before readiness and never opens a decision', { timeout: 5000 }, async t => { + const registry = new RuntimeRequests(), f = sessionFixture(t, { registry }); + let loadId: unknown; + f.onLoad(message => { + loadId = message['id']; + f.send({ jsonrpc: '2.0', id: 'replay-action', method: 'session/request_permission', params: { + sessionId: 'native-session', toolCall: { toolCallId: 'old-tool', title: null }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + } }); + }); + const start = f.session.start({ cwd: process.cwd(), authMethodId: 'cursor_login', resumeSessionId: 'native-session' }); + void start.catch(() => undefined); + await f.waitFor(() => f.writes.some(x => x['id'] === 'replay-action')); + const refusal = f.writes.find(x => x['id'] === 'replay-action')!; + assert.equal(typeof refusal['error'].code, 'number'); assert.equal(Object.hasOwn(refusal, 'result'), false); + assert.equal(registry.list('chat').length, 0); assert.equal(f.session.idle, false); + f.reply(loadId, { configOptions: f.configs() }); await start; + assert.equal(f.session.idle, true); assert.equal(f.kills.length, 0); +}); diff --git a/tests/unit/cursor-acp-steer.test.ts b/tests/unit/cursor-acp-steer.test.ts new file mode 100644 index 000000000..1dd7c1d92 --- /dev/null +++ b/tests/unit/cursor-acp-steer.test.ts @@ -0,0 +1,406 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { spawn } from 'node:child_process'; +import { launchControlAgent } from '../fixtures/native-acp-control.mjs'; +import type { RemoteTarget } from '../../src/messaging/types.ts'; + +const root = fs.mkdtempSync(join(tmpdir(), 'cursor-main-control-')); +const config = await import('../../src/core/config.ts'); +test.mock.module('../../src/core/config.js', { namedExports: { ...config, + detectCli: () => ({ available: true, path: process.execPath }) } }); +const factory = await import('../../src/agent/runtime/acp/cursor-session.ts'); +const peers: ReturnType[] = []; +const sessions: Awaited>[] = []; +let mode = 'held'; +test.mock.module('../../src/agent/runtime/acp/cursor-session.js', { namedExports: { ...factory, + createCursorSession: async (input: Parameters[0]) => { + const peer = launchControlAgent(root, mode); peers.push(peer); + // Preserve the real factory/session/pool; only executable launch is substituted. + const spawnImpl = (() => peer.child) as typeof spawn; + const session = await factory.createCursorSession({ ...input, spawnImpl, + requestTimeoutMs: 2000, controlTimeoutMs: 600, drainTimeoutMs: 2000 }); + sessions.push(session); return session; + } } }); +const agent = await import('../../src/agent/spawn.ts'); +const gateway = await import('../../src/orchestrator/gateway.ts'); +const submissions: Parameters[] = []; +test.mock.module('../../src/orchestrator/gateway.js', { namedExports: { ...gateway, + submitMessage: (...args: Parameters) => { + submissions.push(args); return gateway.submitMessage(...args); + } } }); +const { steerHandler } = await import('../../src/cli/handlers-runtime.ts'); +const { withSessionScope } = await import('../../src/core/session-context.ts'); +const { db, insertMessage } = await import('../../src/core/db.ts'); +const { createChatSession } = await import('../../src/core/chat-sessions.ts'); +const { subscribe } = await import('../../src/core/event-bus.ts'); +const { poolStats } = await import('../../src/agent/runtime-pool.ts'); +const { clearGoalTimers } = await import('../../src/agent/lifecycle-handler.ts'); +let serial = 0; +type Event = { event: string; data: Record }; +test.beforeEach(t => { + mode = 'held'; submissions.length = 0; + config.settings.cli = 'cursor'; config.settings.workingDir = root; config.settings.projectDirs = [root]; + config.settings.permissions = 'auto'; config.settings.fallbackOrder = []; config.settings.activeOverrides = {}; + config.settings.perCli = { ...config.settings.perCli, cursor: { model: 'default', effort: '', transport: 'native' } }; + config.settings.memory = { ...config.settings.memory, enabled: false }; + config.settings.multiSession = { enabled: true, maxConcurrent: 4, midRunPolicy: 'steer' }; + fs.mkdirSync(join(config.JAW_HOME, 'prompts'), { recursive: true }); + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network in owned ACP test'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); +}); +test.afterEach(async () => { + for (const item of [...agent.messageQueue]) agent.removeQueuedMessage(item.id); + for (const session of sessions.splice(0)) await session.close(); + for (const peer of peers.splice(0)) { + if (peer.child.exitCode === null && peer.child.signalCode === null) peer.child.kill(); + await peer.exited; + } + clearGoalTimers(); assert.equal(poolStats().busy, 0); +}); +test.after(() => fs.rmSync(root, { recursive: true, force: true })); +function options(target?: RemoteTarget) { + const id = ++serial; + return { cli: 'cursor', model: 'default', effort: '', origin: 'web', + scopeKey: `control-scope-${id}`, chatSessionId: createChatSession(`control-chat-${id}`).id, requestId: `control-request-${id}`, + sysPrompt: 'OPERATIONAL_SENTINEL: keep output local.', _skipHistory: true, _isSmokeContinuation: true, + ...(target ? { target } : {}) }; +} +function rows(session: string) { + return db.prepare('SELECT role, content FROM messages WHERE session_id=? ORDER BY id').all(session); +} +async function start(t: test.TestContext, target?: RemoteTarget) { + const opts = options(target), events: Event[] = [], firstPartial = Promise.withResolvers(); + let exits = 0; + const off = subscribe(event => { + if (event.data['scope'] !== opts.scopeKey && event.data['sessionId'] !== opts.chatSessionId) return; + events.push(event); + if (event.event === 'agent_runtime' && event.data['kind'] === 'message') firstPartial.resolve(); + }); + t.after(off); + const run = agent.spawnAgent('ORIGINAL_A: continue old task.', { ...opts, lifecycle: { onExit: () => { exits++; } } }); + await firstPartial.promise; + const peer = peers.at(-1)!; + await peer.waitFor(value => value.kind === 'prompt'); + const owner = agent.activeMainProcesses.get(opts.scopeKey)!; + assert.equal(typeof owner.replaceTurn, 'function', 'actual main must install the replacement hook'); + assert.equal(owner.steerTurnInBand, undefined); + const slash = (instruction: string) => withSessionScope({ scope: opts.scopeKey, chatSessionId: opts.chatSessionId }, + () => steerHandler([instruction], { interface: 'web', locale: 'en' })); + const prompts = () => peer.records.filter(value => value.kind === 'prompt'); + const noFinal = () => { + assert.equal(exits, 0); + assert.equal(events.some(event => event.event === 'agent_done' || event.event === 'agent_runtime' && event.data['kind'] === 'turn-end'), false); + assert.equal(rows(opts.chatSessionId).some(row => (row as { role: string }).role === 'assistant'), false); + assert.equal(agent.activeMainProcesses.get(opts.scopeKey), owner); assert.equal(poolStats().busy, 1); + }; + return { opts, events, run, peer, owner, slash, prompts, noFinal, exits: () => exits }; +} + +test('actual /steer A -> B -> C preserves PID/SID/logical run and reinjects only accepted context', { timeout: 10000 }, async t => { + const f = await start(t); + const b = f.slash('REDIRECT_B: change to the second task.'); + await f.peer.waitFor(value => value.kind === 'cancel'); f.noFinal(); + const queued = await f.slash('QUEUED_ONLY: must not enter C context.'); + assert.equal(queued.ok, true); assert.equal(submissions.length, 1); + assert.equal(submissions[0]![1].midRunPolicy, 'followup'); + assert.equal(agent.messageQueue.length, 1); assert.equal(f.prompts().length, 1); + await assert.rejects(agent.steerAgent(f.opts.scopeKey, 'FOREIGN_ONLY', 'web', { chatSessionId: 'foreign-chat' }), /owner/i); + await f.peer.command('release-cancel'); assert.equal((await b).ok, true); + await f.peer.waitFor(value => value.kind === 'prompt' && value.index === 2); f.noFinal(); + const c = f.slash('CURRENT_C: stop old tasks and answer C only.'); + await f.peer.waitFor(value => value.kind === 'cancel' && value.index === 2); f.noFinal(); + await f.peer.command('release-cancel'); assert.equal((await c).ok, true); + await f.peer.waitFor(value => value.kind === 'prompt' && value.index === 3); f.noFinal(); + const [aWire, bWire, cWire] = f.prompts().map(value => String(value.prompt)); + assert.match(aWire!, /OPERATIONAL_SENTINEL/); assert.match(aWire!, /ORIGINAL_A/); + assert.match(bWire!, /\nPARTIAL_1_LATE_1\n<\/partial_output>/); + assert.match(bWire!, /\[Previous request - read-only context\]\nORIGINAL_A: continue old task\./); + assert.match(cWire!, /\[Accepted redirect - read-only context\]\nREDIRECT_B: change to the second task\./); + assert.ok(cWire!.endsWith('[Current Message]\nCURRENT_C: stop old tasks and answer C only.')); + for (const prompt of [bWire!, cWire!]) { + assert.equal(prompt.split('OPERATIONAL_SENTINEL').length - 1, 1); + assert.equal(prompt.split('ORIGINAL_A').length - 1, 1); + assert.doesNotMatch(prompt, /QUEUED_ONLY|FOREIGN_ONLY/); + } + const initial = rows(f.opts.chatSessionId)[0] as { role: string; content: string }; + assert.equal(initial.role, 'user'); assert.ok(initial.content.endsWith('\nORIGINAL_A: continue old task.')); + assert.deepEqual(rows(f.opts.chatSessionId).slice(1), [ + { role: 'user', content: 'REDIRECT_B: change to the second task.' }, + { role: 'user', content: 'CURRENT_C: stop old tasks and answer C only.' }, + ]); + // Prevent the intentionally queued independent followup from starting at final release. + agent.removeQueuedMessage(agent.messageQueue[0]!.id); + await f.peer.command('finish', { text: '_C_FINAL' }); + const result = await f.run.promise; + assert.equal(result.code, 0); assert.equal(result.text, 'PARTIAL_3_C_FINAL'); + assert.equal(new Set(f.prompts().map(value => value.pid)).size, 1); + assert.equal(new Set(f.prompts().map(value => value.sid)).size, 1); + const runtime = f.events.filter(event => event.event === 'agent_runtime'); + assert.equal(new Set(runtime.map(event => event.data['runId'])).size, 1); + assert.equal(runtime.filter(event => event.data['kind'] === 'turn-end').length, 1); + assert.equal(f.events.filter(event => event.event === 'agent_done').length, 1); + assert.equal(f.events.filter(event => event.event === 'steer_started').length, 2); + assert.equal(rows(f.opts.chatSessionId).length, 4); + assert.equal(agent.activeMainProcesses.has(f.opts.scopeKey), false); assert.equal(poolStats().busy, 0); + assert.equal(f.owner.replaceTurn, undefined); + assert.equal(f.exits(), 1); +}); + +test('fast Node B completion still records raw input/events before the sole main MESSAGE', { timeout: 10000 }, async t => { + mode = 'fast'; const f = await start(t); + const response = Promise.withResolvers(), written = Promise.withResolvers<() => void>(); + const stream = f.peer.child.stdin!, write = stream.write; + const sawResponse = (chunk: Buffer) => { if (String(chunk).includes('FAST_FINAL')) response.resolve(); }; + f.peer.child.stdout!.on('data', sawResponse); + t.after(() => f.peer.child.stdout!.off('data', sawResponse)); + t.mock.method(stream, 'write', function (...args: unknown[]) { + if (String(args[0]).includes('"method":"session/prompt"')) { + const callback = args.at(-1); + assert.equal(typeof callback, 'function'); + args[args.length - 1] = (...values: unknown[]) => { + let released = false; + written.resolve(() => { + if (released) return; + released = true; + Reflect.apply(callback as (...values: unknown[]) => void, undefined, values); + }); + }; + } + return Reflect.apply(write, stream, args); + }); + const redirect = f.slash('FAST_B_RAW'); + const releaseWrite = await written.promise; + t.after(releaseWrite); + await response.promise; f.noFinal(); + assert.equal(rows(f.opts.chatSessionId).length, 1, 'B must not persist before local dispatch completes'); + releaseWrite(); assert.equal((await redirect).ok, true); + const result = await f.run.promise; + await f.peer.waitFor(value => value.kind === 'prompt' && value.index === 2); + assert.equal(result.text, 'FAST_FINAL'); assert.equal(submissions.length, 0); + assert.deepEqual(rows(f.opts.chatSessionId).slice(1), [ + { role: 'user', content: 'FAST_B_RAW' }, { role: 'assistant', content: 'FAST_FINAL' }, + ]); + const input = f.events.findIndex(event => event.event === 'new_message' && event.data['content'] === 'FAST_B_RAW'); + const steer = f.events.findIndex(event => event.event === 'steer_started'); + const terminal = f.events.findIndex(event => event.event === 'agent_done'); + assert.ok(input >= 0 && input < steer && steer < terminal); + assert.equal(f.events.filter(event => event.event === 'agent_done').length, 1); + assert.equal(f.events.filter(event => event.event === 'agent_runtime' && event.data['kind'] === 'turn-end').length, 1); +}); + +for (const failure of ['missing-cancel', 'exit-on-cancel', 'commit'] as const) { + test(`actual slash ${failure} is fatal with zero queue/retry`, { timeout: 10000 }, async t => { + mode = failure === 'commit' ? 'fast' : failure; + const f = await start(t); let attempts = 0; + if (failure === 'commit') t.mock.method(insertMessage, 'run', () => { attempts++; throw new Error('PRIVATE_INSERT_FAILURE'); }); + await assert.rejects(f.slash('FAILED_B')); + const result = await f.run.promise; + assert.notEqual(result.code, 0); assert.equal(result.runtimeOutcome?.finalText, null); + if (failure === 'commit') await f.peer.waitFor(value => value.kind === 'prompt' && value.index === 2); + assert.equal(submissions.length, 0); assert.equal(agent.messageQueue.length, 0); + assert.equal(f.prompts().length, failure === 'commit' ? 2 : 1); + assert.equal(attempts, failure === 'commit' ? 1 : 0); + assert.equal(rows(f.opts.chatSessionId).some(row => (row as { content: string }).content === 'FAILED_B'), false); + assert.doesNotMatch(JSON.stringify(f.events), /PRIVATE_INSERT_FAILURE/); + assert.equal(agent.activeMainProcesses.has(f.opts.scopeKey), false); + }); +} + +test('captured Slack target is retained while foreign target/chat/scope cannot redirect or persist', { timeout: 10000 }, async t => { + const target: RemoteTarget = { channel: 'slack', targetKind: 'channel', peerKind: 'channel', + targetId: 'C_FIXTURE', threadId: '123.456', guildId: 'T_FIXTURE' }; + const f = await start(t, target); + for (const meta of [{ chatSessionId: 'foreign' }, { target: { ...target, targetId: 'C_FOREIGN' } }, + { target: { ...target, threadId: '999.000' } }, { remoteKey: 'foreign-binding' }]) { + await assert.rejects(agent.steerAgent(f.opts.scopeKey, 'FOREIGN_TARGET', 'web', meta), /owner/i); + } + assert.equal((await withSessionScope({ scope: 'foreign-scope', chatSessionId: f.opts.chatSessionId }, + () => steerHandler(['FOREIGN_SCOPE'], { interface: 'web', locale: 'en' }))).ok, false); + assert.equal(f.peer.records.some(value => value.kind === 'cancel'), false); + const accepted = agent.steerAgent(f.opts.scopeKey, 'REMOTE_B', 'web', { target, chatSessionId: f.opts.chatSessionId }); + await f.peer.waitFor(value => value.kind === 'cancel'); + // Caller mutation after admission must not alter the captured prompt/event target. + target.targetId = 'C_MUTATED'; + await f.peer.command('release-cancel'); assert.equal(await accepted, 'steered'); + await f.peer.waitFor(value => value.kind === 'prompt' && value.index === 2); + const prompt = String(f.prompts()[1]!.prompt); + assert.match(prompt, /Current Slack conversation: channel_id=C_FIXTURE; thread_ts=123\.456/); + assert.doesNotMatch(prompt, /C_MUTATED|FOREIGN_TARGET|FOREIGN_SCOPE/); + const event = f.events.find(value => value.event === 'steer_started')!; + assert.equal((event.data['target'] as RemoteTarget).targetId, 'C_FIXTURE'); + assert.deepEqual(rows(f.opts.chatSessionId).slice(1), [{ role: 'user', content: 'REMOTE_B' }]); + await f.peer.command('finish', { text: '_REMOTE_FINAL' }); await f.run.promise; +}); + +test('main Stop during cancellation suppresses B and reuses the same idle native process safely', { timeout: 10000 }, async t => { + const f = await start(t); + // Direct outcome/reuse control; the ingress regressions below verify no resubmission. + const redirect = agent.steerAgent(f.opts.scopeKey, 'STOPPED_B', 'web', { chatSessionId: f.opts.chatSessionId }); + await f.peer.waitFor(value => value.kind === 'cancel'); + assert.equal(agent.killActiveAgent(f.opts.scopeKey, 'user'), true); + await f.peer.command('release-cancel'); + assert.equal(await redirect, 'cancelled'); + const stopped = await f.run.promise; + assert.equal(stopped.runtimeOutcome?.status, 'stopped'); assert.equal(stopped.runtimeOutcome.finalText, null); + assert.equal(f.prompts().length, 1); assert.equal(f.owner.replaceTurn, undefined); + assert.equal(rows(f.opts.chatSessionId).some(row => (row as { content: string }).content === 'STOPPED_B'), false); + assert.equal(poolStats().busy, 0); + const next = agent.spawnAgent('NEXT_RUN_D', { ...f.opts, requestId: 'next-' + f.opts.requestId }); + await f.peer.waitFor(value => value.kind === 'prompt' && value.index === 2); + assert.equal(peers.length, 1); assert.doesNotMatch(String(f.prompts()[1]!.prompt), /STOPPED_B/); + await f.peer.command('finish', { text: '_D_FINAL' }); + assert.equal((await next.promise).text, 'PARTIAL_2_D_FINAL'); + assert.equal(new Set(f.prompts().map(value => value.pid)).size, 1); + assert.equal(new Set(f.prompts().map(value => value.sid)).size, 1); +}); + +for (const ingress of ['slash', 'gateway'] as const) { + test(`WP12-STOP-RESUBMIT: actual ${ingress} cancels pending B after Stop without queue or resurrection`, { timeout: 10000 }, async t => { + const f = await start(t), instruction = `STOPPED_${ingress}_B`; + let requestId: string | undefined; + const redirect = ingress === 'slash' ? f.slash(instruction) : undefined; + if (ingress === 'gateway') { + const result = gateway.submitMessage(instruction, { origin: 'web', scope: f.opts.scopeKey, + chatSessionId: f.opts.chatSessionId, midRunPolicy: 'steer' }); + assert.equal(result.action, 'started'); assert.equal(result.disposition, 'steered'); + requestId = result.requestId; assert.ok(requestId); + } + try { + await f.peer.waitFor(value => value.kind === 'cancel'); f.noFinal(); + assert.equal(agent.killActiveAgent(f.opts.scopeKey, 'user'), true); + await f.peer.command('release-cancel'); + if (redirect) assert.equal((await redirect).ok, true); + assert.equal((await f.run.promise).runtimeOutcome?.status, 'stopped'); + // Let detached gateway continuations finish; never await a receipt the buggy + // branch leaves pending in its queue. No sleep or vendor work is required. + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual({ resubmissions: submissions.length, + queued: agent.messageQueue.filter(item => item.scope === f.opts.scopeKey).length, + queueAdmissions: f.events.filter(event => event.event === 'queue_update' + && Array.isArray(event.data['queued']) + && event.data['queued'].some((item: { prompt: string }) => item.prompt === instruction)).length, + prompts: f.prompts().length, + userRows: rows(f.opts.chatSessionId).filter(row => (row as { role: string; content: string }).role === 'user' + && (row as { content: string }).content === instruction).length, + }, { resubmissions: 0, queued: 0, queueAdmissions: 0, prompts: 1, userRows: 0 }); + assert.equal(agent.activeMainProcesses.has(f.opts.scopeKey), false); + if (requestId) { + const receipts = f.events.filter(event => event.event === 'request_settled' && event.data['requestId'] === requestId); + assert.equal(receipts.length, 1); + assert.equal(receipts[0]!.data['outcome'], 'cancelled'); + assert.equal(receipts[0]!.data['reason'], 'native-steer-stopped'); + assert.equal(receipts[0]!.data['scope'], f.opts.scopeKey); + assert.equal(receipts[0]!.data['sessionId'], f.opts.chatSessionId); + } + } finally { + // A RED implementation may have queued B. Remove only this fixture's + // queue entries before the existing bounded session/owned-child teardown. + for (const item of [...agent.messageQueue]) { + if (item.scope === f.opts.scopeKey) agent.removeQueuedMessage(item.id); + } + } + }); +} + +test('WP12-STOP-BUSY-C: actual gateway cannot enqueue busy C after immediate Stop', { timeout: 10000 }, async t => { + const f = await start(t), instruction = 'STOPPED_BUSY_C'; + const b = agent.steerAgent(f.opts.scopeKey, 'PENDING_B', 'web', { chatSessionId: f.opts.chatSessionId }); + void b.catch(() => {}); + try { + await f.peer.waitFor(value => value.kind === 'cancel'); f.noFinal(); + const c = gateway.submitMessage(instruction, { origin: 'web', scope: f.opts.scopeKey, + chatSessionId: f.opts.chatSessionId, midRunPolicy: 'steer' }); + // Intentionally no await: C has observed busy but its consumer has not yet + // processed the asynchronous fallback when the user cancels the scope. + const stopped = agent.killActiveAgent(f.opts.scopeKey, 'user'); + assert.equal(stopped, true); assert.equal(c.action, 'started'); assert.ok(c.requestId); + await f.peer.command('release-cancel'); + assert.equal(await b, 'cancelled'); + assert.equal((await f.run.promise).runtimeOutcome?.status, 'stopped'); + await new Promise(resolve => setImmediate(resolve)); + const receipts = f.events.filter(event => event.event === 'request_settled' && event.data['requestId'] === c.requestId); + assert.deepEqual({ + queueAdmissions: f.events.filter(event => event.event === 'queue_update' + && Array.isArray(event.data['queued']) + && event.data['queued'].some((item: { prompt: string }) => item.prompt === instruction)).length, + queued: agent.messageQueue.filter(item => item.scope === f.opts.scopeKey).length, + prompts: peers.flatMap(peer => peer.records.filter(value => value.kind === 'prompt')).length, + userRows: rows(f.opts.chatSessionId).filter(row => (row as { role: string }).role === 'user' + && (row as { content: string }).content.includes(instruction)).length, + settlements: receipts.length, outcome: receipts[0]?.data['outcome'], + }, { queueAdmissions: 0, queued: 0, prompts: 1, userRows: 0, settlements: 1, outcome: 'cancelled' }); + assert.equal(agent.activeMainProcesses.has(f.opts.scopeKey), false); + assert.equal(receipts[0]!.data['scope'], f.opts.scopeKey); + assert.equal(receipts[0]!.data['sessionId'], f.opts.chatSessionId); + } finally { + // Remove a buggy late admission before bounded owned-session teardown; + // never await a receipt whose resurrected run is deliberately held open. + for (const item of [...agent.messageQueue]) { + if (item.scope === f.opts.scopeKey) agent.removeQueuedMessage(item.id); + } + } +}); + +for (const invalidation of ['owner-object', 'scope-generation'] as const) { + test(`actual main ${invalidation} loss during cancel drain cannot send/commit B or clear successor`, { timeout: 10000 }, async t => { + const f = await start(t), hook = f.owner.replaceTurn!; + const redirect = agent.steerAgent(f.opts.scopeKey, 'STALE_B', 'web', { chatSessionId: f.opts.chatSessionId }); + void redirect.catch(() => {}); + await f.peer.waitFor(value => value.kind === 'cancel'); + const successor = { ...f.owner, process: null, starting: true }; + delete successor.replaceTurn; delete successor.cancelTurn; + if (invalidation === 'scope-generation') { + const { bumpScopeSessionGeneration } = await import('../../src/agent/session-persistence.ts'); + bumpScopeSessionGeneration(f.opts.scopeKey); + } + if (invalidation === 'owner-object') agent.activeMainProcesses.set(f.opts.scopeKey, successor); + t.after(() => { if (agent.activeMainProcesses.get(f.opts.scopeKey) === successor) agent.activeMainProcesses.delete(f.opts.scopeKey); }); + await f.peer.command('release-cancel'); await assert.rejects(redirect); + const outcome = await f.run.promise; + assert.notEqual(outcome.code, 0); assert.equal(f.prompts().length, 1); + if (invalidation === 'owner-object') { + assert.equal(agent.activeMainProcesses.get(f.opts.scopeKey), successor); + assert.equal(successor.starting, true); + } else assert.equal(agent.activeMainProcesses.has(f.opts.scopeKey), false); + const receipt = await hook('STALE_C', () => assert.fail('stale callback committed')); + assert.notEqual(receipt.kind, 'dispatched'); + assert.equal(rows(f.opts.chatSessionId).some(row => /STALE_[BC]/.test((row as { content: string }).content)), false); + }); +} + +for (const behavior of ['fast', 'missing-cancel'] as const) { + test(`actual gateway ${behavior} settles its accepted request once without inference retry`, { timeout: 10000 }, async t => { + mode = behavior; const f = await start(t); + const settled = Promise.withResolvers(); + let requestId: string | undefined; + const off = subscribe(event => { + if (event.event === 'request_settled' && event.data['requestId'] === requestId) settled.resolve(); + }); + t.after(off); + const submission = gateway.submitMessage('GATEWAY_B_RAW', { origin: 'web', scope: f.opts.scopeKey, + chatSessionId: f.opts.chatSessionId, midRunPolicy: 'steer' }); + requestId = submission.requestId; + assert.ok(requestId); assert.equal(submission.action, 'started'); assert.equal(submission.disposition, 'steered'); + await settled.promise; const result = await f.run.promise; + // IPC recording and ACP stdout are independent streams; finality does not flush IPC. + if (behavior === 'fast') await f.peer.waitFor(value => value.kind === 'prompt' && value.index === 2); + const receipts = f.events.filter(event => event.event === 'request_settled' && event.data['requestId'] === requestId); + assert.equal(receipts.length, 1); assert.equal(receipts[0]!.data['outcome'], behavior === 'fast' ? 'steered' : 'failed'); + assert.equal(agent.messageQueue.length, 0); assert.equal(peers.length, 1); + assert.equal(f.prompts().length, behavior === 'fast' ? 2 : 1); + if (behavior === 'fast') { + assert.equal(result.text, 'FAST_FINAL'); + assert.ok(f.events.indexOf(receipts[0]!) < f.events.findIndex(event => event.event === 'agent_done')); + assert.deepEqual(rows(f.opts.chatSessionId).slice(1), [ + { role: 'user', content: 'GATEWAY_B_RAW' }, { role: 'assistant', content: 'FAST_FINAL' }, + ]); + } else { + assert.notEqual(result.code, 0); + assert.equal(rows(f.opts.chatSessionId).some(row => (row as { content: string }).content === 'GATEWAY_B_RAW'), false); + } + }); +} diff --git a/tests/unit/cursor-native-wire.test.ts b/tests/unit/cursor-native-wire.test.ts new file mode 100644 index 000000000..e8de7dac4 --- /dev/null +++ b/tests/unit/cursor-native-wire.test.ts @@ -0,0 +1,145 @@ +import '../setup/isolated-home.ts'; +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { launchControlAgent } from '../fixtures/native-acp-control.mjs'; +import { AcpSession } from '../../src/agent/runtime/acp/session.ts'; +import { AcpRuntimeSession } from '../../src/agent/runtime/acp/runtime-session.ts'; +import { AcpReplacement } from '../../src/agent/runtime/acp/replacement.ts'; +import type { RuntimeEvent } from '../../src/shared/runtime-contract.ts'; + +async function fixture(t: TestContext, mode = 'held') { + const peer = launchControlAgent(process.env.CLI_JAW_HOME!, mode); + // The IPC fd is additional to three non-null pipes; spawn's overload loses that fact. + const child = peer.child as ChildProcessWithoutNullStreams; + const chunks: string[] = []; + child.stdout.on('data', chunk => chunks.push(String(chunk))); + const protocol = new AcpSession(child, { permissions: 'auto', promptTimeoutMs: 5000, + requestTimeoutMs: 2000, controlTimeoutMs: 600, drainTimeoutMs: 2000 }); + t.after(async () => { await protocol.close(); await peer.exited; assert.notEqual(child.exitCode ?? child.signalCode, null); }); + await protocol.start({ cwd: process.env.CLI_JAW_HOME!, authMethodId: 'cursor_login' }); + const events: RuntimeEvent[] = [], prepared: string[] = []; + const partial = Promise.withResolvers(); + const runtime = new AcpRuntimeSession(protocol, { + provider: 'cursor', deferTurnEnd: true, + capabilities: { transport: 'native', steer: 'restart', resume: true, tools: true, toolOutput: true, + approvals: false, questions: false, images: false, subagents: false }, + getTurnContext: () => ({ runId: 'wire-run', sessionId: 'jaw-session', scope: 'wire-scope', + turnId: 'logical-turn', audience: 'internal', isCurrent: () => true }), + createReplacement: io => new AcpReplacement(io), + prepareReplacement: (instruction, text) => { + assert.equal(protocol.idle, true); prepared.push(text); return { text: instruction }; + }, + record: (context, body) => { + const event = { ...context, version: 1 as const, seq: events.length + 1, ...body }; + events.push(event); if (body.kind === 'message') partial.resolve(); return event; + }, + }); + const prompts = () => peer.records.filter(row => row.kind === 'prompt'); + const start = async () => { + const result = runtime.send({ text: 'A' }, () => {}); + await partial.promise; await peer.waitFor(row => row.kind === 'prompt'); + return { result }; + }; + return { ...peer, child, chunks, protocol, runtime, events, prepared, prompts, start }; +} + +for (const mode of ['missing-cancel', 'exit-on-cancel', 'noncancelled', 'illegal-chunk']) { + test(`owned Node ${mode} retires with one error result and no B dispatch`, { timeout: 8000 }, async t => { + const f = await fixture(t, mode), { result } = await f.start(); + let commits = 0; + await assert.rejects(f.runtime.steer({ text: 'B' }, () => { commits++; })); + const outcome = await result; + assert.equal(outcome.status, 'error'); assert.equal(outcome.finalText, null); + assert.match(outcome.partialText, /PARTIAL_1/); assert.equal(commits, 0); + assert.equal(f.prompts().length, 1); assert.equal(f.protocol.alive, false); + assert.equal(f.runtime.claimTurnOutcome('logical-turn')?.status, 'error'); + assert.equal(f.runtime.finalizeTurn('logical-turn', { kind: 'turn-end', status: 'error', finalText: null }), true); + assert.equal(f.runtime.finalizeTurn('logical-turn', { kind: 'turn-end', status: 'error', finalText: null }), false); + assert.equal(f.events.filter(event => event.kind === 'turn-end').length, 1); + await f.exited; + if (mode === 'exit-on-cancel') assert.equal(f.child.exitCode, 23); + if (mode === 'missing-cancel') assert.equal(f.runtime.lastError, 'acp_cancel_timeout'); + if (mode === 'illegal-chunk') { + assert.equal(f.runtime.lastError, 'acp_content_without_active_turn'); + assert.ok(f.chunks.some(chunk => chunk.includes('"stopReason":"cancelled"') && chunk.includes('ILLEGAL_AFTER_TERMINAL'))); + } + }); +} + +test('real stdio holds B until original cancelled RPC, consumer drain and actual idle; one final', { timeout: 8000 }, async t => { + const f = await fixture(t), gate = Promise.withResolvers(), entered = Promise.withResolvers(); + const original = f.protocol.prompt.bind(f.protocol); + t.after(() => gate.resolve()); + t.mock.method(f.protocol, 'prompt', (parts, owner, consume, options) => original(parts, owner, async (frame, signal) => { + if (JSON.stringify(frame).includes('_LATE_1')) { entered.resolve(); await gate.promise; } + return consume(frame, signal); + }, options)); + const { result } = await f.start(); let settled = false, commits = 0; + void result.then(() => { settled = true; }); + const steering = f.runtime.steer({ text: 'B' }, () => { commits++; assert.equal(settled, false); }); + await f.waitFor(row => row.kind === 'cancel'); + assert.equal(f.prompts().length, 1); assert.equal(settled, false); + await f.command('release-cancel'); await entered.promise; + assert.equal(f.protocol.idle, false); assert.equal(f.prompts().length, 1); + assert.equal(commits, 0); assert.equal(f.prepared.length, 0); + assert.equal(f.events.some(event => event.kind === 'turn-end'), false); + gate.resolve(); assert.equal((await steering).accepted, true); + await f.waitFor(row => row.kind === 'prompt' && row.index === 2); + assert.deepEqual(f.prepared, ['PARTIAL_1_LATE_1']); + assert.equal(f.events.some(event => event.kind === 'tool'), true); + assert.equal(settled, false); assert.equal(commits, 1); + await f.command('finish', { text: 'B_FINAL' }); + const outcome = await result; + assert.deepEqual(outcome, { status: 'done', finalText: 'PARTIAL_2B_FINAL', partialText: 'PARTIAL_1_LATE_1PARTIAL_2B_FINAL' }); + assert.deepEqual(f.prompts().map(row => [row.prompt, row.pid, row.sid]), + [['A', f.child.pid, f.protocol.nativeSessionId], ['B', f.child.pid, f.protocol.nativeSessionId]]); + assert.equal(f.records.some(row => row.kind === 'overlap'), false); + f.runtime.claimTurnOutcome('logical-turn'); + assert.equal(f.runtime.finalizeTurn('logical-turn', { kind: 'turn-end', status: 'done', finalText: outcome.finalText }), true); + assert.equal(f.events.filter(event => event.kind === 'turn-end').length, 1); +}); + +test('a held local cancel-write callback times out and reaps its real child without B', { timeout: 8000 }, async t => { + const f = await fixture(t, 'missing-cancel'), { result } = await f.start(); + const original = f.child.stdin.write; + const blocked = Promise.withResolvers(); + t.mock.method(f.child.stdin, 'write', function (...args: unknown[]) { + if (String(args[0]).includes('session/cancel')) { + blocked.resolve(); + // Model an outstanding OS write, not a fabricated RPC cancellation result. + return true; + } + return Reflect.apply(original, f.child.stdin, args); + }); + const steering = f.runtime.steer({ text: 'B' }); void steering.catch(() => {}); + await blocked.promise; await assert.rejects(steering); + assert.equal((await result).status, 'error'); assert.equal(f.runtime.lastError, 'acp_cancel_timeout'); + assert.equal(f.prompts().length, 1); assert.equal(f.records.some(row => row.kind === 'cancel'), false); + await f.exited; +}); + +test('real idle-gap content after cancellation retires before another prompt can start', { timeout: 8000 }, async t => { + const f = await fixture(t, 'fast'); + const owner = { binding: { runId: 'raw', sessionId: 'jaw', scope: 'scope', turnId: 'raw' }, + isCurrent: () => true, emit: () => null }; + const a = f.protocol.prompt([{ type: 'text', text: 'A' }], owner, () => {}); + await f.waitFor(row => row.kind === 'prompt'); + await f.protocol.cancel(); await a; assert.equal(f.protocol.idle, true); + await f.command('illegal'); await f.exited; + assert.equal(f.protocol.alive, false); + await assert.rejects(f.protocol.prompt([{ type: 'text', text: 'B' }], owner, () => {}), /unavailable/); + assert.equal(f.prompts().length, 1); + // Anonymous same-session data AFTER B admission has no wire attempt identity. + // This test certifies only the enforceable idle gap, not provider ordering after B. +}); + +test('Stop during held real cancellation cannot resurrect B and leaves a reusable idle session', { timeout: 8000 }, async t => { + const f = await fixture(t), { result } = await f.start(); let commits = 0; + const steering = f.runtime.steer({ text: 'B' }, () => { commits++; }); + await f.waitFor(row => row.kind === 'cancel'); + const stopping = f.runtime.cancel(); await f.command('release-cancel'); await stopping; + assert.equal((await steering).accepted, false); assert.equal((await result).status, 'stopped'); + assert.equal(commits, 0); assert.equal(f.prompts().length, 1); + assert.equal(f.protocol.idle, true); assert.equal(f.protocol.alive, true); +}); diff --git a/tests/unit/cursor-replacement-context.test.ts b/tests/unit/cursor-replacement-context.test.ts new file mode 100644 index 000000000..104e0e189 --- /dev/null +++ b/tests/unit/cursor-replacement-context.test.ts @@ -0,0 +1,191 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + appendCursorAcceptedInstruction, buildCursorReplacementPrompt, buildPromptForArgs, + PROMPT_HISTORY_MAX_ROWS, PROMPT_HISTORY_MAX_CHARS, withHistoryPrompt, withSteerContext, + type CursorAcceptedContext, +} from '../../src/agent/prompt-context.ts'; + +// Independent fixed wire expectations from packet 122, not delimiter-count oracles. +const HEADER = '[Cursor redirect context - read-only]\n'; +const ORIGINAL = '[Previous request - read-only context]\n'; +const ACCEPTED = '[Accepted redirect - read-only context]\n'; +const OMITTED = '[Some previous request context was omitted to fit the history limit.]'; +const BOUNDARY = '[History Boundary]\nRecent Context is read-only background. The Current Message below is the only task to execute now.\nDo not continue prior plans, audits, commands, questions, or goals unless the Current Message explicitly asks to resume or continue them.'; +const EMPTY: CursorAcceptedContext = Object.freeze({ messages: Object.freeze([]), omitted: false }); +const fullLimit = 8_388_608; + +function history(current: string, context: string): string { + return `${context}\n\n${BOUNDARY}\n\n---\n[Current Message]\n${current}`; +} + +function operational(prompt: string, sys = 'RULES'): string { + return `[Operational Context — cli-jaw Integration]\nThe following operational guidelines apply to this session. Follow these task rules and use the tools/commands described:\n\n${sys}\n\n---\n\n${prompt}`; +} + +function partial(prompt: string, text: string, truncated = false): string { + return [ + '[Interrupted Turn Context — cli-jaw steer]', + 'The previous turn was interrupted by a new instruction from the user (steer).', + 'Its partial output before the interruption is below and is INCOMPLETE.', + 'Remember what it was doing, do not repeat completed work, and continue with the current message.', + '[이전 턴이 사용자의 새 지시(steer)로 중단되었습니다. 아래는 중단 시점까지의 미완성 부분 출력입니다.', + '완료된 작업을 반복하지 말고, 맥락을 기억한 채 현재 메시지를 이어서 수행하세요.]', + '', '', `${truncated ? '... [truncated]\n' : ''}${text}`, + '', '', '---', prompt, + ].join('\n'); +} + +function replacement(originalRequest: string, accepted: CursorAcceptedContext = EMPTY, instruction = 'NOW'): string { + return buildCursorReplacementPrompt({ instruction, originalRequest, accepted, partialText: '', sysPrompt: '' }); +} + +test('exports existing history defaults and composes short A → B → C without mutating accepted state', () => { + assert.equal(PROMPT_HISTORY_MAX_ROWS, 10); + assert.equal(PROMPT_HISTORY_MAX_CHARS, 8000); + assert.equal(replacement('A', EMPTY, 'B'), history('B', HEADER + ORIGINAL + 'A')); + const accepted = appendCursorAcceptedInstruction(EMPTY, 'B'); + assert.deepEqual(accepted, { messages: ['B'], omitted: false }); + assert.notEqual(accepted, EMPTY); + assert.notEqual(accepted.messages, EMPTY.messages); + assert.equal(replacement('A', accepted, 'C'), history('C', HEADER + ORIGINAL + 'A\n\n' + ACCEPTED + 'B')); + assert.deepEqual(EMPTY, { messages: [], omitted: false }); +}); + +test('accepted row 9 → 10 drops oldest, preserves chronology and latches omission', () => { + let context = EMPTY; + for (let i = 1; i <= 9; i++) context = appendCursorAcceptedInstruction(context, String(i)); + assert.deepEqual(context, { messages: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], omitted: false }); + const next = appendCursorAcceptedInstruction(Object.freeze(context), '10'); + assert.deepEqual(next, { messages: ['2', '3', '4', '5', '6', '7', '8', '9', '10'], omitted: true }); + assert.equal(context.messages.length, 9); + const block = HEADER + [ORIGINAL + 'A', ...next.messages.map(m => ACCEPTED + m), OMITTED].join('\n\n'); + assert.equal(replacement('A', next), history('NOW', block)); + assert.equal(appendCursorAcceptedInstruction(next, '11').omitted, true); +}); + +for (const length of [7999, 8000, 8001]) { + test(`accepted raw content ${length}: newest suffix within 8000 retained characters`, () => { + const text = 'H' + 'x'.repeat(length - 2) + 'T'; + assert.deepEqual(appendCursorAcceptedInstruction(EMPTY, text), { + messages: [length > 8000 ? text.slice(1) : text], omitted: length > 8000, + }); + }); + test(`serialized context ${length}: exact header/labels/marker/separator accounting`, () => { + const text = 'o'.repeat(length - HEADER.length - ORIGINAL.length); + const expected = length <= 8000 ? HEADER + ORIGINAL + text + : HEADER + ORIGINAL + 'o'.repeat(8000 - HEADER.length - ORIGINAL.length - 2 - OMITTED.length) + '\n\n' + OMITTED; + assert.equal(replacement(text), history('NOW', expected)); + assert.equal(expected.length, Math.min(length, 8000)); + }); +} + +test('append allocates newest first and clips oldest retained suffix before dropping rows', () => { + const input = Object.freeze({ messages: Object.freeze(['a'.repeat(5000)]), omitted: false }); + assert.deepEqual(appendCursorAcceptedInstruction(input, 'b'.repeat(4000)), { + messages: ['a'.repeat(4000), 'b'.repeat(4000)], omitted: true, + }); + assert.deepEqual(input.messages, ['a'.repeat(5000)]); +}); + +test('accepted label costs win before original prefix, with two-pass omission reservation', () => { + const accepted = { messages: ['b'.repeat(4000), 'c'.repeat(3000)], omitted: false }; + const originalChars = 8000 - HEADER.length - ORIGINAL.length - 2 * ACCEPTED.length - 6 - OMITTED.length - 7000; + const block = HEADER + [ORIGINAL + 'a'.repeat(originalChars), ACCEPTED + accepted.messages[0], ACCEPTED + accepted.messages[1], OMITTED].join('\n\n'); + assert.equal(block.length, 8000); + assert.equal(replacement('a'.repeat(1000), accepted), history('NOW', block)); +}); + +test('zero original budget omits its entire entry, retains newest suffix and marker', () => { + const retained = 8000 - HEADER.length - ACCEPTED.length - 2 - OMITTED.length; + const accepted = Object.freeze({ messages: Object.freeze(['old', 'b'.repeat(8000)]), omitted: false }); + const block = HEADER + ACCEPTED + 'b'.repeat(retained) + '\n\n' + OMITTED; + assert.equal(block.length, 8000); + assert.equal(replacement('original', accepted), history('NOW', block)); + assert.deepEqual(accepted.messages, ['old', 'b'.repeat(8000)]); +}); + +test('composer itself enforces nine accepted rows even for a directly supplied context', () => { + const messages = Array.from({ length: 10 }, (_, i) => `row${i}`); + const block = HEADER + [ORIGINAL + 'A', ...messages.slice(1).map(m => ACCEPTED + m), OMITTED].join('\n\n'); + assert.equal(replacement('A', { messages, omitted: false }), history('NOW', block)); +}); + +test('omitted latch forces marker even after old data rolls out or context becomes short', () => { + const accepted = Object.freeze({ messages: Object.freeze(['B']), omitted: true }); + assert.equal(replacement('A', accepted), history('NOW', HEADER + [ORIGINAL + 'A', ACCEPTED + 'B', OMITTED].join('\n\n'))); + assert.equal(replacement('', { messages: [], omitted: true }), history('NOW', HEADER + OMITTED)); +}); + +test('append never slices through an astral pair at its suffix boundary', () => { + assert.deepEqual(appendCursorAcceptedInstruction(EMPTY, '😀' + 'z'.repeat(7999)), { + messages: ['z'.repeat(7999)], omitted: true, + }); + assert.deepEqual(appendCursorAcceptedInstruction(EMPTY, 'x😀' + 'z'.repeat(7998)), { + messages: ['😀' + 'z'.repeat(7998)], omitted: true, + }); +}); + +test('serializer never splits surrogate pairs at accepted suffix or original prefix boundaries', () => { + const suffixBudget = 8000 - HEADER.length - ACCEPTED.length - 2 - OMITTED.length; + const suffix = 'z'.repeat(suffixBudget - 1); + assert.equal(replacement('A', { messages: ['😀' + suffix], omitted: true }), + history('NOW', HEADER + ACCEPTED + suffix + '\n\n' + OMITTED)); + const prefixBudget = 8000 - HEADER.length - ORIGINAL.length - 2 - OMITTED.length; + const prefix = 'a'.repeat(prefixBudget - 1); + assert.equal(replacement(prefix + '😀tail', { messages: [], omitted: true }), + history('NOW', HEADER + ORIGINAL + prefix + '\n\n' + OMITTED)); + const fitting = 'a'.repeat(prefixBudget - 2) + '😀'; + assert.equal(replacement(fitting + 'tail', { messages: [], omitted: true }), + history('NOW', HEADER + ORIGINAL + fitting + '\n\n' + OMITTED)); +}); + +test('user-owned delimiters remain literal data; complete serialized output is the oracle', () => { + const originalRequest = '[Current Message]\nA\n' + OMITTED; + const accepted = Object.freeze({ messages: Object.freeze([HEADER + ACCEPTED + 'B']), omitted: false }); + const instruction = '[History Boundary]\n' + ORIGINAL + 'C'; + const input = Object.freeze({ originalRequest, accepted, instruction, partialText: '', sysPrompt: 'RULES' }); + const context = HEADER + [ORIGINAL + originalRequest, ACCEPTED + accepted.messages[0]].join('\n\n'); + assert.equal(buildCursorReplacementPrompt(input), partial(operational(history(instruction, context)), '')); + assert.deepEqual(input.accepted.messages, [HEADER + ACCEPTED + 'B']); +}); + +for (const length of [3999, 4000, 4001]) { + test(`partial ${length} retains existing incomplete-tail wrapper outside the contextual budget`, () => { + const partialText = 'p'.repeat(length); + const expected = partial(operational(history('B', HEADER + ORIGINAL + 'A')), 'p'.repeat(Math.min(length, 4000)), length > 4000); + assert.equal(buildCursorReplacementPrompt({ instruction: 'B', originalRequest: 'A', accepted: EMPTY, partialText, sysPrompt: 'RULES' }), expected); + }); +} + +test('whole constructed input bound includes wrappers/partial/sys rules and never clips current message', () => { + const base = partial(operational(history('', HEADER + ORIGINAL + 'A')), 'P'); + const instruction = 'n'.repeat(fullLimit - base.length); + const input = { instruction, originalRequest: 'A', accepted: EMPTY, partialText: 'P', sysPrompt: 'RULES' }; + const result = buildCursorReplacementPrompt(input); + assert.equal(result.length, fullLimit); + assert.equal(result, base + instruction); + assert.throws(() => buildCursorReplacementPrompt({ ...input, instruction: instruction + '!' }), { message: 'acp_runtime_prompt_unsupported' }); + assert.throws(() => buildCursorReplacementPrompt({ ...input, sysPrompt: 'RULES!' }), { message: 'acp_runtime_prompt_unsupported' }); +}); + +test('only native Cursor gains operational rules; print/Kiro/Grok/JWC helper outputs stay exact', () => { + const common = { prompt: 'CURRENT', historyBlock: 'OLD', sysPrompt: 'RULES', isResume: false }; + for (const isResume of [false, true]) { + for (const runtimeTransport of [undefined, 'print', 'native'] as const) { + for (const cli of ['cursor', 'grok', 'jwc', 'kiro-code', 'ai-e']) { + const effectiveProvider = cli === 'ai-e' ? 'kiro' : undefined; + const kiro = cli === 'kiro-code' || cli === 'ai-e'; + const nativeCursor = cli === 'cursor' && runtimeTransport === 'native'; + const expected = (kiro && isResume) || cli === 'jwc' ? 'CURRENT' + : kiro || nativeCursor ? operational(history('CURRENT', 'OLD')) : history('CURRENT', 'OLD'); + assert.equal(buildPromptForArgs({ ...common, cli, effectiveProvider, isResume, runtimeTransport }), expected); + } + } + } + assert.equal(buildPromptForArgs({ ...common, cli: 'cursor', runtimeTransport: 'native', sysPrompt: '' }), history('CURRENT', 'OLD')); + assert.equal(withHistoryPrompt('CURRENT', ''), 'CURRENT'); + assert.equal(withHistoryPrompt('CURRENT', 'OLD'), history('CURRENT', 'OLD')); + assert.equal(withSteerContext('CURRENT', ' '), 'CURRENT'); + assert.equal(withSteerContext('CURRENT', ' P '), partial('CURRENT', 'P')); +}); diff --git a/tests/unit/enoent-guard.test.ts b/tests/unit/enoent-guard.test.ts index fdedd10b3..c04f1d8d0 100644 --- a/tests/unit/enoent-guard.test.ts +++ b/tests/unit/enoent-guard.test.ts @@ -184,27 +184,9 @@ test('EG-006: acpSettled guard exists in both error and exit handlers', () => { ); }); -// ─── EG-007: settled flags are set before resolve/broadcast ─── - -test('EG-007: settled flag is set before resolve() in error handlers', () => { - // Standard CLI error handler: stdSettled = true must come before resolve - const errorIdx = spawnSrc.indexOf("child.on('error'"); - const errorBlock = spawnSrc.slice(errorIdx, errorIdx + 1400); - const settledIdx = errorBlock.indexOf('stdSettled = true;'); - const resolveIdx = errorBlock.indexOf('resolve!('); - assert.ok(settledIdx > 0, 'stdSettled assignment should exist in error handler'); - assert.ok(resolveIdx > 0, 'resolve should exist in error handler'); - assert.ok(settledIdx < resolveIdx, 'stdSettled = true must come before resolve'); - - // ACP error handler: acpSettled = true must come before resolve - const acpErrorIdx = spawnSrc.indexOf("acp.on('error'"); - const acpErrorBlock = spawnSrc.slice(acpErrorIdx, acpErrorIdx + 800); - const acpSettledIdx = acpErrorBlock.indexOf('acpSettled = true;'); - const acpResolveIdx = acpErrorBlock.indexOf('resolve!('); - assert.ok(acpSettledIdx > 0, 'acpSettled assignment should exist in ACP error handler'); - assert.ok(acpResolveIdx > 0, 'resolve should exist in ACP error handler'); - assert.ok(acpSettledIdx < acpResolveIdx, 'acpSettled = true must come before resolve'); -}); +// EG-007a/b are behavioral probes in print-spawn-journal.test.ts and +// print-bypass-paths.test.ts: reentrant error hooks plus the subsequent close/exit +// must produce one completion. Fixed source windows could not verify that contract. // ─── EG-008: quota-copilot.ts uses env-first token lookup ─── diff --git a/tests/unit/kiro-context-delta.test.ts b/tests/unit/kiro-context-delta.test.ts index 2c9583ab5..7e5477915 100644 --- a/tests/unit/kiro-context-delta.test.ts +++ b/tests/unit/kiro-context-delta.test.ts @@ -1,13 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; import { buildPromptForArgs } from '../../src/agent/prompt-context.ts'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - test('Kiro fresh prompt includes operational context and bounded history', () => { const prompt = buildPromptForArgs({ cli: 'kiro-code', @@ -50,15 +44,5 @@ test('ai-e Kiro resume follows the same current-turn-only rule', () => { assert.equal(prompt, 'next ai-e kiro turn'); }); -test('JWC resident branch passes the current prompt directly', () => { - const src = fs.readFileSync(join(__dirname, '../../src/agent/spawn.ts'), 'utf8'); - const branchStart = src.indexOf("if (cli === 'jwc' && mainManaged && !opts.internal)"); - const branchEnd = src.indexOf('const permissions =', branchStart); - - assert.ok(branchStart > 0, 'jwc branch should exist before normal CLI spawn path'); - assert.ok(branchEnd > branchStart, 'jwc branch should end before normal permissions setup'); - - const branch = src.slice(branchStart, branchEnd); - assert.match(branch, /jawRuntime\.prompt\(jwcCwd,\s*prompt\)/); - assert.doesNotMatch(branch, /withHistoryPrompt|buildPromptForArgs/); -}); +// JWC's actual resident call-site contract is covered in +// native-legacy-prompt-routing.test.ts with isolated module mocks. diff --git a/tests/unit/live-run-trace-hydration.test.ts b/tests/unit/live-run-trace-hydration.test.ts index 92fd464a4..2099e47a1 100644 --- a/tests/unit/live-run-trace-hydration.test.ts +++ b/tests/unit/live-run-trace-hydration.test.ts @@ -1,13 +1,13 @@ -// WP4 (devlog 260703 doc 12): /api/orchestrate/snapshot falls back to durable -// trace_events tool rows when the in-RAM live-run toolLog is empty or behind, -// preserves RAM-only isEmployee mirrors, and passes RAM through untouched when -// it is healthy. Route-level harness per tests/unit/trace-routes.test.ts. +// Snapshots converge from durable rows even at equal counts, preserving RAM fallback. +import '../setup/test-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer, type Server } from 'node:http'; import express, { type NextFunction, type Request, type Response } from 'express'; import { registerOrchestrateRoutes } from '../../src/routes/orchestrate.ts'; -import { startTraceRun, stampTraceTool } from '../../src/trace/store.ts'; +import { registerMessageRoutes } from '../../src/routes/messages.ts'; +import { db } from '../../src/core/db.ts'; +import { startTraceRun, stampTraceTool, updateTraceToolRow, countToolTraceRows } from '../../src/trace/store.ts'; import { beginLiveRun, setLiveRunTraceId, appendLiveRunTool, clearLiveRun } from '../../src/agent/live-run-state.ts'; import type { ToolEntry } from '../../src/types/agent.ts'; @@ -73,18 +73,120 @@ test('snapshot hydration preserves RAM-only isEmployee mirror entries', async () clearLiveRun(SCOPE); }); -test('snapshot passes the RAM toolLog through when RAM is healthy (no fallback)', async () => { +// Intentionally replaces the old equal-count => RAM-label-wins expectation (devlog 030/031). +// Equal row counts do not prove freshness: updateTraceToolRow changes the existing SQL row. +// This strengthens the behavioral oracle: assert durable terminal status, updated detail, +// and an explicit detail clear through the snapshot route, while the row count stays one. +test('snapshot observes equal-count durable terminal and detail updates', async () => { clearLiveRun(SCOPE); const runId = startTraceRun({ cli: 'claude', audience: 'public' }); beginLiveRun(SCOPE, 'claude'); setLiveRunTraceId(SCOPE, runId); - // One trace row and one RAM entry with a DIFFERENT label: counts are equal, so - // hydration must NOT replace RAM — the RAM version is what the snapshot returns. - const tool: ToolEntry = { icon: '🔧', label: 'trace-version', toolType: 'tool' }; + const tool: ToolEntry = { icon: '🔧', label: 'working', toolType: 'tool', status: 'running', detail: 'started' }; stampTraceTool(tool, { traceRunId: runId, traceAudience: 'public' }, 'tool'); appendLiveRunTool(SCOPE, { ...tool, label: 'ram-version' }); + updateTraceToolRow({ ...tool, label: 'finished', status: 'done', detail: 'result' }); + assert.equal(countToolTraceRows(runId), 1, 'completion updates the same SQL row'); const activeRun = await fetchActiveRun(); - assert.deepEqual(activeRun.toolLog.map(t => t.label), ['ram-version']); + assert.deepEqual(activeRun.toolLog.map(t => [t.label, t.status, t.detail]), [['finished', 'done', 'result']]); + updateTraceToolRow({ ...tool, label: 'finished', status: 'done', detail: '' }); + const cleared = await fetchActiveRun(); + assert.equal(cleared.toolLog.length, 1); + assert.equal(cleared.toolLog[0]?.detail ?? '', '', 'empty durable detail must not resurrect stale RAM detail'); clearLiveRun(SCOPE); }); + +test('snapshot keeps all RAM tools when storage has no rows or only some rows', async () => { + clearLiveRun(SCOPE); + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + beginLiveRun(SCOPE, 'claude'); + setLiveRunTraceId(SCOPE, runId); + appendLiveRunTool(SCOPE, { icon: '🔧', label: 'unstored-boss', toolType: 'tool', stepRef: 'lost' }); + appendLiveRunTool(SCOPE, { icon: '🤖', label: 'unstored-worker', toolType: 'tool', isEmployee: true }); + assert.equal(countToolTraceRows(runId), 0); + assert.deepEqual((await fetchActiveRun()).toolLog.map(t => t.label), ['unstored-boss', 'unstored-worker']); + seedTraceTools(runId, ['stored-boss']); + assert.deepEqual((await fetchActiveRun()).toolLog.map(t => t.label), ['stored-boss', 'unstored-boss', 'unstored-worker']); + setLiveRunTraceId(SCOPE, 'tr_missing000000000000000000'); + assert.deepEqual((await fetchActiveRun()).toolLog.map(t => t.label), ['unstored-boss', 'unstored-worker']); + clearLiveRun(SCOPE); +}); + +test('snapshot preserves cross-run and unscoped workers with the boss stepRef', async () => { + clearLiveRun(SCOPE); + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + const workerRunId = startTraceRun({ cli: 'claude', audience: 'internal' }); + const boss: ToolEntry = { icon: '🔧', label: 'boss', toolType: 'tool', stepRef: 'shared', status: 'done' }; + stampTraceTool(boss, { traceRunId: runId, traceAudience: 'public' }); + beginLiveRun(SCOPE, 'claude'); + setLiveRunTraceId(SCOPE, runId); + appendLiveRunTool(SCOPE, { ...boss, label: 'stale-boss', status: 'running' }); + appendLiveRunTool(SCOPE, { ...boss, traceRunId: workerRunId, label: 'worker', isEmployee: true }); + appendLiveRunTool(SCOPE, { icon: '🤖', label: 'unknown-worker', toolType: 'tool', stepRef: 'shared', isEmployee: true }); + const tools = (await fetchActiveRun()).toolLog; + assert.deepEqual(tools.map(t => t.label), ['boss', 'worker', 'unknown-worker']); + assert.equal(tools[0]?.status, 'done'); + clearLiveRun(SCOPE); +}); + +test('snapshot reads only the latest 400 durable rows before applying inline caps', async () => { + clearLiveRun(SCOPE); + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + seedTraceTools(runId, Array.from({ length: 405 }, (_, i) => `boss-${i + 1}`)); + beginLiveRun(SCOPE, 'claude'); + setLiveRunTraceId(SCOPE, runId); + const tools = (await fetchActiveRun()).toolLog; + assert.equal(tools.length, 160); + assert.equal(tools[0]?.label, '241 tool events omitted', 'inline cap saw 400 rows, not the whole run'); + assert.equal(tools[1]?.label, 'boss-247'); + assert.equal(tools.at(-1)?.label, 'boss-405'); + clearLiveRun(SCOPE); +}); + +test('snapshot recalculates one omission marker for 161 durable tools and preserves it without DB rows', async () => { + clearLiveRun(SCOPE); + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + beginLiveRun(SCOPE, 'claude'); + for (let seq = 1; seq <= 161; seq++) { + const tool: ToolEntry = { icon: '🔧', label: `boss-${seq}`, toolType: 'tool', status: 'done' }; + stampTraceTool(tool, { traceRunId: runId, traceAudience: 'public' }); + appendLiveRunTool(SCOPE, tool); + } + assert.equal(countToolTraceRows(runId), 161); + const newestSequences = Array.from({ length: 159 }, (_, i) => i + 3); + // With no durable rows, the RAM marker still accounts for its two evicted tools. + setLiveRunTraceId(SCOPE, 'tr_missing000000000000000000'); + const fallback = (await fetchActiveRun()).toolLog; + assert.equal(fallback.length, 160); + assert.equal(fallback[0]?.label, '2 tool events omitted'); + assert.deepEqual(fallback.slice(1).map(t => t.traceSeq), newestSequences); + + // Reconstructing from all durable tools must replace, not append, the RAM marker. + setLiveRunTraceId(SCOPE, runId); + const reconstructed = (await fetchActiveRun()).toolLog; + assert.equal(reconstructed.length, 160); + assert.equal(reconstructed.filter(t => t.traceSeq === undefined).length, 1, 'exactly one omission marker'); + assert.equal(reconstructed[0]?.label, '2 tool events omitted'); + assert.deepEqual(reconstructed.slice(1).map(t => t.traceSeq), newestSequences, 'all 159 newest real tools survive'); + clearLiveRun(SCOPE); +}); + +test('actual messages API bounds a legacy tool blob before sending it to clients', async () => { + const raw = JSON.stringify(Array.from({ length: 200 }, (_, i) => ({ icon: 'x', label: `raw-${i}`, + toolType: 'tool', stepRef: `raw-${i}`, detail: 'x'.repeat(600) }))); + const id = Number(db.prepare("INSERT INTO messages(role,content,session_id,tool_log) VALUES('assistant','fixture','default',?)").run(raw).lastInsertRowid); + const app = express(); registerMessageRoutes(app, noAuth); + const server = createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); assert.ok(address && typeof address === 'object'); + try { + const response = await fetch(`http://127.0.0.1:${address.port}/api/messages?session=default`, { signal: AbortSignal.timeout(3000) }); + assert.equal(response.status, 200); + const body = await response.json() as { data: Array<{ id: number; tool_log: string }> }; + const row = body.data.find(message => message.id === id)!; + assert.ok(row.tool_log.length <= 64_000); assert.notEqual(row.tool_log, raw); + const tools = JSON.parse(row.tool_log) as Array<{ label: string }>; + assert.ok(tools.length <= 160); assert.equal(tools.at(-1)?.label, 'raw-199'); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } +}); diff --git a/tests/unit/merge-tool-log.test.ts b/tests/unit/merge-tool-log.test.ts new file mode 100644 index 000000000..48fbe8ef5 --- /dev/null +++ b/tests/unit/merge-tool-log.test.ts @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mergeLatestTools } from '../../src/agent/merge-tool-log.js'; +import type { ToolEntry } from '../../src/types/agent.js'; +import type { SanitizedToolLogEntry } from '../../src/shared/tool-log-sanitize.js'; +import { sanitizeToolLogForDurableStorage } from '../../src/shared/tool-log-sanitize.js'; + +function tool(label: string, fields: Partial = {}): ToolEntry { + return { icon: '🔧', label, toolType: 'tool', ...fields }; +} + +test('run/ref and run/seq identity domains cannot collide, including delimiter-like refs', () => { + const result = mergeLatestTools([ + tool('ref', { traceRunId: 'run', stepRef: 'trace:2' }), + tool('seq', { traceRunId: 'run', traceSeq: 2 }), + tool('other-run', { traceRunId: 'other', stepRef: 'trace:2' }), + tool('delimiter-ref', { traceRunId: 'run', stepRef: 'x:ref:y' }), + tool('delimiter-run', { traceRunId: 'run:ref:x', stepRef: 'y' }), + ], [tool('latest-ref', { traceRunId: 'run', stepRef: 'trace:2', status: 'done' })], 'fallback'); + assert.deepEqual(result.map(t => t.label), ['latest-ref', 'seq', 'other-run', 'delimiter-ref', 'delimiter-run']); +}); + +test('fallback scopes boss entries but never unscoped workers; unknown runs stay distinct', () => { + const result = mergeLatestTools([ + tool('boss', { stepRef: 'same' }), + tool('worker-a', { stepRef: 'same', isEmployee: true }), + tool('worker-b', { stepRef: 'same', isEmployee: true }), + ], [ + tool('boss-done', { traceRunId: 'boss-run', stepRef: 'same', status: 'done' }), + tool('child', { traceRunId: 'child-run', stepRef: 'same', isEmployee: true }), + tool('unknown-child', { stepRef: 'same', isEmployee: true }), + ], 'boss-run'); + assert.deepEqual(result.map(t => t.label), ['boss-done', 'worker-a', 'worker-b', 'child', 'unknown-child']); + assert.equal(mergeLatestTools([tool('unknown', { stepRef: 'same' })], [tool('unknown', { stepRef: 'same' })], '').length, 2); +}); + +test('identityless entries occur once per input occurrence, without merging by label', () => { + const anonymous = tool('anonymous'); + const result = mergeLatestTools([anonymous, { ...anonymous }], [{ ...anonymous }], 'run'); + assert.equal(result.length, 3); + assert.deepEqual(result.map(t => t.label), ['anonymous', 'anonymous', 'anonymous']); + assert.equal(mergeLatestTools([anonymous], [], 'run').length, 1, 'primary anonymous is not appended twice'); + assert.equal(mergeLatestTools([], [anonymous], 'run').length, 1); + assert.deepEqual(mergeLatestTools([], [], ''), []); +}); + +test('invalid seq pointers do not identify tools; a nonempty ref takes precedence over seq', () => { + for (const traceSeq of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.equal(mergeLatestTools([tool('a', { traceSeq })], [tool('b', { traceSeq })], 'run').length, 2); + } + const result = mergeLatestTools([ + tool('a', { stepRef: 'a', traceSeq: 1 }), tool('b', { stepRef: 'b', traceSeq: 1 }), + ], [tool('a-done', { stepRef: 'a', traceSeq: 2, status: 'done' })], 'run'); + assert.deepEqual(result.map(t => t.label), ['a-done', 'b']); +}); + +test('latest within each source wins equal status while primary wins source ties and order', () => { + const result = mergeLatestTools([ + tool('boss-old', { stepRef: 'boss', status: 'running' }), + tool('boss-second', { stepRef: 'second', status: 'done' }), + tool('boss-latest', { stepRef: 'boss', status: 'running' }), + ], [ + tool('worker-old', { traceRunId: 'child', stepRef: 'worker', isEmployee: true, status: 'done' }), + tool('mirror-boss', { stepRef: 'boss', status: 'running' }), + tool('worker-latest', { traceRunId: 'child', stepRef: 'worker', isEmployee: true, status: 'done' }), + ], 'run'); + assert.deepEqual(result.map(t => t.label), ['boss-latest', 'boss-second', 'worker-latest']); +}); + +test('terminal status never regresses within either source or across sources', () => { + for (const status of ['done', 'error', 'failed', 'completed', 'cancelled', 'canceled', 'interrupted', 'stopped']) { + const terminal = tool('terminal', { stepRef: 'same', status, detail: 'final result' }); + const running = tool('stale', { stepRef: 'same', status: 'running', detail: 'in progress' }); + for (const [primary, mirrors] of [ + [[terminal, running], []], [[], [terminal, running]], + [[terminal], [running]], [[running], [terminal]], + ] as [ToolEntry[], ToolEntry[]][]) { + const result = mergeLatestTools(primary, mirrors, 'run'); + assert.equal(result.length, 1); + assert.equal(result[0]?.status, status); + assert.equal(result[0]?.detail, 'final result'); + } + } +}); + +test('explicit empty detail clears old content; undefined preserves prior detail', () => { + const old = tool('old', { stepRef: 'same', status: 'done', detail: 'old detail' }); + const empty = tool('empty', { stepRef: 'same', status: 'done', detail: '' }); + const missing = tool('missing', { stepRef: 'same', status: 'done' }); + assert.equal(Object.hasOwn(mergeLatestTools([missing], [missing], 'run')[0]!, 'detail'), false); + assert.equal(mergeLatestTools([old, empty], [], 'run')[0]?.detail, ''); + assert.equal(mergeLatestTools([old, missing], [], 'run')[0]?.detail, 'old detail'); + assert.equal(mergeLatestTools([empty], [old], 'run')[0]?.detail, ''); + assert.equal(mergeLatestTools([missing], [old, empty], 'run')[0]?.detail, ''); + assert.equal(mergeLatestTools([missing], [old, missing], 'run')[0]?.detail, 'old detail'); + assert.equal(mergeLatestTools([old], [empty], 'run')[0]?.detail, 'old detail', 'primary wins equal status'); + assert.equal(mergeLatestTools([tool('running', { stepRef: 'same', status: 'running', detail: 'old' })], [empty], 'run')[0]?.detail, ''); +}); + +test('latest mirror detail fills a primary omission without replacing primary label', () => { + const result = mergeLatestTools([tool('primary', { stepRef: 'same', status: 'done' })], [ + tool('mirror-old', { stepRef: 'same', status: 'done', detail: 'first' }), + tool('mirror-latest', { stepRef: 'same', status: 'done', detail: 'second' }), + ], 'run'); + assert.equal(result[0]?.label, 'primary'); + assert.equal(result[0]?.detail, 'second'); +}); + +test('readonly inputs are untouched and mirror toolType defaults to the shared ToolEntry port', () => { + const primary = Object.freeze([Object.freeze(tool('primary', { stepRef: 'same', detail: 'original' }))]); + const mirrors: readonly SanitizedToolLogEntry[] = Object.freeze([ + Object.freeze({ icon: '🔧', label: 'mirror', stepRef: 'same', status: 'done', detail: '' }), + Object.freeze({ icon: '🤖', label: 'anonymous' }), + ]); + const result = mergeLatestTools(primary, mirrors, 'run'); + result[0]!.label = 'changed'; + assert.equal(primary[0]?.label, 'primary'); + assert.equal(primary[0]?.detail, 'original'); + assert.equal(mirrors[0]?.label, 'mirror'); + assert.equal(result[1]?.toolType, 'tool'); +}); + +test('a small primary plus capped 161-tool RAM mirrors keeps one head marker and 159 real tools after sanitize', () => { + const mirrors = sanitizeToolLogForDurableStorage(Array.from({ length: 161 }, (_, i) => + tool(`mirror-${i + 1}`, { stepRef: `item-${i + 1}`, status: 'done' }))); + assert.equal(mirrors[0]?.label, '2 tool events omitted'); + const merged = mergeLatestTools([tool('boss', { stepRef: 'boss' })], mirrors, 'run'); + assert.equal(merged[0]?.label, '2 tool events omitted', 'the mirror marker precedes the primary tools'); + assert.equal(merged[1]?.label, 'boss', 'real primary tools still precede real mirror tools'); + const sanitized = sanitizeToolLogForDurableStorage(merged); + assert.equal(sanitized.length, 160); + assert.equal(sanitized[0]?.label, '3 tool events omitted', 'only the final sanitizer accounts for the extra capped tool'); + assert.deepEqual(sanitized.slice(1).map(t => t.label), Array.from({ length: 159 }, (_, i) => `mirror-${i + 3}`)); + assert.deepEqual(sanitizeToolLogForDurableStorage(mergeLatestTools([], mirrors, 'run')), mirrors, 'empty primary retains the RAM fallback marker'); +}); + +test('markers from both sources collapse to the primary marker at the head without summing or tool identity collisions', () => { + const primaryMarker = tool('5 tool events omitted', { icon: '⚠️', stepRef: 'boss', status: 'done' }); + const mirrorMarker = tool('9 tool events omitted', { icon: '⚠️', stepRef: 'worker', status: 'done' }); + const primary = [tool('boss', { stepRef: 'boss' }), primaryMarker, { ...primaryMarker }]; + const mirrors = [tool('worker', { stepRef: 'worker' }), mirrorMarker, { ...mirrorMarker }]; + const merged = mergeLatestTools(primary, mirrors, 'run'); + assert.deepEqual(merged.map(t => t.label), ['5 tool events omitted', 'boss', 'worker']); + assert.deepEqual(sanitizeToolLogForDurableStorage(merged).map(t => t.label), ['5 tool events omitted', 'boss', 'worker']); + assert.equal(primary[0]?.label, 'boss', 'inputs stay in their original order'); + assert.equal(mirrors[0]?.label, 'worker'); + assert.deepEqual(mergeLatestTools([], [mirrorMarker, { ...mirrorMarker }], 'run'), [mirrorMarker]); +}); diff --git a/tests/unit/native-acp-callbacks.test.ts b/tests/unit/native-acp-callbacks.test.ts new file mode 100644 index 000000000..9d4d4d14e --- /dev/null +++ b/tests/unit/native-acp-callbacks.test.ts @@ -0,0 +1,373 @@ +import test, { mock, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import express from 'express'; +import { AcpConnection } from '../../src/agent/runtime/acp/connection.ts'; +import { AcpCallbacks, type AcpRequestOwner } from '../../src/agent/runtime/acp/callbacks.ts'; +import { RuntimeRequests } from '../../src/agent/runtime/requests.ts'; +import { registerRuntimeRequestRoutes } from '../../src/routes/runtime-requests.ts'; +import { stringifyTraceValue } from '../../src/trace/redact.ts'; +import { decodeRuntimeBody } from '../../src/trace/runtime-body-codec.ts'; +import type { RuntimeEvent, RuntimeEventBody } from '../../src/shared/runtime-contract.ts'; + +const raw: unknown[] = [], published: RuntimeEvent[] = []; +let appendFails = false; +mock.module('../../src/trace/activity-journal.js', { namedExports: { markActivityFailure: () => {}, appendActivityBody: (entry: { raw: unknown }) => { + if (appendFails) return null; + raw.push(JSON.parse(stringifyTraceValue(entry.raw))); + return { traceRunId: 'not-an-identity-source', traceSeq: raw.length, detailAvailable: true, detailBytes: 100, rawRetentionStatus: 'available' }; +} } }); +mock.module('../../src/core/event-bus.js', { namedExports: { publish: (_topic: string, _event: string, data: RuntimeEvent) => { + published.push(data); +} } }); +const { recordRuntimeEvent } = await import('../../src/agent/runtime/events.ts'); +test.beforeEach(() => { raw.length = 0; published.length = 0; appendFails = false; }); + +const originalBinding = { runId: 'run', sessionId: 'chat', scope: 'mention-watch:scope', turnId: 'turn' }; +const options = [ + { optionId: 'CANARY_NATIVE_REJECT', name: 'Allow all', kind: 'reject_once' }, + { optionId: 'CANARY_NATIVE_ALLOW', name: 'Bearer CANARY_LABEL_SECRET_LONG', kind: 'allow_once' }, +]; +function params() { + return { sessionId: 'native', toolCall: { toolCallId: 'CANARY_NATIVE_TOOL', title: '{"password":"CANARY_TITLE_SECRET"}' }, options }; +} +function fixture(t: TestContext, permissions: unknown = 'safe') { + const changes = new EventEmitter(); + const writes: Record[] = [], attempts: RuntimeEventBody[] = [], failures: Error[] = []; + const callbacksToRelease: Array<() => void> = []; + const registry = new RuntimeRequests(); + let holdWrites = false, current = true, resolverThrows = false, writeFails = false; + let callbacks: AcpCallbacks; + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), stderr: new PassThrough(), + stdin: new Writable({ write(chunk, _encoding, callback) { + writes.push(JSON.parse(String(chunk))); + changes.emit('change'); + if (writeFails) callback(new Error('private-write-error')); + else if (holdWrites) callbacksToRelease.push(callback); else callback(); + } }), + }); + const connection = new AcpConnection(child as unknown as ChildProcessWithoutNullStreams, { + frame: frame => callbacks.handle(frame), + failed: error => { failures.push(error); callbacks?.dispose(); changes.emit('change'); }, + }); + function makeOwner(runId = 'run'): AcpRequestOwner { + const binding = { ...originalBinding, runId }; + const context = { ...binding, parentItemId: 'parent', audience: 'public' as const }; + return { nativeSessionId: 'native', binding, parentItemId: 'parent', isCurrent: () => current, + emit: body => { + attempts.push(body); + const event = recordRuntimeEvent(context, body); + changes.emit('change'); + return event; + } }; + } + let owner: AcpRequestOwner | null = makeOwner(); + callbacks = new AcpCallbacks(connection, { registry, permissions, getOwner: () => { + if (resolverThrows) throw new Error('private-resolver'); + return owner; + } }); + const waitFor = (predicate: () => boolean): Promise => { + if (predicate()) return Promise.resolve(); + return new Promise(resolve => { + const changed = () => { if (predicate()) { changes.off('change', changed); resolve(); } }; + changes.on('change', changed); + }); + }; + t.after(async () => { + callbacks.dispose(); connection.close(); + for (const release of callbacksToRelease.splice(0)) release(); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + await new Promise(resolve => setImmediate(resolve)); + changes.removeAllListeners(); + }); + return { registry, connection, callbacks, child, writes, attempts, failures, waitFor, + get owner() { return owner; }, + setOwner: (runId: string | null) => { owner = runId === null ? null : makeOwner(runId); }, + loseOwner: () => { current = false; }, breakResolver: () => { resolverThrows = true; }, + failWrites: () => { writeFails = true; }, + hold: () => { holdWrites = true; }, release: () => callbacksToRelease.shift()?.(), + request: (id: string | number, value: unknown = params(), method = 'session/request_permission') => { + child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, method, params: value }) + '\n'); + }, + }; +} +async function serve(t: TestContext, registry: RuntimeRequests) { + const app = express(); + app.use(express.json()); + registerRuntimeRequestRoutes(app, (req, res, next) => { + if (req.headers.authorization !== 'Bearer fixture-auth') { res.status(401).json({ error: 'Unauthorized' }); return; } + next(); + }, registry); + const server = app.listen(0, '127.0.0.1'); + await new Promise(resolve => server.once('listening', resolve)); + t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return `http://127.0.0.1:${address.port}`; +} +const auth = { authorization: 'Bearer fixture-auth', 'content-type': 'application/json' }; +function respond(f: ReturnType) { + const pending = f.registry.list('chat')[0]!; + f.registry.respond(pending.requestId, pending, { optionId: pending.view.fields[0]!.options[1]!.id }); + return pending; +} + +test('HTTP decision, public event, raw writer and replay share a safe view while the prompt stays pending', { timeout: 5000 }, async t => { + const f = fixture(t, ['read', 'mcp.*']); + const base = await serve(t, f.registry); + const prompt = f.connection.request('session/prompt', {}); + await prompt.dispatched; + let promptSettled = false; + void prompt.result.then(() => { promptSettled = true; }, () => { promptSettled = true; }); + f.request(prompt.id); // independent bidirectional ID collision + assert.equal(f.writes.length, 1); + const response = await fetch(base + '/api/runtime/requests?sessionId=chat', { headers: auth }); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(Object.hasOwn(body, 'requests'), false); + const pending = body.data.requests[0]; + const event = published.find(e => e.kind === 'request'); + assert.ok(event?.kind === 'request'); + assert.deepEqual(pending.view, event.view); + assert.deepEqual(decodeRuntimeBody(raw[0], { ...originalBinding, version: 1, seq: 1 }, 'request'), event); + assert.ok(!JSON.stringify([body, published, raw]).includes('CANARY')); + assert.equal(promptSettled, false); + const post = await fetch(base + '/api/runtime/requests/' + pending.requestId, { + method: 'POST', headers: auth, body: JSON.stringify({ ...originalBinding, + response: { optionId: pending.view.fields[0].options[1].id } }), + }); + assert.deepEqual(await post.json(), { ok: true, data: { accepted: true } }); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.deepEqual(f.writes[1], { jsonrpc: '2.0', id: prompt.id, + result: { outcome: { outcome: 'selected', optionId: 'CANARY_NATIVE_ALLOW' } } }); + assert.equal(f.writes.length, 2); + assert.equal(promptSettled, false); + f.child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: prompt.id, result: { stopReason: 'end_turn' } }) + '\n'); + assert.deepEqual(await prompt.result, { stopReason: 'end_turn' }); + assert.ok(!JSON.stringify([published, raw]).includes('CANARY')); +}); + +test('actual routes enforce supplied auth, body shape, exact binding and private handle membership', { timeout: 5000 }, async t => { + const f = fixture(t); + const base = await serve(t, f.registry); + f.request('peer'); + const pending = f.registry.list('chat')[0]!; + const url = base + '/api/runtime/requests/' + pending.requestId; + const valid = { ...originalBinding, response: { optionId: pending.view.fields[0]!.options[1]!.id } }; + assert.equal((await fetch(base + '/api/runtime/requests?sessionId=chat')).status, 401); + assert.equal((await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(valid) })).status, 401); + for (const query of ['', '?sessionId=', '?sessionId=chat&sessionId=other', '?sessionId=' + 'x'.repeat(241)]) { + assert.equal((await fetch(base + '/api/runtime/requests' + query, { headers: auth })).status, 400); + } + assert.deepEqual(await (await fetch(base + '/api/runtime/requests?sessionId=other', { headers: auth })).json(), { ok: true, data: { requests: [] } }); + for (const value of [{ ...valid, extra: true }, [], { ...valid, response: { optionId: 'CANARY_NATIVE_ALLOW' } }, + { ...originalBinding }, { ...valid, runId: '' }, { ...valid, response: { optionId: null, grant: true } }]) { + assert.equal((await fetch(url, { method: 'POST', headers: auth, body: JSON.stringify(value) })).status, 400); + } + for (const key of ['runId', 'sessionId', 'scope', 'turnId']) { + assert.equal((await fetch(url, { method: 'POST', headers: auth, body: JSON.stringify({ ...valid, [key]: 'foreign' }) })).status, 409); + } + assert.equal(f.writes.length, 0); + assert.equal(f.registry.list('chat').length, 1); + assert.equal((await fetch(url, { method: 'POST', headers: auth, body: JSON.stringify(valid) })).status, 200); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.equal((await fetch(url, { method: 'POST', headers: auth, body: JSON.stringify(valid) })).status, 409); + assert.equal(f.writes.length, 1); +}); + +test('auto uses protocol kinds and nullable titles without opening requests', { timeout: 5000 }, async t => { + const f = fixture(t, 'auto'); + f.request(1, { ...params(), toolCall: { toolCallId: 'tool', title: null } }); + await f.waitFor(() => f.writes.length === 1); + assert.equal(f.writes[0]!.result.outcome.optionId, 'CANARY_NATIVE_ALLOW'); + assert.deepEqual(f.registry.list('chat'), []); + assert.deepEqual(published, []); +}); + +for (const mode of ['safe', [], ['auto']] as const) test(`restrictive policy ${JSON.stringify(mode)} waits and cancels`, { timeout: 5000 }, async t => { + const f = fixture(t, mode); + f.request('peer'); + assert.equal(f.writes.length, 0); + assert.equal(f.registry.list('chat').length, 1); + f.callbacks.cancelRun('run'); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.equal(f.writes[0]!.result.outcome.outcome, 'cancelled'); +}); + +for (const dispose of [false, true]) test(`respond then ${dispose ? 'dispose' : 'cancelRun'} cannot admit a selected reply`, { timeout: 5000 }, async t => { + const f = fixture(t); + f.request('peer'); + respond(f); + if (dispose) f.callbacks.dispose(); else f.callbacks.cancelRun('run'); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.ok(f.writes.every(write => write.result?.outcome?.outcome !== 'selected')); + if (!dispose) assert.equal(f.writes[0]!.result.outcome.outcome, 'cancelled'); +}); +for (const dispose of [false, true]) test(`final ownership predicate may ${dispose ? 'dispose' : 'cancel'} without admitting selected`, { timeout: 5000 }, async t => { + const f = fixture(t); + let armed = false; + f.owner!.isCurrent = () => { + if (armed) { armed = false; if (dispose) f.callbacks.dispose(); else f.callbacks.cancelRun('run'); } + return true; + }; + f.request('peer'); respond(f); armed = true; + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.ok(f.writes.every(write => write.result?.outcome?.outcome !== 'selected')); +}); +for (const throws of [false, true]) test(`publication ${throws ? 'throw' : 'null'} after resolving an answer still cancels`, { timeout: 5000 }, async t => { + const f = fixture(t); + const emit = f.owner!.emit; + f.owner!.emit = body => { + if (body.kind === 'request') { + respond(f); + if (throws) throw new Error('private-publish-failure'); + return null; + } + return emit(body); + }; + f.request('peer'); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.equal(f.writes[0]!.result.outcome.outcome, 'cancelled'); +}); + +test('failed reply closes the connection and settles other pending callbacks without unhandled errors', { timeout: 5000 }, async t => { + const f = fixture(t); f.request('first'); f.request('second'); f.failWrites(); respond(f); + await f.waitFor(() => f.attempts.filter(e => e.kind === 'request-settled').length === 2); + assert.equal(f.connection.alive, false); + assert.equal(f.failures.length, 1); + assert.deepEqual(f.registry.list('chat'), []); + assert.ok(!JSON.stringify(f.failures.map(e => e.message)).includes('private-write-error')); +}); + +test('cancelling an admitted selected reply retires the connection without claiming bytes were unsent', { timeout: 5000 }, async t => { + const f = fixture(t); + f.request('peer'); f.hold(); respond(f); + await f.waitFor(() => f.writes.length === 1); + assert.equal(f.writes[0]!.result.outcome.outcome, 'selected'); // admitted before cancellation + f.callbacks.cancelRun('run'); + assert.equal(f.connection.alive, false); + f.release(); f.request('later'); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.equal(f.writes.length, 1); + assert.equal(f.failures.length, 1); +}); + +test('current-run cancellation fences late callbacks and an older cancellation cannot unlock a newer run', { timeout: 5000 }, async t => { + const f = fixture(t, 'auto'); + f.callbacks.cancelRun('run'); f.request('old'); + await f.waitFor(() => f.writes.length === 1); + assert.equal(f.writes[0]!.result.outcome.outcome, 'cancelled'); + f.setOwner('new'); f.request('new-normal'); + await f.waitFor(() => f.writes.length === 2); + assert.equal(f.writes[1]!.result.outcome.outcome, 'selected'); + await new Promise(resolve => setImmediate(resolve)); // already-observed write bookkeeping + f.callbacks.cancelRun('new'); f.callbacks.cancelRun('run'); f.request('new-cancelled'); + await f.waitFor(() => f.writes.length === 3); + assert.equal(f.writes[2]!.result.outcome.outcome, 'cancelled'); +}); + +test('lost ownership converts an already-recorded decision into cancellation', { timeout: 5000 }, async t => { + const f = fixture(t); f.request('peer'); respond(f); f.loseOwner(); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.equal(f.writes[0]!.result.outcome.outcome, 'cancelled'); +}); + +test('expired permission replies with cancellation exactly once', { timeout: 5000 }, async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 }); + const f = fixture(t); f.request('peer'); + t.mock.timers.tick(120_000); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.equal(f.writes[0]!.result.outcome.outcome, 'cancelled'); + assert.deepEqual(f.registry.list('chat'), []); +}); + +test('publication failure cancels the invisible request and releases private mapping', { timeout: 5000 }, async t => { + appendFails = true; + const f = fixture(t); f.request('peer'); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.equal(f.writes[0]!.result.outcome.outcome, 'cancelled'); + assert.deepEqual(f.registry.list('chat'), []); + assert.deepEqual(published, []); +}); + +test('32 callbacks fit and the next receives an error without another pending view', { timeout: 5000 }, async t => { + const f = fixture(t); + for (let id = 0; id < 33; id++) f.request(id); + await f.waitFor(() => f.writes.length === 1); + assert.equal(f.writes[0]!.id, 32); + assert.equal(f.writes[0]!.error.code, -32000); + assert.equal(f.registry.list('chat').length, 32); + f.callbacks.cancelRun('run'); + await f.waitFor(() => f.attempts.filter(e => e.kind === 'request-settled').length === 32); + assert.equal(f.writes.length, 33); + assert.ok(f.writes.slice(1).every(write => write.result.outcome.outcome === 'cancelled')); +}); + +test('duplicate peer ID retires instead of later sending a successful duplicate response', { timeout: 5000 }, async t => { + const f = fixture(t); f.request('duplicate'); f.request('duplicate'); + await f.waitFor(() => !f.connection.alive); + assert.equal(f.failures.length, 1); + assert.ok(f.writes.every(write => write.result?.outcome?.outcome !== 'selected')); + assert.deepEqual(f.registry.list('chat'), []); +}); + +test('unsupported host operations and malformed permission params only receive fixed errors', { timeout: 5000 }, async t => { + const f = fixture(t); + const methods = ['fs/read_text_file', 'fs/write_text_file', 'terminal/create', 'terminal/output', + 'terminal/wait_for_exit', 'terminal/kill', 'terminal/release', 'cursor/ask_question', 'cursor/create_plan']; + methods.forEach((method, id) => f.request(id, { sessionId: 'native', path: '/private/not-opened' }, method)); + f.request('bad', { ...params(), sessionId: 'foreign' }); + await f.waitFor(() => f.writes.length === 10); + assert.ok(f.writes.slice(0, 9).every(write => write.error.code === -32601)); + assert.equal(f.writes[9]!.error.code, -32602); + assert.deepEqual(f.registry.list('chat'), []); + assert.deepEqual(published, []); + assert.ok(!JSON.stringify(f.writes).includes('/private/')); +}); + +test('no owner refuses requests; throwing owner resolver retires safely', { timeout: 5000 }, async t => { + const f = fixture(t); f.setOwner(null); f.request('missing'); + await f.waitFor(() => f.writes.length === 1); + assert.equal(f.writes[0]!.error.code, -32600); + f.breakResolver(); f.request('broken'); + assert.equal(f.connection.alive, false); + assert.equal(f.failures[0]!.message, 'acp_callback_owner_failed'); +}); + +test('caller binding mutation cannot retag the captured request or settlement', { timeout: 5000 }, async t => { + const f = fixture(t); f.request('peer'); + f.owner!.binding.turnId = 'replacement'; + respond(f); + await f.waitFor(() => f.attempts.some(e => e.kind === 'request-settled')); + assert.ok(published.every(event => event.turnId === 'turn')); + assert.equal(f.writes[0]!.result.outcome.outcome, 'selected'); +}); + +test('drain waits for actual writes after registry settlement and for no-owner refusals', { timeout: 5000 }, async t => { + const f = fixture(t); f.request('peer'); f.hold(); respond(f); + await f.waitFor(() => f.writes.length === 1); + assert.deepEqual(f.registry.list('chat'), []); + let drained = false; + const drain = f.callbacks.drain('run').then(() => { drained = true; }); + await Promise.resolve(); + assert.equal(drained, false); + f.release(); await drain; + assert.equal(drained, true); + f.setOwner(null); f.request('no-owner'); + await f.waitFor(() => f.writes.length === 2); + drained = false; + const refusal = f.callbacks.drain().then(() => { drained = true; }); + await Promise.resolve(); + assert.equal(drained, false); + f.release(); await refusal; + assert.equal(drained, true); +}); +test('drain rejects retired or failed callback work rather than authorizing reuse', { timeout: 5000 }, async t => { + const f = fixture(t); f.request('peer'); f.failWrites(); respond(f); + await assert.rejects(f.callbacks.drain('run'), /acp_callback/); + assert.equal(f.connection.alive, false); + await assert.rejects(f.callbacks.drain(), /acp_callbacks_closed/); +}); diff --git a/tests/unit/native-acp-config.test.ts b/tests/unit/native-acp-config.test.ts new file mode 100644 index 000000000..e76a65d6d --- /dev/null +++ b/tests/unit/native-acp-config.test.ts @@ -0,0 +1,301 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { configureAcpModel, parseAcpSelectConfigs, type AcpConfigPort } from '../../src/agent/runtime/acp/config.ts'; + +const choice = (value: string, name = value) => ({ value, name }); +function select(id = 'model', currentValue = 'old', options: unknown = [choice('old'), choice('new')], + category: string | null = 'model', name = id) { + return { id, name, type: 'select', currentValue, options, category }; +} +const effort = (id = 'effort', category = 'thought_level', name = 'Reasoning effort') => + select(id, 'high', [choice('high'), choice('medium'), choice('extra-high')], category, name); + +function fixture(initial: unknown, update?: (id: string, value: string) => unknown | Promise) { + let snapshot: unknown = initial; + const writes: Array<[string, string]> = []; + let reads = 0; + const port: AcpConfigPort = { + getConfigOptions() { reads++; return snapshot; }, + async setConfigOption(id, value) { + writes.push([id, value]); + if (update) { snapshot = await update(id, value); return; } + assert.ok(Array.isArray(snapshot)); + snapshot = snapshot.map((entry: Record) => entry['id'] === id + ? { ...entry, currentValue: value } : entry); + }, + }; + return { port, writes, get reads() { return reads; }, get snapshot() { return snapshot; } }; +} + +test('parses copied flat and single-level grouped choices without rewriting wire identifiers', () => { + const raw = [select(' Model.ID ', 'old', [choice('old', ''), + { group: 'vendor', name: 'Vendor', options: [choice('new-high-thinking[fast=false]', 'New')] }, + ])]; + const parsed = parseAcpSelectConfigs(raw); + assert.deepEqual(parsed, [{ id: ' Model.ID ', name: ' Model.ID ', category: 'model', currentValue: 'old', + options: [{ value: 'old', name: '' }, { value: 'new-high-thinking[fast=false]', name: 'New' }] }]); + assert.notEqual(parsed[0], raw[0]); + assert.deepEqual(raw[0]?.options, [choice('old', ''), + { group: 'vendor', name: 'Vendor', options: [choice('new-high-thinking[fast=false]', 'New')] }]); +}); + +test('absent config snapshots and unknown config types remain unavailable', () => { + for (const value of [undefined, null, []]) assert.deepEqual(parseAcpSelectConfigs(value), []); + assert.deepEqual(parseAcpSelectConfigs([ + { id: 'fast', name: 'Fast', type: 'boolean', currentValue: false }, + { id: 'future', name: 'Future', type: 'future-type', currentValue: { private: 'ignored' } }, + ]), []); +}); + +test('rejects malformed shapes, identifiers, names, current values and choices', () => { + const malformed: unknown[] = [ + {}, 'options', true, 3, [null], [false], [[]], [Object.create({ id: 'model' })], + [select('', 'old')], [select(' \t', 'old')], [{ ...select(), id: 1 }], + [{ ...select(), name: null }], [{ ...select(), name: 'n'.repeat(1001) }], + [{ ...select(), type: false }], [{ ...select(), category: {} }], + [{ ...select(), currentValue: null }], [{ ...select(), currentValue: false }], + [{ ...select(), currentValue: '' }], [{ ...select(), currentValue: 'not-a-choice' }], + [{ ...select(), currentValue: 'v'.repeat(1025) }], + [select('model', 'old', {})], [select('model', 'old', [null])], + [select('model', 'old', [{ value: true, name: 'True' }])], + [select('model', 'old', [choice('')])], [select('model', 'old', [choice(' ')])], + [select('model', 'old', [{ value: 'old', name: 42 }])], + [select('model', 'old', [choice('old', 'n'.repeat(1001))])], + [select('model', 'old', [choice('old'), choice('v'.repeat(1025))])], + [select('model', 'old', [{ group: '', name: 'Group', options: [choice('old')] }])], + [select('model', 'old', [{ group: 'g', name: false, options: [choice('old')] }])], + [select('model', 'old', [{ group: 'g', name: 'Group', options: 'bad' }])], + [select('model', 'old', [{ group: 'g', name: 'Group', value: 'old', options: [] }])], + [select('model', 'old', [{ group: 'g', name: 'Group', options: [ + { group: 'nested', name: 'Nested', options: [choice('old')] }, + ] }])], + [{ id: 'effort', name: 'Effort', type: 'boolean', currentValue: 'true' }], + ]; + for (const raw of malformed) assert.throws(() => parseAcpSelectConfigs(raw), /^Error: acp_config_/); +}); + +test('rejects duplicate config IDs, group IDs and values including across groups', () => { + assert.throws(() => parseAcpSelectConfigs([select(), select()]), /acp_config_duplicate_id/); + assert.throws(() => parseAcpSelectConfigs([select(), { id: 'model', name: 'Future', type: 'future' }]), + /acp_config_duplicate_id/); + assert.throws(() => parseAcpSelectConfigs([select('model', 'old', [choice('old'), choice('old')])]), + /acp_config_duplicate_value/); + assert.throws(() => parseAcpSelectConfigs([select('model', 'old', [ + { group: 'a', name: 'A', options: [choice('old')] }, + { group: 'b', name: 'B', options: [choice('old')] }, + ])]), /acp_config_duplicate_value/); + assert.throws(() => parseAcpSelectConfigs([select('model', 'old', [ + { group: 'a', name: 'A', options: [choice('old')] }, + { group: 'a', name: 'A', options: [] }, + ])]), /acp_config_duplicate_group/); +}); + +test('enforces 64 configs, 2048 aggregate choices and 64 aggregate groups at exact boundaries', () => { + const configs = Array.from({ length: 64 }, (_, i) => select(`c${i}`, 'old', [choice('old')], null)); + assert.equal(parseAcpSelectConfigs(configs).length, 64); + assert.throws(() => parseAcpSelectConfigs([...configs, select('overflow')]), /acp_config_invalid_configs/); + const many = Array.from({ length: 1024 }, (_, i) => choice(`v${i}`)); + assert.equal(parseAcpSelectConfigs([select('a', 'v0', many), select('b', 'v0', many)]).length, 2); + assert.throws(() => parseAcpSelectConfigs([select('a', 'v0', many), select('b', 'v0', many), + select('c', 'v0', [choice('v0')])]), /acp_config_choice_limit/); + const groups = Array.from({ length: 32 }, (_, i) => ({ group: `g${i}`, name: 'G', options: [choice(`v${i}`)] })); + assert.equal(parseAcpSelectConfigs([select('a', 'v0', groups), select('b', 'v0', groups)]).length, 2); + assert.throws(() => parseAcpSelectConfigs([select('a', 'v0', groups), select('b', 'v0', groups), + select('c', 'v0', [groups[0]])]), /acp_config_group_limit/); + const long = 'x'.repeat(1024); + assert.equal(parseAcpSelectConfigs([select(long, long, [choice(long, 'n'.repeat(1000))], 'model', 'Model')])[0]?.id, long); + assert.throws(() => parseAcpSelectConfigs([select(`${long}x`)]), /acp_config_invalid_id/); +}); + +test('exact model value wins over labels and preserves suffixes, brackets, case and whitespace', async () => { + const value = ' Model-X-high-thinking-fast[reasoning=medium] '; + const f = fixture([select(' Model.Config ', 'old', [choice('old', value), choice(value, 'Friendly label')])]); + await configureAcpModel(f.port, { model: value }); + assert.deepEqual(f.writes, [[' Model.Config ', value]]); + for (const model of ['Friendly label', value.trim(), 'Model-X', 'default']) { + await assert.rejects(configureAcpModel(f.port, { model }), /acp_config_unsupported_model/); + } + assert.equal(f.writes.length, 1); +}); + +test('configures grouped model and effort choices through the public helper', async () => { + const f = fixture([ + select('model', 'old', [{ group: 'models', name: 'Models', options: [choice('old'), choice('new')] }]), + select('effort', 'low', [{ group: 'levels', name: 'Levels', options: [choice('low'), choice('wire-M', 'Medium')] }], + 'model_config'), + ]); + await configureAcpModel(f.port, { model: 'new', effort: 'MEDIUM' }); + assert.deepEqual(f.writes, [['model', 'new'], ['effort', 'wire-M']]); +}); + +test('the public helper refuses malformed and over-budget snapshots before any write', async () => { + const huge = Array.from({ length: 2049 }, (_, i) => choice(`v${i}`)); + for (const raw of [{}, [select(), { ...effort(), currentValue: false }], + Array.from({ length: 65 }, (_, i) => select(`model${i}`)), + [select('model', 'v0', huge)]]) { + const f = fixture(raw); + await assert.rejects(configureAcpModel(f.port, { model: 'new', effort: 'medium' }), /^Error: acp_config_/); + assert.deepEqual(f.writes, []); + } +}); + +test('model ID compatibility requires an advertised unambiguous exact model selector', async () => { + const f = fixture([select('model', 'old', undefined, null)]); + await configureAcpModel(f.port, { model: 'new' }); + assert.deepEqual(f.writes, [['model', 'new']]); + for (const raw of [[], [select('other', 'old', undefined, null, 'Model')], + [select(), select('second')], [select('other'), select('model', 'old', undefined, null)]]) { + const g = fixture(raw); + await assert.rejects(configureAcpModel(g.port, { model: 'new' }), /acp_config_(unsupported|ambiguous)_model/); + assert.deepEqual(g.writes, []); + } +}); + +test('empty requests retain provider defaults; explicit default is only an advertised value', async () => { + const f = fixture([select('model', 'composer-2.5', [choice('composer-2.5')])]); + for (const input of [{}, { model: '', effort: '' }, { model: null, effort: undefined }, { model: ' ', effort: '\t' }]) { + await configureAcpModel(f.port, input); + } + await configureAcpModel(f.port, { model: 'composer-2.5' }); + assert.deepEqual(f.writes, []); + await assert.rejects(configureAcpModel(f.port, { model: 'default' }), /acp_config_unsupported_model/); + const advertised = fixture([select('model', 'old', [choice('old'), choice('default')])]); + await configureAcpModel(advertised.port, { model: 'default' }); + assert.deepEqual(advertised.writes, [['model', 'default']]); + await configureAcpModel(fixture(undefined).port, {}); +}); + +test('awaits model write, then uses only refreshed effort selector/choices and verifies application', async () => { + let release: () => void = () => { throw new Error('gate not installed'); }; + const gate = new Promise(resolve => { release = resolve; }); + const fresh = [select('model', 'new'), select('fresh-effort', 'low', [choice('low'), choice('wire-X', 'Extra High')], + 'model_config', 'Reasoning')]; + const f = fixture([select(), effort('old-effort')], async (id, value) => { + if (id === 'model') { await gate; return fresh; } + assert.equal(id, 'fresh-effort'); + assert.equal(value, 'wire-X'); + return [select('model', 'new'), { ...fresh[1], currentValue: 'wire-X' }]; + }); + const pending = configureAcpModel(f.port, { model: 'new', effort: 'xhigh' }); + assert.deepEqual(f.writes, [['model', 'new']]); + assert.equal(f.reads, 1); + release(); + await pending; + assert.deepEqual(f.writes, [['model', 'new'], ['fresh-effort', 'wire-X']]); + assert.equal(f.reads, 3); +}); + +test('Composer 2.5 model change removes effort: explicit medium errors without an effort RPC', async () => { + const models = [choice('grok-4.6'), choice('composer-2.5')]; + const changed = [select('model', 'composer-2.5', models)]; + const f = fixture([select('model', 'grok-4.6', models), effort()], () => changed); + await assert.rejects(configureAcpModel(f.port, { model: 'composer-2.5', effort: 'medium' }), + /acp_config_unsupported_effort/); + assert.deepEqual(f.writes, [['model', 'composer-2.5']]); + assert.deepEqual(f.snapshot, changed); + await configureAcpModel(f.port, { model: 'composer-2.5', effort: '' }); + assert.equal(f.writes.length, 1); +}); + +test('accepts each supported effort selector shape and sends original option wire value', async () => { + const selectors = [effort('reasoning', 'model_option'), effort('reasoning_effort', 'model_config'), + effort('effort', ''), effort('opaque', 'thought_level', 'Depth'), + effort('opaque', 'MODEL_CONFIG', 'Reasoning Effort')]; + for (const config of selectors) { + const f = fixture([{ ...config, category: config.category || null }]); + await configureAcpModel(f.port, { effort: 'Extra_HIGH' }); + assert.deepEqual(f.writes, [[config.id, 'extra-high']]); + } +}); + +test('recognizes only exact known normalized effort IDs or names in legacy/standard categories', async () => { + for (const category of ['model_option', 'model_config']) { + for (const label of ['Effort', 'Reasoning', 'Reasoning Effort', 'reasoning-level', 'THINKING_LEVEL', 'Thought Level']) { + for (const [id, name] of [[label, 'Depth'], ['opaque', label]]) { + const f = fixture([effort(id, category, name)]); + await configureAcpModel(f.port, { effort: 'medium' }); + assert.deepEqual(f.writes, [[id, 'medium']]); + } + } + } +}); + +test('summary-only and verbosity-only selectors never receive an effort RPC', async () => { + for (const category of ['model_option', 'model_config']) { + for (const [id, name] of [['reasoning-summary', 'Reasoning Summary'], ['reasoning-verbosity', 'Reasoning Verbosity']]) { + const f = fixture([select(id, 'low', [choice('low'), choice('high')], category, name)]); + await assert.rejects(configureAcpModel(f.port, { effort: 'high' }), + { message: 'acp_config_unsupported_effort' }); + assert.deepEqual(f.writes, []); + } + } +}); + +test('normalizes known effort case/separators but preserves exact opaque future values', async () => { + for (const requested of ['xhigh', 'XHIGH', 'extra-high', 'Extra High', 'extra_high', 'extraHigh']) { + const f = fixture([select('effort', 'low', [choice('low'), choice('EXTRA_HIGH')], 'thought_level')]); + await configureAcpModel(f.port, { effort: requested }); + assert.deepEqual(f.writes, [['effort', 'EXTRA_HIGH']]); + } + const f = fixture([select('effort', 'low', [choice('low'), choice('Custom-Depth')], 'thought_level')]); + await assert.rejects(configureAcpModel(f.port, { effort: 'customdepth' }), /acp_config_unsupported_effort/); + await configureAcpModel(f.port, { effort: 'Custom-Depth' }); + assert.deepEqual(f.writes, [['effort', 'Custom-Depth']]); +}); + +test('refuses missing/unsupported/boolean effort and ambiguous selectors or normalized choices', async () => { + for (const raw of [[], [select()], [{ id: 'effort', name: 'Effort', type: 'boolean', currentValue: true }], + [effort('reasoning', 'unrecognized')]]) { + const f = fixture(raw); + await assert.rejects(configureAcpModel(f.port, { effort: 'medium' }), /acp_config_unsupported_effort/); + assert.deepEqual(f.writes, []); + } + const ambiguous = fixture([effort('legacy', 'model_option'), effort('effort', 'model_config')]); + await assert.rejects(configureAcpModel(ambiguous.port, { effort: 'high' }), /acp_config_ambiguous_effort/); + const values = fixture([select('effort', 'low', [choice('low'), choice('xhigh'), choice('extra-high')], 'thought_level')]); + await assert.rejects(configureAcpModel(values.port, { effort: 'xhigh' }), /acp_config_ambiguous_effort_value/); + const names = fixture([select('effort', 'low', [choice('low'), choice('wire1', 'Medium'), choice('wire2', 'MEDIUM')], 'thought_level')]); + await assert.rejects(configureAcpModel(names.port, { effort: 'medium' }), /acp_config_ambiguous_effort_value/); + const unsupported = fixture([effort()]); + await assert.rejects(configureAcpModel(unsupported.port, { effort: 'ultra' }), /acp_config_unsupported_effort/); + for (const f of [ambiguous, values, names, unsupported]) assert.deepEqual(f.writes, []); +}); + +test('skips only confirmed current selections without emitting redundant writes', async () => { + const f = fixture([select(), effort()]); + await configureAcpModel(f.port, { model: 'old', effort: 'HIGH' }); + assert.deepEqual(f.writes, []); + await configureAcpModel(f.port, { effort: 'medium' }); + await configureAcpModel(f.port, { effort: 'Medium' }); + assert.deepEqual(f.writes, [['effort', 'medium']]); +}); + +test('does not report success for ignored model/effort writes or effort-induced model drift', async () => { + const initial = [select(), effort()]; + const ignoredModel = fixture(initial, () => initial); + await assert.rejects(configureAcpModel(ignoredModel.port, { model: 'new', effort: 'medium' }), /acp_config_model_not_applied/); + assert.deepEqual(ignoredModel.writes, [['model', 'new']]); + const ignoredEffort = fixture(initial, () => initial); + await assert.rejects(configureAcpModel(ignoredEffort.port, { effort: 'medium' }), /acp_config_effort_not_applied/); + const drift = fixture(initial, () => [select('model', 'new'), { ...effort(), currentValue: 'medium' }]); + await assert.rejects(configureAcpModel(drift.port, { model: 'old', effort: 'medium' }), /acp_config_model_not_applied/); +}); + +test('malformed refreshed metadata and rejected writes stop configuration without fallback or retry', async () => { + const f = fixture([select(), effort()], () => [{ ...select(), currentValue: false }]); + await assert.rejects(configureAcpModel(f.port, { model: 'new', effort: 'medium' }), /acp_config_invalid_current/); + assert.deepEqual(f.writes, [['model', 'new']]); + const failure = new Error('transport_closed'); + const g = fixture([select(), effort()], () => { throw failure; }); + await assert.rejects(configureAcpModel(g.port, { model: 'new', effort: 'medium' }), error => error === failure); + assert.deepEqual(g.writes, [['model', 'new']]); +}); + +test('invalid explicit requests fail before writes; errors never interpolate provider metadata', async () => { + const f = fixture([select(), effort()]); + await assert.rejects(configureAcpModel(f.port, { model: 'x'.repeat(1025) }), /acp_config_invalid_requested_model/); + await assert.rejects(configureAcpModel(f.port, { model: 'new', effort: 'x'.repeat(1025) }), /acp_config_invalid_requested_effort/); + assert.deepEqual(f.writes, []); + assert.throws(() => parseAcpSelectConfigs([{ ...select(), id: 'SECRET'.repeat(200) }]), + { message: 'acp_config_invalid_id' }); +}); diff --git a/tests/unit/native-acp-connection.test.ts b/tests/unit/native-acp-connection.test.ts new file mode 100644 index 000000000..f95565212 --- /dev/null +++ b/tests/unit/native-acp-connection.test.ts @@ -0,0 +1,402 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { AcpConnection } from '../../src/agent/runtime/acp/connection.ts'; +import { decodeFrame, type RpcFrame } from '../../src/agent/runtime/acp/wire.ts'; + +const FRAME_LIMIT = 4 * 1024 * 1024; +type Callback = (error?: Error | null) => void; +function fixture(t: TestContext, options: { + hold?: boolean; writeError?: boolean; writeThrow?: boolean; + frame?: (frame: RpcFrame) => void; failed?: () => void; +} = {}) { + const frames: RpcFrame[] = [], failures: Error[] = [], writes: string[] = [], callbacks: Callback[] = []; + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), stderr: new PassThrough(), + stdin: new Writable({ highWaterMark: 1, write(chunk, _encoding, callback) { + writes.push(String(chunk)); + if (options.writeThrow) throw new Error('sensitive-write-exception'); + if (options.hold) callbacks.push(callback); + else callback(options.writeError ? new Error('sensitive-EPIPE') : undefined); + } }), + }); + const connection = new AcpConnection(child as unknown as ChildProcessWithoutNullStreams, { + frame: frame => { frames.push(frame); options.frame?.(frame); }, + failed: error => { failures.push(error); options.failed?.(); }, + }); + t.after(() => { + connection.close(); + for (const callback of callbacks.splice(0)) callback(); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + }); + return { connection, child, frames, failures, writes, callbacks, + feed: (value: unknown) => child.stdout.write(JSON.stringify(value) + '\n') }; +} +function frameOfSize(size: number): RpcFrame { + const frame = { jsonrpc: '2.0' as const, method: 'session/update', params: '' }; + frame.params = 'x'.repeat(size - Buffer.byteLength(JSON.stringify(frame))); + return frame; +} +const tick = () => new Promise(resolve => setImmediate(resolve)); + +test('reassembles split UTF-8 bytes, CRLF, blank lines and coalesced frames', t => { + const f = fixture(t); + const frame = { jsonrpc: '2.0', method: 'session/update', params: { text: '한글 🌱' } }; + const bytes = Buffer.from(JSON.stringify(frame) + '\r\n'); + for (const byte of bytes) f.child.stdout.write(Buffer.from([byte])); + f.child.stdout.write('\n \r\n' + JSON.stringify(frame) + '\n' + JSON.stringify(frame) + '\n'); + assert.deepEqual(f.frames, [frame, frame, frame]); + assert.equal(f.connection.alive, true); +}); + +test('fragmented carry handles tiny chunks without changing content', t => { + const f = fixture(t); + const frame = frameOfSize(65_536); + const bytes = Buffer.from(JSON.stringify(frame) + '\n'); + for (let i = 0; i < bytes.length; i++) f.child.stdout.emit('data', bytes.subarray(i, i + 1)); + assert.deepEqual(f.frames, [frame]); +}); + +const invalidFrames = [ + ['json', '{"secret":"private-frame-sentinel"'], ['scalar', 'true'], ['batch', '[]'], + ['version', '{"jsonrpc":"1.0","method":"event"}'], + ['null id', '{"jsonrpc":"2.0","id":null,"result":null}'], + ['fractional id', '{"jsonrpc":"2.0","id":1.2,"result":null}'], + ['unsafe id', '{"jsonrpc":"2.0","id":9007199254740993,"result":null}'], + ['empty method', '{"jsonrpc":"2.0","method":""}'], + ['method and result', '{"jsonrpc":"2.0","id":1,"method":"event","result":null}'], + ['missing id', '{"jsonrpc":"2.0","result":null}'], + ['missing result', '{"jsonrpc":"2.0","id":1}'], + ['ambiguous response', '{"jsonrpc":"2.0","id":1,"result":null,"error":{"code":1,"message":"x"}}'], + ['malformed error', '{"jsonrpc":"2.0","id":1,"error":{"code":"bad","message":"private-frame-sentinel"}}'], +] as const; +for (const [label, line] of invalidFrames) test(`invalid ${label} retires pending work without exposing payload`, async t => { + const f = fixture(t); + const request = f.connection.request('session/prompt', {}); + await request.dispatched; + f.child.stdout.write(line + '\n'); + await assert.rejects(request.result, /^Error: acp_invalid_/); + assert.equal(f.connection.alive, false); + assert.equal(f.failures.length, 1); + assert.ok(!f.failures[0]!.message.includes('private-frame-sentinel')); + assert.equal(f.child.stdout.listenerCount('data'), 0); +}); + +test('invalid UTF-8 fails closed instead of replacement-character decoding', t => { + const f = fixture(t); + f.child.stdout.write(Buffer.from([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xc3, 0x28, 0x22, 0x7d, 10])); + assert.equal(f.failures[0]?.message, 'acp_invalid_utf8'); + assert.equal(f.frames.length, 0); +}); + +for (const partial of [false, true]) test(`stdout EOF ${partial ? 'with incomplete frame' : 'without frame'} settles requests`, async t => { + const f = fixture(t); + const request = f.connection.request('session/prompt', {}); + await request.dispatched; + if (partial) f.child.stdout.write('{"jsonrpc":'); + f.child.stdout.end(); + await assert.rejects(request.result, partial ? /acp_truncated_frame/ : /acp_stdout_end/); + assert.equal(f.failures.length, 1); +}); + +test('exact frame limit is accepted inbound and outbound', async t => { + const f = fixture(t); + const frame = frameOfSize(FRAME_LIMIT); + f.feed(frame); + assert.deepEqual(f.frames, [frame]); + await f.connection.write(frame); + assert.equal(Buffer.byteLength(f.writes[0]!), FRAME_LIMIT + 1); + assert.equal(f.connection.alive, true); +}); +for (const split of [false, true]) test(`exact-limit CRLF payload is accepted ${split ? 'across delimiter chunks' : 'in one chunk'}`, t => { + const f = fixture(t); + const frame = frameOfSize(FRAME_LIMIT); + if (split) { + f.child.stdout.write(JSON.stringify(frame)); + f.child.stdout.write('\r'); + f.child.stdout.write('\n'); + } else f.child.stdout.write(JSON.stringify(frame) + '\r\n'); + assert.equal(f.connection.alive, true); + assert.deepEqual(f.frames, [frame]); +}); +test('CRLF does not permit a payload byte beyond the limit', t => { + const f = fixture(t); + f.child.stdout.write(JSON.stringify(frameOfSize(FRAME_LIMIT + 1)) + '\r\n'); + assert.equal(f.failures[0]?.message, 'acp_frame_limit'); + assert.equal(f.frames.length, 0); +}); +for (const newline of [false, true]) test(`oversized ${newline ? 'complete' : 'incomplete'} input fails before delivery`, t => { + const f = fixture(t); + f.child.stdout.write(Buffer.from('x'.repeat(FRAME_LIMIT + 1) + (newline ? '\n' : ''))); + assert.equal(f.failures[0]?.message, 'acp_frame_limit'); + assert.equal(f.frames.length, 0); +}); +test('fragmented input crossing the limit retires and drops the remainder', t => { + const f = fixture(t); + f.child.stdout.write(Buffer.alloc(FRAME_LIMIT, 32)); + assert.equal(f.connection.alive, true); + f.child.stdout.write('x\n{"jsonrpc":"2.0","method":"never"}\n'); + assert.equal(f.failures[0]?.message, 'acp_frame_limit'); + assert.equal(f.frames.length, 0); +}); + +test('peer callbacks and updates interleave with a pending prompt despite equal IDs', async t => { + const replies: Promise[] = []; + const f = fixture(t, { frame(frame) { + if ('method' in frame && 'id' in frame) replies.push(f.connection.reply(frame.id, { accepted: true })); + } }); + const request = f.connection.request('session/prompt', {}); + await request.dispatched; + let finished = false; + void request.result.then(() => { finished = true; }); + f.feed({ jsonrpc: '2.0', id: request.id, method: 'session/request_permission', params: {} }); + f.feed({ jsonrpc: '2.0', method: 'session/update', params: { text: 'pending' } }); + await Promise.all(replies); + assert.equal(finished, false); + assert.equal(f.frames.length, 2); + assert.deepEqual(JSON.parse(f.writes[1]!), { jsonrpc: '2.0', id: request.id, result: { accepted: true } }); + f.feed({ jsonrpc: '2.0', id: request.id, result: null }); + assert.equal(await request.result, null); + f.feed({ jsonrpc: '2.0', id: request.id, result: 'duplicate' }); + f.feed({ jsonrpc: '2.0', id: 'unknown', result: 'late' }); + assert.equal(f.frames.length, 2); +}); + +test('RPC error rejects only that request with code-only diagnostics', async t => { + const f = fixture(t); + const a = f.connection.request('a', {}), b = f.connection.request('b', {}); + await Promise.all([a.dispatched, b.dispatched]); + f.feed({ jsonrpc: '2.0', id: a.id, error: { code: -32001, message: 'private-frame-sentinel', data: { token: 'private' } } }); + await assert.rejects(a.result, { message: 'acp_rpc_error:-32001' }); + assert.equal(f.connection.alive, true); + f.feed({ jsonrpc: '2.0', id: b.id, result: { ok: true } }); + assert.deepEqual(await b.result, { ok: true }); +}); + +test('64 pending requests are admitted; the next rejects without corrupting them', async t => { + const f = fixture(t); + const requests = Array.from({ length: 64 }, () => f.connection.request('probe', {})); + assert.throws(() => f.connection.request('overflow', {}), /acp_unavailable/); + await Promise.all(requests.map(r => r.dispatched)); + assert.equal(f.connection.alive, true); + for (const request of requests) f.feed({ jsonrpc: '2.0', id: request.id, result: request.id }); + assert.deepEqual(await Promise.all(requests.map(r => r.result)), requests.map(r => r.id)); +}); +test('invalid request timeout does not write or retire a valid connection', t => { + const f = fixture(t); + for (const ms of [0, -1, 0.5, NaN, Infinity, 2_147_483_648]) { + assert.throws(() => f.connection.request('probe', {}, ms), /acp_invalid_timeout/); + } + assert.equal(f.writes.length, 0); + assert.equal(f.connection.alive, true); +}); + +test('backpressure keeps FIFO cancel before replacement without a cancel request ID', async t => { + const f = fixture(t, { hold: true }); + const cancel = f.connection.notify('session/cancel', { sessionId: 'fixture' }); + const next = f.connection.request('session/prompt', { prompt: 'next' }); + let dispatched = false; + void next.dispatched.then(() => { dispatched = true; }); + await Promise.resolve(); + assert.equal(f.writes.length, 1); + assert.equal(dispatched, false); + assert.equal(Object.hasOwn(JSON.parse(f.writes[0]!), 'id'), false); + f.callbacks.shift()!(); + await cancel; + assert.equal(f.writes.length, 2); + f.callbacks.shift()!(); + await next.dispatched; + assert.equal(JSON.parse(f.writes[1]!).method, 'session/prompt'); + f.feed({ jsonrpc: '2.0', id: next.id, result: { stopReason: 'end_turn' } }); + await next.result; +}); + +test('request timeout rejects active and queued writes once; late callbacks cannot revive it', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(t, { hold: true }); + const a = f.connection.request('session/prompt', {}, 50); + const b = f.connection.request('initialize', {}, 500); + t.mock.timers.tick(50); + await Promise.all([a.dispatched, a.result, b.dispatched, b.result].map(p => assert.rejects(p, /acp_timeout/))); + f.callbacks.shift()!(); + f.feed({ jsonrpc: '2.0', id: a.id, result: 'late' }); + f.child.emit('error', new Error('late')); f.child.emit('exit', 1, null); + f.child.stdin.emit('error', new Error('late-EPIPE')); f.child.stdout.emit('error', new Error('late-read')); + assert.equal(f.failures.length, 1); + assert.equal(f.writes.length, 1); + assert.throws(() => f.connection.request('never', {}), /acp_unavailable/); + await assert.rejects(f.connection.notify('never', {}), /acp_closed/); +}); + +test('standalone and queued notification/reply writes have independent deadlines', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(t, { hold: true }); + const a = f.connection.notify('session/cancel', {}), b = f.connection.reply('peer', null); + t.mock.timers.tick(29_999); + assert.equal(f.connection.alive, true); + t.mock.timers.tick(1); + await Promise.all([a, b].map(p => assert.rejects(p, /acp_write_timeout/))); + assert.equal(f.failures.length, 1); +}); +test('response before write callback does not remove the dispatch deadline', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(t, { hold: true }); + const request = f.connection.request('session/prompt', {}, 60_000); + f.feed({ jsonrpc: '2.0', id: request.id, result: 'already completed' }); + assert.equal(await request.result, 'already completed'); + t.mock.timers.tick(30_000); + await assert.rejects(request.dispatched, /acp_write_timeout/); + assert.equal(await request.result, 'already completed'); + assert.equal(f.failures.length, 1); +}); +test('queued write deadline starts on admission rather than when it becomes active', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(t, { hold: true }); + const a = f.connection.notify('first', {}); + t.mock.timers.tick(10_000); + const b = f.connection.notify('second', {}); + t.mock.timers.tick(10_000); + f.callbacks.shift()!(); + await a; + t.mock.timers.tick(19_999); + assert.equal(f.connection.alive, true); + t.mock.timers.tick(1); + assert.equal(f.connection.alive, false); + await assert.rejects(b, /acp_write_timeout/); +}); + +for (const mode of ['error', 'throw', 'exit', 'stdin', 'stdout', 'closed-stdin'] as const) { + test(`${mode} before result await settles original promises without unhandled rejection`, async t => { + const f = fixture(t, { hold: mode !== 'error' && mode !== 'throw', writeError: mode === 'error', writeThrow: mode === 'throw' }); + if (mode === 'closed-stdin') f.child.stdin.end(); + const request = f.connection.request('session/prompt', {}); + if (mode === 'exit') f.child.emit('exit', 1, null); + if (mode === 'stdin' || mode === 'stdout') f.child[mode].emit('error', new Error('private-frame-sentinel')); + await assert.rejects(request.dispatched, /^Error: acp_/); + await tick(); // deliberately observe the original result one event-loop turn later + await assert.rejects(request.result, /^Error: acp_/); + assert.equal(f.failures.length, 1); + assert.ok(!f.failures[0]!.message.includes('private-frame-sentinel')); + }); +} + +test('8MiB queued-write byte boundary is exact and overflow settles all writes', async t => { + const f = fixture(t, { hold: true }); + const frame = frameOfSize(FRAME_LIMIT - 1); // LF makes each write exactly4MiB + const a = f.connection.write(frame), b = f.connection.write(frame); + assert.equal(f.connection.alive, true); + const c = f.connection.notify('overflow', {}); + await Promise.all([a, b, c].map(p => assert.rejects(p, /acp_write_limit/))); + assert.equal(f.failures.length, 1); + assert.equal(f.writes.length, 1); +}); +test('1024 tiny queued writes fit; entry1025 retires them all', async t => { + const f = fixture(t, { hold: true }); + const writes = Array.from({ length: 1024 }, () => f.connection.notify('event', {})); + assert.equal(f.connection.alive, true); + writes.push(f.connection.notify('overflow', {})); + await Promise.all(writes.map(p => assert.rejects(p, /acp_write_limit/))); + assert.equal(f.failures.length, 1); +}); +test('oversized outgoing frame is rejected before touching stdin', async t => { + const f = fixture(t); + await assert.rejects(f.connection.write(frameOfSize(FRAME_LIMIT + 1)), /acp_write_limit/); + assert.equal(f.writes.length, 0); +}); + +for (const value of [1n, (() => { const x: unknown[] = []; x.push(x); return x; })()]) { + test('serialization failure settles a registered request without provider data in diagnostics', async t => { + const f = fixture(t); + const request = f.connection.request('session/prompt', value); + await assert.rejects(request.dispatched, /acp_serialize_failed/); + await tick(); + await assert.rejects(request.result, /acp_serialize_failed/); + assert.equal(f.writes.length, 0); + assert.equal(f.failures.length, 1); + }); +} +test('undefined result cannot serialize into a malformed outgoing response', async t => { + const f = fixture(t); + await assert.rejects(f.connection.reply('peer', undefined), /acp_invalid_outgoing_frame/); + assert.equal(f.writes.length, 0); +}); +test('root toJSON returning undefined retires all pending work with sanitized rejection', async t => { + const f = fixture(t); + const request = f.connection.request('session/prompt', {}); + await request.dispatched; + const frame = { jsonrpc: '2.0' as const, id: 'peer', result: null, toJSON: () => undefined }; + await assert.rejects(async () => f.connection.write(frame), /acp_serialize_failed/); + await assert.rejects(request.result, /acp_serialize_failed/); + assert.equal(f.writes.length, 1); + assert.equal(f.failures.length, 1); +}); +test('serialization that closes the connection cannot admit a write after close', async t => { + const f = fixture(t); + const request = f.connection.request('session/prompt', { toJSON() { f.connection.close(); return {}; } }); + await assert.rejects(request.dispatched, /acp_closed/); + await assert.rejects(request.result, /acp_closed/); + assert.equal(f.writes.length, 0); + assert.equal(f.failures.length, 1); +}); + +test('frame hook close stops the rest of a coalesced chunk', t => { + const f = fixture(t, { frame: () => f.connection.close() }); + f.child.stdout.write('{"jsonrpc":"2.0","method":"first"}\n{"jsonrpc":"2.0","method":"second"}\n'); + assert.equal(f.frames.length, 1); + assert.equal(f.failures.length, 1); +}); +test('throwing frame and failure hooks do not escape the transport handler', t => { + const f = fixture(t, { frame: () => { throw new Error('private-frame-sentinel'); }, failed: () => { throw new Error('owner failed'); } }); + assert.doesNotThrow(() => f.feed({ jsonrpc: '2.0', method: 'event' })); + assert.equal(f.failures[0]?.message, 'acp_frame_hook_failed'); + f.connection.close(); + assert.equal(f.failures.length, 1); +}); +test('refusal preserves peer ID and error code on wire', async t => { + const f = fixture(t); + await f.connection.refuse(7, -32601, 'Unsupported method'); + assert.deepEqual(decodeFrame(f.writes[0]!), { jsonrpc: '2.0', id: 7, error: { code: -32601, message: 'Unsupported method' } }); +}); + +test('matched response observer runs before the next frame in a coalesced chunk and before promise jobs', async t => { + const order: string[] = []; + const f = fixture(t, { frame: () => { order.push('notification'); } }); + const request = f.connection.request('session/prompt', {}, 1000, () => { order.push('response'); }); + await request.dispatched; + const done = request.result.then(() => { order.push('promise'); }); + f.child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: null }) + '\n' + + '{"jsonrpc":"2.0","method":"session/update"}\n'); + assert.deepEqual(order, ['response', 'notification']); + await done; + assert.deepEqual(order, ['response', 'notification', 'promise']); +}); +test('unmatched and reentrant duplicate replies do not invoke the response observer twice', async t => { + const f = fixture(t); + let count = 0; + const request = f.connection.request('session/prompt', {}, 1000, () => { + count++; + f.feed({ jsonrpc: '2.0', id: request.id, result: 'reentrant' }); + }); + await request.dispatched; + f.feed({ jsonrpc: '2.0', id: 'unknown', result: null }); + assert.equal(count, 0); + f.feed({ jsonrpc: '2.0', id: request.id, result: 'first' }); + assert.equal(await request.result, 'first'); + f.feed({ jsonrpc: '2.0', id: request.id, result: 'duplicate' }); + assert.equal(count, 1); +}); +for (const throws of [false, true]) test(`response observer ${throws ? 'throw' : 'close'} rejects its still-registered request`, async t => { + const f = fixture(t); + const request = f.connection.request('session/prompt', {}, 1000, () => { + if (throws) throw new Error('private observer text'); + f.connection.close(); + }); + const other = f.connection.request('other', {}); + await Promise.all([request.dispatched, other.dispatched]); + f.feed({ jsonrpc: '2.0', id: request.id, result: null }); + await assert.rejects(request.result, throws ? /acp_response_observer_failed/ : /acp_closed/); + await assert.rejects(other.result, /^Error: acp_/); + assert.equal(f.failures.length, 1); +}); diff --git a/tests/unit/native-acp-notification-queue.test.ts b/tests/unit/native-acp-notification-queue.test.ts new file mode 100644 index 000000000..028d4c05f --- /dev/null +++ b/tests/unit/native-acp-notification-queue.test.ts @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { AcpNotificationQueue } from '../../src/agent/runtime/acp/notification-queue.ts'; +import type { RpcFrame } from '../../src/agent/runtime/acp/wire.ts'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} +const frame = (text = 'value'): RpcFrame => ({ jsonrpc: '2.0', method: 'session/update', params: { text } }); + +test('notification work is serial and terminal sealing still drains previously admitted work', async () => { + const started = deferred(), held = deferred(), order: string[] = []; + const queue = new AcpNotificationQueue(() => assert.fail('unexpected queue failure')); + queue.enqueue(frame(), async () => { order.push('first'); started.resolve(); await held.promise; order.push('first-done'); }); + queue.enqueue(frame(), () => { order.push('second'); }); + await started.promise; + assert.deepEqual(order, ['first']); + queue.seal(); + assert.throws(() => queue.enqueue(frame(), () => {}), /acp_notification_after_terminal/); + held.resolve(); + await queue.drain(); + assert.deepEqual(order, ['first', 'first-done', 'second']); + assert.equal(queue.idle, true); +}); +test('256 queued notifications fit and the next fails closed before retaining more', async () => { + const failures: string[] = [], consumed: number[] = []; + const queue = new AcpNotificationQueue(error => failures.push(error.message)); + for (let i = 0; i < 256; i++) queue.enqueue(frame(), () => { consumed.push(i); }); + assert.equal(failures.length, 0); + queue.enqueue(frame(), () => { consumed.push(256); }); + await assert.rejects(queue.drain(), /acp_notification_limit/); + assert.deepEqual(failures, ['acp_notification_limit']); + assert.deepEqual(consumed, []); + assert.equal(queue.idle, true); +}); +test('notification byte admission includes active and queued envelopes', async () => { + const held = deferred(), started = deferred(); + const failures: string[] = []; + const queue = new AcpNotificationQueue(error => failures.push(error.message)); + const base = frame(''); + const part = frame('x'.repeat(4 * 1024 * 1024 - Buffer.byteLength(JSON.stringify(base)))); + queue.enqueue(part, async (_frame, signal) => { + started.resolve(); signal.addEventListener('abort', held.resolve, { once: true }); await held.promise; + }); + await started.promise; + queue.enqueue(part, () => {}); + assert.equal(failures.length, 0); + queue.enqueue(frame(), () => {}); + await assert.rejects(queue.drain(), /acp_notification_limit/); + assert.deepEqual(failures, ['acp_notification_limit']); +}); +test('close aborts a stalled consumer and queued work cannot apply later', async () => { + const started = deferred(), held = deferred(); + let applied = 0, signal: AbortSignal | undefined; + const queue = new AcpNotificationQueue(() => assert.fail('intentional close is not another failure')); + queue.enqueue(frame(), async (_frame, current) => { + signal = current; started.resolve(); await held.promise; + if (!current.aborted) applied++; + }); + queue.enqueue(frame(), () => { applied++; }); + await started.promise; + const drain = queue.drain(); + queue.close(); + assert.equal(signal?.aborted, true); + await assert.rejects(drain, /acp_notification_closed/); + held.resolve(); + await Promise.resolve(); + assert.equal(applied, 0); + assert.equal(queue.idle, true); +}); +test('consumer failure is fixed-code, settles drain and reports once', async () => { + const failures: string[] = []; + const queue = new AcpNotificationQueue(error => failures.push(error.message)); + queue.enqueue(frame(), () => { throw new Error('private consumer text'); }); + await assert.rejects(queue.drain(), /acp_notification_consumer_failed/); + queue.close(); + assert.deepEqual(failures, ['acp_notification_consumer_failed']); +}); +test('invalid or reentrantly closed serialization cannot be retained', async () => { + const failures: string[] = []; + const queue = new AcpNotificationQueue(error => failures.push(error.message)); + const invalid = { ...frame(), toJSON: () => undefined }; + queue.enqueue(invalid, () => assert.fail('invalid notification consumed')); + await assert.rejects(queue.drain(), /acp_invalid_notification/); + const other = new AcpNotificationQueue(() => {}); + const closing = { ...frame(), toJSON() { other.close(); return frame(); } }; + assert.throws(() => other.enqueue(closing, () => assert.fail('closed notification consumed')), /acp_notification_after_terminal/); + assert.equal(other.idle, true); +}); diff --git a/tests/unit/native-acp-permissions.test.ts b/tests/unit/native-acp-permissions.test.ts new file mode 100644 index 000000000..4f8231da1 --- /dev/null +++ b/tests/unit/native-acp-permissions.test.ts @@ -0,0 +1,208 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + automaticPermission, normalizeNativePermissions, permissionOptions, permissionResponse, + preparePermissionRequest, validatedPermissionParams, + type AcpPermissionOption, type AcpPermissionResponse, +} from '../../src/agent/runtime/acp/permissions.ts'; + +function option(overrides: Partial = {}): AcpPermissionOption { + return { optionId: 'native-once-private', name: 'Allow once', kind: 'allow_once', ...overrides }; +} +function params(overrides: Record = {}) { + return { sessionId: 'native-session', toolCall: { toolCallId: 'native-tool' }, options: [option()], ...overrides }; +} +const cancelled: AcpPermissionResponse = { outcome: { outcome: 'cancelled' } }; + +test('options reject malformed collections, duplicates, sparse items and over-limit cardinality', () => { + for (const value of [undefined, null, {}, 'allow', [], Array.from({ length: 21 }, (_, i) => option({ optionId: String(i) }))]) { + assert.throws(() => permissionOptions(value), { message: 'acp_invalid_options' }); + } + for (const value of [[option(), option()], new Array(1), [null], [[]], [new Date()], [true], [{}]]) { + assert.throws(() => permissionOptions(value), { message: 'acp_invalid_option' }); + } +}); + +test('option fields reject invalid types, unknown kinds and over-limit strings', () => { + const invalid = [ + { optionId: '' }, { optionId: null }, { optionId: 1 }, { optionId: 'x'.repeat(241) }, + { name: null }, { name: 1 }, { name: 'x'.repeat(1001) }, + { kind: 'allow' }, { kind: 'ALLOW_ONCE' }, { kind: null }, + ]; + for (const fields of invalid) { + assert.throws(() => permissionOptions([{ ...option(), ...fields }]), { message: 'acp_invalid_option' }); + } +}); + +test('options accept exact bounds and all protocol kinds, copying only validated fields', () => { + const kinds = ['reject_once', 'reject_always', 'allow_always', 'allow_once'] as const; + const raw = Array.from({ length: 20 }, (_, i) => ({ + optionId: String(i).padEnd(240, 'x'), name: 'x'.repeat(1000), kind: kinds[i % 4]!, extra: 'private-extra', + })); + const result = permissionOptions(raw); + assert.equal(result.length, 20); + assert.deepEqual(result[0], { optionId: '0'.padEnd(240, 'x'), name: 'x'.repeat(1000), kind: 'reject_once' }); + assert.notEqual(result[0], raw[0]); + raw[0]!.optionId = 'mutated'; + assert.equal(result[0]!.optionId, '0'.padEnd(240, 'x')); + assert.equal(permissionOptions([option({ name: '' })])[0]!.name, ''); +}); + +test('params reject malformed records and a missing, malformed or foreign native session', () => { + for (const value of [null, undefined, [], new Date(), 'params']) { + assert.throws(() => validatedPermissionParams(value, 'native-session'), { message: 'acp_invalid_permission' }); + } + for (const sessionId of [undefined, null, 1, '', 'foreign-session']) { + assert.throws(() => validatedPermissionParams(params({ sessionId }), 'native-session'), { message: 'acp_wrong_session' }); + } + assert.throws(() => validatedPermissionParams(params({ sessionId: '' }), ''), { message: 'acp_wrong_session' }); +}); + +test('params validate tool identity, nullable title types and options at the actual boundary', () => { + for (const toolCall of [null, [], new Date(), {}, { toolCallId: '' }, { toolCallId: 7 }, { toolCallId: 'x'.repeat(241) }]) { + assert.throws(() => validatedPermissionParams(params({ toolCall }), 'native-session'), { message: 'acp_invalid_tool' }); + } + for (const title of [1, false, {}, [], 'x'.repeat(1001)]) { + assert.throws(() => validatedPermissionParams(params({ toolCall: { toolCallId: 't', title } }), 'native-session'), + { message: 'acp_invalid_tool' }); + } + assert.throws(() => validatedPermissionParams(params({ options: [option(), option()] }), 'native-session'), + { message: 'acp_invalid_option' }); +}); + +test('null and absent titles fall back while empty and exact-limit strings remain intact', () => { + for (const toolCall of [{ toolCallId: 't' }, { toolCallId: 't', title: null }]) { + assert.deepEqual(validatedPermissionParams(params({ toolCall }), 'native-session'), + { options: [option()], title: 'Permission request' }); + } + for (const title of ['', 'Read local file', 'x'.repeat(1000)]) { + const result = validatedPermissionParams(params({ toolCall: { toolCallId: 'x'.repeat(240), title } }), 'native-session'); + assert.equal(result.title, title); + } +}); + +test('normalization rejects unknown modes, malformed tokens and sparse arrays', () => { + for (const value of [undefined, null, {}, 'custom', 'AUTO', ' auto ', [1], ['read', null], + ['read write'], ['a/b'], ['x'.repeat(65)], ['read\nwrite'], new Array(1)]) { + assert.throws(() => normalizeNativePermissions(value), { message: 'invalid_native_permissions' }); + } +}); + +test('normalization preserves literals and takes independent immutable custom snapshots', () => { + assert.equal(normalizeNativePermissions('auto'), 'auto'); + assert.equal(normalizeNativePermissions('safe'), 'safe'); + const saved = [' read ', '', ' ', 'mcp.future:*', 'auto', 'read', 'x'.repeat(64)]; + const before = [...saved]; + const snapshot = normalizeNativePermissions(saved); + const another = normalizeNativePermissions(saved); + assert.deepEqual(snapshot, ['read', 'mcp.future:*', 'auto', 'read', 'x'.repeat(64)]); + assert.deepEqual(saved, before); + assert.notEqual(snapshot, saved); + assert.notEqual(snapshot, another); + assert.ok(Array.isArray(snapshot)); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(Object.isFrozen(saved), false); + assert.equal(Reflect.set(snapshot, '0', 'write'), false); + saved[0] = 'bash'; + saved.push('edit'); + assert.deepEqual(snapshot, ['read', 'mcp.future:*', 'auto', 'read', 'x'.repeat(64)]); +}); + +test('safe and every custom array remain human decisions, including empty and auto tokens', () => { + for (const value of ['safe', [], ['auto'], ['read', 'write', 'bash', 'mcp.*'], [' ', '']]) { + const normalized = normalizeNativePermissions(value); + assert.equal(automaticPermission(normalized, [option()]), undefined); + assert.equal(automaticPermission(normalized, [option({ kind: 'reject_once' })]), undefined); + if (Array.isArray(value)) { + assert.ok(Array.isArray(normalized)); + assert.equal(Object.isFrozen(normalized), true); + assert.notEqual(normalized, value); + } + } +}); + +test('auto prefers allow_once by kind despite reject-first and misleading localized labels', () => { + const options = permissionOptions([ + option({ optionId: 'reject', kind: 'reject_once', name: 'Allow everything / 모두 허용' }), + option({ optionId: 'always', kind: 'allow_always', name: '이번만 허용' }), + option({ optionId: 'once', kind: 'allow_once', name: '거부 / 拒否' }), + ]); + assert.equal(automaticPermission(normalizeNativePermissions('auto'), options), 'once'); + assert.equal(automaticPermission('auto', options.slice(0, 2)), 'always'); + assert.equal(automaticPermission('auto', [options[0]!, option({ optionId: 'reject-all', kind: 'reject_always', name: 'allow' })]), null); +}); + +test('native-wire responses require membership and distinguish selected rejection from cancellation', () => { + const options = permissionOptions([option(), option({ optionId: 'reject', kind: 'reject_once' })]); + assert.deepEqual(permissionResponse({ optionId: 'native-once-private' }, options), + { outcome: { outcome: 'selected', optionId: 'native-once-private' } }); + assert.deepEqual(permissionResponse({ optionId: 'reject' }, options), { outcome: { outcome: 'selected', optionId: 'reject' } }); + assert.deepEqual(permissionResponse({ optionId: null }, options), cancelled); + assert.throws(() => permissionResponse({ optionId: 'foreign-native-id' }, options), { message: 'invalid_option' }); +}); + +test('both response validators reject missing, inherited, extra and non-string selections', () => { + const options = permissionOptions([option()]); + const prepared = preparePermissionRequest('Approve?', options); + const validators = [(value: unknown) => permissionResponse(value, options), prepared.validate]; + for (const validate of validators) { + for (const value of [null, undefined, [], 'allow', {}, Object.create({ optionId: null }), + { optionId: null, approved: true }, { optionId: null, [Symbol('extra')]: true }, + Object.defineProperty({ optionId: null }, 'hidden', { value: true })]) { + assert.throws(() => validate(value), { message: 'invalid_response' }); + } + for (const optionId of [undefined, false, 1, {}, [], '', 'foreign']) { + assert.throws(() => validate({ optionId }), { message: 'invalid_option' }); + } + } + prepared.dispose(); +}); + +test('prepared views use opaque handles and map only this request handles to private native IDs', () => { + const options = permissionOptions([option(), option({ optionId: 'native-reject-private', kind: 'reject_once', name: 'Reject' })]); + const first = preparePermissionRequest('Approve?', options); + const second = preparePermissionRequest('Approve?', options); + const field = first.view.fields[0]!; + const handles = field.options.map(entry => entry.id); + const allHandles = [field.id, ...handles, second.view.fields[0]!.id, ...second.view.fields[0]!.options.map(entry => entry.id)]; + assert.equal(new Set(allHandles).size, 6); + for (const handle of allHandles) assert.match(handle, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.deepEqual(Object.keys(first).sort(), ['dispose', 'validate', 'view']); + assert.deepEqual({ label: field.label, multiSelect: field.multiSelect, allowFreeform: field.allowFreeform }, + { label: 'Permission', multiSelect: false, allowFreeform: false }); + for (const native of options) { + assert.equal(JSON.stringify(first).includes(native.optionId), false); + assert.throws(() => first.validate({ optionId: native.optionId }), { message: 'invalid_option' }); + } + assert.throws(() => first.validate({ optionId: field.id }), { message: 'invalid_option' }); + assert.throws(() => first.validate({ optionId: second.view.fields[0]!.options[0]!.id }), { message: 'invalid_option' }); + assert.throws(() => second.validate({ optionId: handles[0] }), { message: 'invalid_option' }); + assert.deepEqual(first.validate({ optionId: handles[0] }), { outcome: { outcome: 'selected', optionId: 'native-once-private' } }); + assert.deepEqual(first.validate({ optionId: handles[1] }), { outcome: { outcome: 'selected', optionId: 'native-reject-private' } }); + first.dispose(); second.dispose(); +}); + +test('mapping snapshots survive input/view mutation and disposal blocks all previous handles', () => { + const options = permissionOptions([option()]); + const prepared = preparePermissionRequest('Approve?', options); + const handle = prepared.view.fields[0]!.options[0]!.id; + options[0]!.optionId = 'replacement-native'; + options[0]!.name = 'replacement label'; + assert.equal(prepared.view.fields[0]!.options[0]!.label, 'Allow once'); + prepared.view.fields[0]!.options[0]!.id = 'forged-handle'; + assert.throws(() => prepared.validate({ optionId: 'forged-handle' }), { message: 'invalid_option' }); + assert.deepEqual(prepared.validate({ optionId: handle }), { outcome: { outcome: 'selected', optionId: 'native-once-private' } }); + assert.deepEqual(prepared.validate({ optionId: null }), cancelled); + prepared.dispose(); prepared.dispose(); + assert.throws(() => prepared.validate({ optionId: handle }), { message: 'invalid_option' }); + assert.deepEqual(prepared.validate({ optionId: null }), cancelled); +}); + +test('preparation preserves complete prose for the canonical registry sanitizer, never pre-clips it', () => { + const title = '[docs](https://example.test) ' + 't'.repeat(600); + const label = '[ -f file ] ' + 'l'.repeat(600); + const prepared = preparePermissionRequest(title, permissionOptions([option({ name: label })])); + assert.equal(prepared.view.title, title); + assert.equal(prepared.view.fields[0]!.options[0]!.label, label); + prepared.dispose(); +}); diff --git a/tests/unit/native-acp-projection.test.ts b/tests/unit/native-acp-projection.test.ts new file mode 100644 index 000000000..e2ea731fd --- /dev/null +++ b/tests/unit/native-acp-projection.test.ts @@ -0,0 +1,214 @@ +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { acpText, acpSnapshot } from '../../src/agent/runtime/acp/content.ts'; +import { FULLTEXT_MAX_CHARS } from '../../src/agent/events/fulltext-bound.ts'; +import type { RuntimeEvent, RuntimeEventBody } from '../../src/shared/runtime-contract.ts'; +import { encodeRuntimeBody, decodeRuntimeBody } from '../../src/trace/runtime-body-codec.ts'; +import { stringifyTraceValue } from '../../src/trace/redact.ts'; + +// This suite injects every recorder; loading the unused SQLite default races other test processes. +mock.module('../../src/trace/store.js', { namedExports: { + appendTraceEvent: () => assert.fail('default trace recorder must not be used by a pure projector test'), +} }); +const { RuntimeProjection } = await import('../../src/agent/runtime/projection.ts'); +const { AcpProjection } = await import('../../src/agent/runtime/acp/projection.ts'); + +function harness(scope = 'one', failure?: 'null' | 'throw') { + const events: RuntimeEvent[] = [], beforeCodec: RuntimeEventBody[] = [], notices: string[] = []; + let seq = 0; + const state = new RuntimeProjection({ runId: 'run-' + scope, sessionId: 'jaw-chat-' + scope, + scope, turnId: 'turn-' + scope, audience: 'internal' }, (context, body) => { + beforeCodec.push(body); + if (failure === 'null') return null; + if (failure === 'throw') throw new Error('fixture recorder unavailable'); + const identity = { version: 1 as const, runId: context.runId, sessionId: context.sessionId, + scope: context.scope, turnId: context.turnId, seq: seq += 3 }; + const encoded = encodeRuntimeBody(identity, body); + const event = decodeRuntimeBody(JSON.parse(stringifyTraceValue(encoded.raw)), identity, body.kind); + assert.ok(event); events.push(event); return event; + }, reason => notices.push(reason)); + const mapper = new AcpProjection(state); + const update = (kind: string, fields: Record = {}) => mapper.update({ + sessionId: 'native', update: { sessionUpdate: kind, ...fields }, + }, 'native'); + const message = (text: string, messageId?: string) => update('agent_message_chunk', { + content: { type: 'text', text }, ...(messageId === undefined ? {} : { messageId }), + }); + return { state, mapper, events, beforeCodec, notices, update, message }; +} +const messages = (h: ReturnType) => h.events.filter(e => e.kind === 'message'); +const tools = (h: ReturnType) => h.events.filter(e => e.kind === 'tool'); +const end = { stopReason: 'end_turn' }; + +test('captured Grok read-file flow selects only its trailing anonymous answer', () => { + const fixture = JSON.parse(readFileSync(new URL('../fixtures/grok-acp-read-file.json', import.meta.url), 'utf8')); + const h = harness(); + for (const frame of fixture.frames) h.mapper.update(frame.params, 'fixture-native'); + assert.equal(h.mapper.finalText(fixture.result), fixture.expectedFinal); + assert.ok(h.mapper.partialText.startsWith("I'll read")); + assert.ok(h.mapper.partialText.endsWith(fixture.expectedFinal)); + assert.equal(messages(h).at(-1)!.phase, 'final'); + assert.equal(messages(h).at(-1)!.text, fixture.expectedFinal); + assert.equal(tools(h).at(-1)!.status, 'done'); + assert.equal(tools(h).at(-1)!.name, 'read_file'); + assert.match(tools(h).at(-1)!.output!, /GROK_TOOL_7689_CEDAR/); + assert.equal(new Set(tools(h).map(e => e.itemId)).size, 1); + assert.equal(h.events.some(e => e.kind === 'turn-end' || e.kind === 'turn-start'), false); + assert.equal(JSON.stringify(h.events).includes('fixture-read'), false); +}); + +test('explicit and anonymous segments use actual ID and structural boundaries', () => { + const h = harness(); + h.message('first', 'one'); h.message(' continued', 'one'); + h.message('second', 'two'); assert.equal(h.mapper.finalText(end), 'second'); + h.message('anonymous'); assert.equal(h.mapper.finalText(end), 'anonymous'); + h.message(' answer'); assert.equal(h.mapper.finalText(end), 'anonymous answer'); + h.message('explicit again', 'three'); assert.equal(h.mapper.finalText(end), 'explicit again'); + h.update('agent_thought_chunk', { content: { type: 'text', text: 'thinking' } }); + assert.equal(h.mapper.finalText(end), null); + h.message('after thought'); h.update('user_message_chunk', { content: { type: 'text', text: 'user' } }); + assert.equal(h.mapper.finalText(end), null); + h.message('new answer'); + assert.equal(h.mapper.finalText(end), 'new answer'); + assert.equal(h.mapper.partialText.includes('thinking'), false); + assert.equal(h.mapper.partialText.includes('user'), false); +}); + +test('closed-only text never revives; empty text is distinct from missing and stopped output', () => { + const h = harness(); assert.equal(h.mapper.finalText(end), null); + h.message('commentary'); h.update('tool_call', { toolCallId: 'read', title: 'Read' }); + assert.equal(h.mapper.finalText(end), null); + h.message(''); assert.equal(h.mapper.finalText(end), ''); + const count = h.events.length; assert.equal(h.mapper.finalText(end), ''); assert.equal(h.events.length, count); + assert.equal(h.mapper.partialText, 'commentary'); + for (const reason of ['cancelled', 'max_tokens', 'max_turn_requests', 'refusal', 'unknown', undefined]) { + assert.equal(h.mapper.finalText({ stopReason: reason }), null); + } + h.mapper.stopTools(); assert.equal(h.mapper.finalText(end), null); + const stopped = h.events.length; h.mapper.stopTools(); assert.equal(h.events.length, stopped); + h.message('replacement'); assert.equal(h.mapper.finalText(end), 'replacement'); + assert.equal(h.mapper.partialText, 'commentaryreplacement'); +}); + +test('a duplicate terminal tool snapshot does not discard a newer assistant answer', () => { + const h = harness(), terminal = { toolCallId: 'retired', title: 'Read', status: 'completed' }; + h.update('tool_call', terminal); h.message('answer'); + const count = h.events.length; h.update('tool_call_update', terminal); + assert.equal(h.events.length, count); assert.equal(h.mapper.finalText(end), 'answer'); +}); + +test('an explicit ID boundary without text closes the old candidate without inventing empty final', () => { + for (const ids of [['one', 'two'], ['one', undefined], [undefined, 'two']] as const) { + const h = harness(); h.message('old', ids[0]); + h.update('agent_message_chunk', { content: [], ...(ids[1] === undefined ? {} : { messageId: ids[1] }) }); + assert.equal(h.mapper.finalText(end), null); assert.equal(h.mapper.partialText, 'old'); + } +}); + +test('tool snapshots preserve identity, richer detail and native completion semantics', () => { + const h = harness(); + h.update('tool_call', { toolCallId: 'private-id', title: 'run_terminal_command', rawInput: { command: 'fixture' } }); + h.update('tool_call_update', { toolCallId: 'private-id', title: 'Execute fixture', rawOutput: 'first' }); + h.update('tool_call_update', { toolCallId: 'private-id', rawOutput: '' }); + assert.equal(tools(h).at(-1)!.output, ''); assert.equal(tools(h).at(-1)!.status, 'running'); + assert.equal(tools(h).at(-1)!.name, 'run_terminal_command'); assert.match(tools(h).at(-1)!.detail!, /Execute fixture/); + const terminal = { toolCallId: 'private-id', status: 'completed', rawOutput: { exit_code: 1, output_for_prompt: 'failed command' } }; + h.update('tool_call_update', terminal); + assert.equal(tools(h).at(-1)!.status, 'done'); assert.match(tools(h).at(-1)!.detail!, /[Ee]xit.*1/); + const count = h.events.length; h.update('tool_call_update', terminal); + h.update('tool_call_update', { toolCallId: 'private-id', status: 'in_progress' }); h.mapper.stopTools(); + assert.equal(h.events.length, count); + assert.equal(JSON.stringify(h.events).includes('private-id'), false); +}); + +test('unknown-first updates work, failed status remains error and stop retires only running tools', () => { + const h = harness(); + h.update('tool_call_update', { toolCallId: 'one', content: [{ type: 'content', content: { type: 'text', text: 'output' } }] }); + h.update('tool_call', { toolCallId: 'two', title: 'Second', status: 'failed' }); + h.mapper.stopTools(); + assert.equal(tools(h).findLast(e => e.name === 'Second')!.status, 'error'); + assert.equal(tools(h).at(-1)!.status, 'stopped'); + const oldId = tools(h).at(-1)!.itemId; + h.update('tool_call', { toolCallId: 'one', title: 'New attempt' }); + assert.equal(tools(h).at(-1)!.status, 'running'); assert.notEqual(tools(h).at(-1)!.itemId, oldId); +}); + +test('binary-only message closes final eligibility and tool binary content is marked without retaining bytes', () => { + const h = harness(); h.message('earlier'); + const binary = { type: 'image', data: 'PRIVATE_BINARY_CANARY'.repeat(10000), mimeType: 'image/png' }; + h.update('agent_message_chunk', { content: binary }); assert.equal(h.mapper.finalText(end), null); + h.update('tool_call', { toolCallId: 'binary', content: [{ type: 'content', content: binary }], title: 'Image' }); + assert.match(tools(h).at(-1)!.detail!, /unsupported/i); + assert.equal(JSON.stringify(h.beforeCodec).includes('PRIVATE_BINARY_CANARY'), false); + h.update('agent_message_chunk', { content: [binary, { type: 'text', text: 'supported text' }] }); + assert.equal(h.mapper.finalText(end), 'supported text'); assert.equal(h.mapper.partialText, 'earliersupported text'); +}); + +test('whole typed JSON is sanitized before recorder/codec clipping; prose is not guessed structured', () => { + const h = harness(), secret = 'CANARY_PRIVATE_JSON_SECRET'; + h.update('tool_call', { toolCallId: 'json', rawInput: { password: secret, padding: 'x'.repeat(250000) }, + rawOutput: { password: secret, padding: 'y'.repeat(250000) } }); + h.update('tool_call_update', { toolCallId: 'json', rawInput: '{"password":"' + secret }); + assert.equal(tools(h).at(-1)!.input, '[structured content withheld]'); + h.update('tool_call_update', { toolCallId: 'json', rawInput: JSON.stringify({ password: secret }), rawOutput: '[ -f file ]' }); + h.message('[docs](url) [ -f file ]'); assert.equal(h.mapper.finalText(end), '[docs](url) [ -f file ]'); + assert.equal(tools(h).at(-1)!.output, '[ -f file ]'); + assert.equal(JSON.stringify(h.beforeCodec).includes(secret), false); + assert.equal(JSON.stringify(h.events).includes(secret), false); + assert.ok(h.state.diagnostics().withinSnapshotCap); +}); + +test('raw final and partial survive null/throwing recorders and are not preview-clipped', () => { + for (const failure of [undefined, 'null', 'throw'] as const) { + const h = harness('failure-' + failure, failure), answer = 'z'.repeat(5000); + h.message('commentary'); h.update('agent_thought_chunk', { content: { type: 'text', text: 'reason' } }); + h.message(answer); + assert.equal(h.mapper.finalText(end), answer); assert.equal(h.mapper.partialText, 'commentary' + answer); + if (!failure) assert.ok(messages(h).at(-1)!.text.length <= 3000); + else assert.equal(h.state.diagnostics().recordingFailed, true); + } +}); + +test('cumulative raw bound rejects atomically across segments while preserving salvage', () => { + const h = harness('limit', 'null'), part = 'x'.repeat(FULLTEXT_MAX_CHARS / 2); + h.message(part); h.update('agent_thought_chunk', { content: { type: 'text', text: 'boundary' } }); h.message(part); + assert.equal(h.mapper.partialText.length, FULLTEXT_MAX_CHARS); + assert.throws(() => h.message('overflow', 'new-id'), /acp_text_limit/); + assert.equal(h.mapper.partialText.length, FULLTEXT_MAX_CHARS); assert.equal(h.mapper.finalText(end), part); + h.message(''); assert.equal(h.mapper.finalText(end), part); +}); + +test('malformed identity/status/content rejects while unknown metadata does not invent a terminal', () => { + const h = harness(); h.message('answer'); + assert.throws(() => h.mapper.update({ sessionId: 'other', update: {} }, 'native'), /acp_wrong_session/); + assert.throws(() => h.update('agent_message_chunk', { content: { type: 'text', text: 42 } }), /acp_invalid_content/); + assert.throws(() => h.update('agent_message_chunk', { messageId: 42, content: { type: 'text', text: 'bad' } }), /acp_invalid_id/); + assert.throws(() => h.update('tool_call', { toolCallId: '' }), /acp_invalid_id/); + assert.throws(() => h.update('tool_call', { toolCallId: 'x', status: 'future' }), /acp_invalid_tool_status/); + h.update('unknown-metadata', { result: 'not final' }); assert.equal(h.mapper.finalText(end), 'answer'); +}); + +test('tool reference capacity is bounded and context identity disambiguates generic item IDs', () => { + const h = harness('capacity', 'null'); + for (let i = 0; i < 4096; i++) h.update('tool_call', { toolCallId: 'tool-' + i }); + assert.throws(() => h.update('tool_call', { toolCallId: 'overflow' }), /acp_tool_limit/); + const a = harness('a'), b = harness('b'); a.message('A'); b.message('B'); + assert.notDeepEqual([messages(a)[0]!.runId, messages(a)[0]!.itemId], [messages(b)[0]!.runId, messages(b)[0]!.itemId]); + assert.equal(a.mapper.finalText(end), 'A'); assert.equal(b.mapper.finalText(end), 'B'); +}); + +test('content helper distinguishes absent/empty, bounds arrays and snapshots, and never serializes binary blocks', () => { + assert.deepEqual(acpText(undefined), { text: null, unsupported: false }); + assert.deepEqual(acpText([]), { text: null, unsupported: false }); + assert.deepEqual(acpText({ type: 'text', text: '' }), { text: '', unsupported: false }); + assert.deepEqual(acpText([{ type: 'text', text: 'A' }, { type: 'content', content: { type: 'text', text: 'B' } }]), + { text: 'AB', unsupported: false }); + assert.deepEqual(acpText({ type: 'resource', toJSON() { throw new Error('must not serialize'); } }), { text: null, unsupported: true }); + assert.throws(() => acpText(Array(4097).fill({ type: 'text', text: '' })), /acp_content_limit/); + assert.throws(() => acpText({ type: 'text', text: 'x'.repeat(FULLTEXT_MAX_CHARS + 1) }), /acp_content_limit/); + assert.throws(() => acpSnapshot('x'.repeat(FULLTEXT_MAX_CHARS + 1)), /acp_content_limit/); + assert.throws(() => acpSnapshot(undefined), /acp_invalid_content/); + assert.throws(() => acpText({ type: 'content', content: null }), /acp_invalid_content/); + assert.equal(acpSnapshot({ content: 'raw' }), '{"content":"raw"}'); +}); diff --git a/tests/unit/native-acp-replacement-session.test.ts b/tests/unit/native-acp-replacement-session.test.ts new file mode 100644 index 000000000..1f87f5f26 --- /dev/null +++ b/tests/unit/native-acp-replacement-session.test.ts @@ -0,0 +1,347 @@ +import test, { mock, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { RuntimeEvent } from '../../src/shared/runtime-contract.ts'; +import { AcpSession } from '../../src/agent/runtime/acp/session.ts'; +import { AcpReplacement } from '../../src/agent/runtime/acp/replacement.ts'; +mock.module('../../src/trace/store.js', { namedExports: { appendTraceEvent: () => null } }); +const { AcpRuntimeSession } = await import('../../src/agent/runtime/acp/runtime-session.ts'); +const { replaceAcpMainTurn } = await import('../../src/agent/runtime/replace-turn.ts'); +const tick = () => new Promise(resolve => setImmediate(resolve)); +type Wire = { id?: string; method?: string; params?: Record }; + +async function fixture(t: TestContext, patch: Partial[1]> = {}) { + const wire: Wire[] = [], events: RuntimeEvent[] = [], order: string[] = []; + const child = Object.assign(new EventEmitter(), { pid: 62001, exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, stdin: new Writable(), stdout: new PassThrough(), stderr: new PassThrough() }); + let promptId: string | undefined, prompts = 0, current = true, record = true, failCancel = false; + let responseBeforeWrite = false, resultsHeld = false, writesHeld = false, heldWrite: (() => void) | undefined; + let holdCancel = false, cancelReply: (() => void) | undefined, lateText = ''; + const send = (value: unknown) => child.stdout.write(JSON.stringify(value) + '\n'); + const reply = (id: unknown, result: unknown) => send({ jsonrpc: '2.0', id, result }); + const update = (text: string) => send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 'native', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } } }); + const finish = () => { update('B_FINAL'); reply(promptId, { stopReason: 'end_turn', _meta: { usage: { outputTokens: 3 } } }); }; + child.stdin = new Writable({ write(chunk, _encoding, done) { + const frame = JSON.parse(String(chunk)) as Wire; wire.push(frame); + if (frame.method === 'initialize') reply(frame.id, { protocolVersion: 1, agentCapabilities: {} }); + if (frame.method === 'session/new') reply(frame.id, { sessionId: 'native' }); + if (frame.method === 'session/prompt') { + promptId = frame.id; prompts++; + if (prompts === 1) update('A_PARTIAL'); + else if (!resultsHeld && responseBeforeWrite) finish(); + else if (!resultsHeld) setImmediate(finish); + if (writesHeld) { heldWrite = done; return; } + } + if (frame.method === 'session/cancel') { + order.push('cancel'); + if (lateText) update(lateText); + if (failCancel) send({ jsonrpc: '2.0', id: promptId, error: { code: -32000, message: 'failure' } }); + else if (holdCancel) { const id = promptId; cancelReply = () => reply(id, { stopReason: 'cancelled' }); } + else reply(promptId, { stopReason: 'cancelled' }); + } + done(); + } }); + const exit = () => { if (child.exitCode !== null) return; child.exitCode = 143; child.emit('exit', 143); child.emit('close', 143); }; + const protocol = new AcpSession(child as unknown as ChildProcessWithoutNullStreams, { permissions: 'auto', + promptTimeoutMs: 2000, controlTimeoutMs: 1000, ownedProcessOptions: { terminateTree: () => queueMicrotask(exit) } }); + await protocol.start({ cwd: process.cwd() }); + const runtime = new AcpRuntimeSession(protocol, { provider: 'fixture-acp', deferTurnEnd: true, + capabilities: { transport: 'native', steer: 'restart', resume: false, tools: true, toolOutput: true, + approvals: true, questions: false, images: false, subagents: false }, + getTurnContext: () => ({ runId: 'run', sessionId: 'jaw', scope: 'scope', turnId: 'logical', audience: 'internal', isCurrent: () => current }), + createReplacement: io => new AcpReplacement(io), + resultUsage: result => result['_meta'] ? { kind: 'usage', outputTokens: 3 } : null, + record: (context, body) => { + if (!record) return null; + const value = { ...context, version: 1 as const, seq: events.length + 1, ...body }; + events.push(value); return value; + }, ...patch, + }); + t.after(async () => { await runtime.close(); heldWrite?.(); }); + const run = () => { const result = runtime.send({ text: 'A' }, () => {}); void result.catch(() => {}); return result; }; + return { runtime, protocol, wire, events, order, child, run, send, reply, update, finish, + earlyResult: () => { responseBeforeWrite = true; }, invalidate: () => { current = false; }, + holdResults: () => { resultsHeld = true; }, + noRecord: () => { record = false; }, failCancel: () => { failCancel = true; }, + holdWrites: () => { writesHeld = true; }, releaseWrite: () => { heldWrite?.(); heldWrite = undefined; writesHeld = false; }, + holdCancel: () => { holdCancel = true; }, releaseCancel: () => { cancelReply?.(); }, lateText: (value: string) => { lateText = value; }, + }; +} + +test('only the injected replacement strategy advertises cancel-reprompt', async t => { + const f = await fixture(t); assert.equal(f.runtime.capabilities.steer, 'cancel-reprompt'); + const noStart = await f.runtime.steer({ text: 'B' }); + assert.equal(noStart.accepted, false); assert.equal(noStart.mode, 'cancel-reprompt'); +}); + +test('same logical turn replaces A and claims/finalizes B once with complete partial', async t => { + const f = await fixture(t), run = f.run(); await tick(); + let committed = 0; + const accepted = await f.runtime.steer({ text: 'B' }, () => { committed++; f.order.push('input'); }); + assert.equal(accepted.accepted, true); assert.equal(accepted.mode, 'cancel-reprompt'); assert.equal(committed, 1); + const result = await run; + assert.deepEqual(result, { status: 'done', finalText: 'B_FINAL', partialText: 'A_PARTIALB_FINAL' }); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 2); + assert.equal(f.wire.filter(row => row.method === 'session/prompt')[1]!.params!.prompt[0].text, 'B'); + assert.equal(f.events.filter(event => event.kind === 'turn-end').length, 0); + assert.deepEqual(f.runtime.claimTurnOutcome('logical'), result); + assert.equal(f.runtime.finalizeTurn('logical', { kind: 'turn-end', status: 'done', finalText: result.finalText }), true); + assert.equal(f.runtime.finalizeTurn('logical', { kind: 'turn-end', status: 'done', finalText: 'duplicate' }), false); + assert.equal(f.events.filter(event => event.kind === 'turn-end').length, 1); +}); + +test('a fast B result waits the synchronous input commit barrier', async t => { + const f = await fixture(t); f.earlyResult(); let finished = false; + const run = f.run().then(result => { finished = true; f.order.push('result'); return result; }); await tick(); + await f.runtime.steer({ text: 'B' }, () => { assert.equal(finished, false); f.order.push('input'); }); + await run; assert.ok(f.order.indexOf('input') < f.order.indexOf('result')); +}); + +test('input commit failure after local dispatch is fatal without retry and preserves partial', async t => { + const f = await fixture(t); f.earlyResult(); const run = f.run(); await tick(); let attempts = 0; + await assert.rejects(f.runtime.steer({ text: 'B' }, () => { attempts++; throw new Error('insert failed'); }), /observer/); + const result = await run; + assert.equal(attempts, 1); assert.equal(result.status, 'error'); assert.equal(result.finalText, null); + assert.match(result.partialText, /A_PARTIAL/); assert.equal(f.protocol.alive, false); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 2); +}); + +test('cancellation failure rejects the steer and never sends a replacement', async t => { + const f = await fixture(t); f.failCancel(); const run = f.run(); await tick(); let committed = 0; + await assert.rejects(f.runtime.steer({ text: 'B' }, () => { committed++; })); + const outcome = await run; assert.equal(outcome.status, 'error'); assert.equal(committed, 0); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 1); + assert.equal(outcome.partialText, 'A_PARTIAL'); +}); + +test('explicit stop during pending replacement settles stopped without input commit', async t => { + const f = await fixture(t); const run = f.run(); await tick(); let commits = 0; + const steering = f.runtime.steer({ text: 'B' }, () => { commits++; }); + await f.runtime.cancel(); const result = await run; const acceptance = await steering; + assert.equal(result.status, 'stopped'); assert.equal(result.finalText, null); + assert.equal(acceptance.accepted, false); assert.equal(commits, 0); +}); + +test('recording failure does not suppress latest final, partial or local dispatch fact', async t => { + const f = await fixture(t); f.noRecord(); const run = f.run(); await tick(); let commits = 0; + assert.equal((await f.runtime.steer({ text: 'B' }, () => { commits++; })).accepted, true); + const result = await run; assert.equal(result.finalText, 'B_FINAL'); assert.equal(result.partialText, 'A_PARTIALB_FINAL'); + assert.equal(commits, 1); assert.equal(f.events.length, 0); assert.equal(f.runtime.claimTurnOutcome('logical')?.status, 'done'); +}); + +test('no active raw prompt, stale owner and unsupported prompt cannot cancel or commit input', async t => { + const f = await fixture(t); let commits = 0; + assert.equal((await f.runtime.steer({ text: 'B' }, () => { commits++; })).accepted, false); + const run = f.run(); await tick(); + await assert.rejects(f.runtime.steer({ text: 'bad', images: [{ mimeType: 'image/png', data: 'x' }] }), /unsupported/); + f.invalidate(); assert.equal((await f.runtime.steer({ text: 'B' }, () => { commits++; })).accepted, false); + assert.equal(f.wire.filter(row => row.method === 'session/cancel').length, 0); assert.equal(commits, 0); + await f.runtime.cancel(); await run; +}); + +test('a completed raw result cannot be treated as cancellation acknowledgement during a result observer', async t => { + const f = await fixture(t), calls: Array> = []; + const run = f.runtime.send({ text: 'A' }, event => { + if (event.kind === 'message' && event.phase === 'final') calls.push(f.runtime.steer({ text: 'late' })); + }); + await tick(); f.finish(); await run; + const outcomes = await Promise.all(calls) as Array<{ accepted: boolean }>; + assert.ok(outcomes.length); assert.ok(outcomes.every(value => !value.accepted)); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 1); +}); + +test('app single-flight keeps B first and returns busy for C without cancelling or committing C', async t => { + const f = await fixture(t); f.holdCancel(); const run = f.run(); await tick(); + let bCommits = 0, cCommits = 0; + const b = f.runtime.steer({ text: 'B' }, () => { bCommits++; }); + const c = await f.runtime.steer({ text: 'C' }, () => { cCommits++; }); + assert.equal(c.accepted, false); assert.equal(c.reason, 'busy'); + assert.equal(f.wire.filter(row => row.method === 'session/cancel').length, 1); + f.releaseCancel(); assert.equal((await b).accepted, true); await run; + assert.equal(bCommits, 1); assert.equal(cCommits, 0); + assert.equal(f.wire.filter(row => row.method === 'session/prompt')[1]!.params!.prompt[0].text, 'B'); +}); + +test('prepare runs after drained partial and changes only the wire prompt, not committed user instruction', async t => { + const prepared: Array<[string, string]> = [], committed: string[] = []; + const f = await fixture(t, { prepareReplacement: (instruction, partial) => { + prepared.push([instruction, partial]); return { text: 'SYS ORIGINAL PARTIAL=' + partial + ' NEW=' + instruction }; + } }); + f.lateText('_LATE'); const run = f.run(); await tick(); assert.equal(prepared.length, 0); + await f.runtime.steer({ text: 'B' }, () => committed.push('B')); await run; + assert.deepEqual(prepared, [['B', 'A_PARTIAL_LATE']]); assert.deepEqual(committed, ['B']); + const requests = f.wire.filter(row => row.method === 'session/prompt'); + assert.equal(requests[0]!.params!.prompt[0].text, 'A'); + assert.equal(requests[1]!.params!.prompt[0].text, 'SYS ORIGINAL PARTIAL=A_PARTIAL_LATE NEW=B'); +}); + +test('Stop reentered by prepare prevents B without killing an idle reusable protocol', async t => { + let f: Awaited>, cancel: Promise | undefined, committed = 0; + f = await fixture(t, { prepareReplacement: () => { cancel = f.runtime.cancel(); return { text: 'B' }; } }); + const run = f.run(); await tick(); const steering = f.runtime.steer({ text: 'B' }, () => { committed++; }); + const accepted = await steering; await cancel; const result = await run; + assert.equal(accepted.accepted, false); assert.equal(result.status, 'stopped'); assert.equal(committed, 0); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 1); assert.equal(f.protocol.alive, true); +}); + +for (const kind of ['throw', 'invalid', 'async'] as const) test(`preparation ${kind} is fatal after cancellation and never dispatches B`, async t => { + const prepare = (() => { + if (kind === 'throw') throw new Error('private prepare failure'); + if (kind === 'async') return Promise.reject(new Error('private async prepare failure')); + return { text: 123 }; + }) as unknown as NonNullable[1]['prepareReplacement']>; + const f = await fixture(t, { prepareReplacement: prepare }); const run = f.run(); await tick(); let commits = 0; + await assert.rejects(f.runtime.steer({ text: 'B' }, () => { commits++; })); + assert.equal((await run).status, 'error'); assert.equal(commits, 0); assert.equal(f.protocol.alive, false); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 1); +}); + +test('prepared output is snapshotted before a queued mutation and ownership is rechecked', async t => { + const f = await fixture(t, { prepareReplacement: () => { + const prepared = { text: 'COMPOSED' }; queueMicrotask(() => { prepared.text = 'mutated'; }); return prepared; + } }); + const run = f.run(); await tick(); await f.runtime.steer({ text: 'B' }); await run; + assert.equal(f.wire.filter(row => row.method === 'session/prompt')[1]!.params!.prompt[0].text, 'COMPOSED'); + let stale: Awaited>; + stale = await fixture(t, { prepareReplacement: () => { stale.invalidate(); return { text: 'B' }; } }); + const stopped = stale.run(); await tick(); await assert.rejects(stale.runtime.steer({ text: 'B' })); await stopped; + assert.equal(stale.wire.filter(row => row.method === 'session/prompt').length, 1); +}); + +test('async input commit is consumed and fails closed instead of releasing a successful final', async t => { + const f = await fixture(t); f.earlyResult(); const run = f.run(); await tick(); + await assert.rejects(f.runtime.steer({ text: 'B' }, () => Promise.reject(new Error('private commit failure'))), /observer/); + const result = await run; assert.equal(result.status, 'error'); assert.equal(result.finalText, null); + assert.equal(f.protocol.alive, false); +}); + +test('close before the first queued dispatch settles the logical turn without provider input', { timeout: 5000 }, async t => { + const f = await fixture(t); + const run = f.run(); + assert.equal(f.wire.some(row => row.method === 'session/prompt'), false); + await f.runtime.close(); + const outcome = await run; + assert.deepEqual(outcome, { status: 'stopped', finalText: null, partialText: '' }); + assert.equal(f.wire.some(row => row.method === 'session/prompt' || row.method === 'session/cancel'), false); + assert.equal(f.protocol.alive, false); assert.equal(f.child.exitCode, 143); + assert.deepEqual(f.runtime.claimTurnOutcome('logical'), outcome); + assert.equal(f.runtime.finalizeTurn('logical', { kind: 'turn-end', status: 'stopped', finalText: null }), true); + assert.equal(f.runtime.finalizeTurn('logical', { kind: 'turn-end', status: 'stopped', finalText: null }), false); + assert.equal(f.events.filter(event => event.kind === 'turn-end').length, 1); +}); + +test('close in the drained preparation gap settles both promises without dispatching or committing B', { timeout: 5000 }, async t => { + let f: Awaited>, closing: Promise | undefined; + let preparations = 0, commits = 0; + f = await fixture(t, { prepareReplacement: (_instruction, partial) => { + preparations++; + assert.equal(f.protocol.idle, true); assert.equal(partial, 'A_PARTIAL_DRAINED'); + closing = f.runtime.close(); + return { text: 'B' }; + } }); + f.lateText('_DRAINED'); + const run = f.run(); await tick(); + await assert.rejects(f.runtime.steer({ text: 'B' }, () => { commits++; })); + assert.ok(closing); await closing; + const outcome = await run; + assert.deepEqual(outcome, { status: 'stopped', finalText: null, partialText: 'A_PARTIAL_DRAINED' }); + assert.equal(preparations, 1); assert.equal(commits, 0); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 1); + assert.equal(f.wire.filter(row => row.method === 'session/cancel').length, 1); + assert.equal(f.child.exitCode, 143); + assert.deepEqual(f.runtime.claimTurnOutcome('logical'), outcome); + assert.equal(f.runtime.finalizeTurn('logical', { kind: 'turn-end', status: 'stopped', finalText: null }), true); + assert.equal(f.events.filter(event => event.kind === 'turn-end').length, 1); +}); + +test('unsolicited original RPC failure settles an unsteered turn with salvage and no retry', { timeout: 5000 }, async t => { + const f = await fixture(t), run = f.run(); await tick(); + const request = f.wire.find(row => row.method === 'session/prompt'); assert.ok(request?.id); + f.send({ jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'PRIVATE_UNSOLICITED_RPC' } }); + const outcome = await run; + assert.deepEqual(outcome, { status: 'error', finalText: null, partialText: 'A_PARTIAL' }); + assert.equal(f.protocol.alive, false); assert.equal(f.child.exitCode, 143); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 1); + assert.equal(f.wire.some(row => row.method === 'session/cancel'), false); + assert.deepEqual(f.runtime.claimTurnOutcome('logical'), outcome); + assert.equal(f.runtime.finalizeTurn('logical', { kind: 'turn-end', status: 'error', finalText: null }), true); + assert.equal(f.runtime.finalizeTurn('logical', { kind: 'turn-end', status: 'error', finalText: null }), false); + assert.equal(f.events.filter(event => event.kind === 'turn-end').length, 1); + assert.equal(JSON.stringify([f.runtime.lastError, f.events]).includes('PRIVATE_UNSOLICITED_RPC'), false); +}); + +test('Stop after local B dispatch preserves its one input commit before the stopped logical result', { timeout: 5000 }, async t => { + const f = await fixture(t); f.holdResults(); + const originalPrompt = f.protocol.prompt.bind(f.protocol); + let stopping: Promise | undefined, commits = 0, settled = false; + t.mock.method(f.protocol, 'prompt', (...args: Parameters) => { + const [parts, owner, consume, options] = args; + return originalPrompt(parts, owner, consume, { ...options, onDispatched: () => { + options?.onDispatched?.(); + if (f.wire.filter(row => row.method === 'session/prompt').length === 2) { + f.order.push('B-dispatched'); + stopping = f.runtime.cancel(); + void stopping.catch(() => {}); + f.order.push('Stop-latched'); + assert.equal(commits, 0); assert.equal(settled, false); + } + } }); + }); + const run = f.run().then(outcome => { settled = true; f.order.push('logical-result'); return outcome; }); + await tick(); + const acceptance = await f.runtime.steer({ text: 'B' }, () => { + assert.ok(stopping); assert.equal(settled, false); + commits++; f.order.push('input-committed'); + }); + assert.equal(acceptance.accepted, true); assert.equal(commits, 1); + assert.ok(stopping); await stopping; + assert.deepEqual(await run, { status: 'stopped', finalText: null, partialText: 'A_PARTIAL' }); + assert.deepEqual(f.order.filter(step => step !== 'cancel'), ['B-dispatched', 'Stop-latched', 'input-committed', 'logical-result']); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 2); + assert.equal(f.wire.filter(row => row.method === 'session/cancel').length, 2); + assert.equal(f.protocol.alive, true); assert.equal(f.protocol.idle, true); + assert.equal(f.runtime.claimTurnOutcome('logical')?.status, 'stopped'); + assert.equal(f.runtime.finalizeTurn('logical', { kind: 'turn-end', status: 'stopped', finalText: null }), true); + assert.equal(f.events.filter(event => event.kind === 'turn-end').length, 1); +}); + +test('owner invalidation during held B write rejects fatally before input commit', { timeout: 5000 }, async t => { + const f = await fixture(t), run = f.run(); await tick(); + f.holdResults(); f.holdWrites(); + let commits = 0, accepted = false; + const steering = f.runtime.steer({ text: 'B' }, () => { commits++; }); + void steering.then(() => { accepted = true; }, () => {}); + await tick(); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 2); + assert.equal(f.wire.filter(row => row.method === 'session/cancel').length, 1); + assert.equal(commits, 0); assert.equal(accepted, false); + + f.invalidate(); + f.releaseWrite(); + const [receipt] = await Promise.allSettled([steering]); + assert.deepEqual({ rejected: receipt!.status === 'rejected', commits }, { rejected: true, commits: 0 }); + assert.equal(f.protocol.alive, false, 'an already-dispatched stale-owner input is fatal, not queueable'); + const outcome = await run; + assert.notEqual(outcome.status, 'done'); assert.equal(outcome.finalText, null); + assert.equal(outcome.partialText, 'A_PARTIAL'); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 2); + assert.equal(f.runtime.claimTurnOutcome('logical')?.finalText, null); +}); + +test('main adapter preserves and consumes an asynchronous commit failure', { timeout: 5000 }, async t => { + const f = await fixture(t), run = f.run(); await tick(); + let commits = 0; + const receipt = await replaceAcpMainTurn(f.runtime, 'B', () => { + commits++; + return Promise.reject(new Error('private async commit failure')); + }); + assert.equal(receipt.kind, 'failed'); assert.equal(commits, 1); + assert.equal(f.protocol.alive, false); + const result = await run; + assert.equal(result.status, 'error'); assert.equal(result.finalText, null); + assert.equal(f.wire.filter(row => row.method === 'session/prompt').length, 2); + await tick(); // A fresh rejecting callback must not become an unhandled rejection. +}); diff --git a/tests/unit/native-acp-replacement.test.ts b/tests/unit/native-acp-replacement.test.ts new file mode 100644 index 000000000..51b4b3b40 --- /dev/null +++ b/tests/unit/native-acp-replacement.test.ts @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { setImmediate as nextTurn } from 'node:timers/promises'; +import { AcpReplacement, AcpReplacementError } from '../../src/agent/runtime/acp/replacement.ts'; +import type { RuntimePrompt } from '../../src/agent/runtime/session.ts'; + +function deferred() { + let resolve!: () => void, reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +function fixture() { + const changed = new EventEmitter(), events: string[] = []; + const starts: Array<{ prompt: RuntimePrompt; epoch: number; written: ReturnType }> = []; + const cancels: Array<{ epoch: number; terminal: ReturnType; drained: ReturnType }> = []; + const retired: Error[] = []; + let onStart: ((epoch: number) => void) | undefined, retirementThrows = false; + const control = new AcpReplacement({ + start(prompt, epoch) { + const entry = { prompt, epoch, written: deferred() }; starts.push(entry); + events.push('start:' + epoch); changed.emit('change'); onStart?.(epoch); return entry.written.promise; + }, + cancelAndDrain() { + const entry = { epoch: control.currentEpoch, terminal: deferred(), drained: deferred() }; cancels.push(entry); + events.push('cancel:' + entry.epoch); changed.emit('change'); + return Promise.all([entry.terminal.promise, entry.drained.promise]).then(() => undefined); + }, + retire(error) { + retired.push(error); events.push('retire'); + for (const entry of starts) entry.written.reject(error); + for (const entry of cancels) { entry.terminal.reject(error); entry.drained.reject(error); } + if (retirementThrows) throw new Error('retirement hook failed'); + }, + }); + const waitFor = (predicate: () => boolean) => predicate() ? Promise.resolve() : new Promise(resolve => { + const check = () => { if (predicate()) { changed.off('change', check); resolve(); } }; changed.on('change', check); + }); + const ready = async () => { + const first = control.first({ text: 'A' }); await waitFor(() => starts.length === 1); + starts[0]!.written.resolve(); await first; + }; + const drain = (index = cancels.length - 1) => { cancels[index]!.terminal.resolve(); cancels[index]!.drained.resolve(); }; + return { control, starts, cancels, events, retired, waitFor, ready, drain, + onStart: (callback: (epoch: number) => void) => { onStart = callback; }, + throwRetirement: () => { retirementThrows = true; } }; +} + +test('replacement waits original terminal and drain before advancing epoch or dispatching B', async () => { + const f = fixture(); await f.ready(); + const replacement = f.control.replace({ text: 'B' }); + assert.equal(f.cancels.length, 1); assert.equal(f.control.hasPendingReplacement, true); + assert.equal(f.control.currentEpoch, 1); + f.cancels[0]!.terminal.resolve(); await nextTurn(); assert.equal(f.starts.length, 1); + f.cancels[0]!.drained.resolve(); await f.waitFor(() => f.starts.length === 2); + assert.equal(f.starts[1]!.epoch, 2); assert.equal(f.control.hasPendingReplacement, false); + let accepted = false; const done = replacement.then(result => { accepted = true; return result; }); + await nextTurn(); assert.equal(accepted, false); + f.starts[1]!.written.resolve(); assert.deepEqual(await done, { accepted: true, epoch: 2 }); + assert.deepEqual(f.events, ['start:1', 'cancel:1', 'start:2']); +}); + +test('cancellation starts while the initial write is held, not behind the admission lock', async () => { + const f = fixture(), first = f.control.first({ text: 'A' }); + await f.waitFor(() => f.starts.length === 1); + const replacement = f.control.replace({ text: 'B' }); + assert.equal(f.cancels.length, 1, 'the cancellation budget must already be running'); + f.drain(); await nextTurn(); assert.equal(f.starts.length, 1); + f.starts[0]!.written.resolve(); await first; await f.waitFor(() => f.starts.length === 2); + f.starts[1]!.written.resolve(); assert.equal((await replacement).accepted, true); +}); + +test('latest intent supersedes only before start and coalesces the old cancellation', async () => { + const f = fixture(); await f.ready(); + const b = f.control.replace({ text: 'B' }), c = f.control.replace({ text: 'C' }); + assert.equal(f.cancels.length, 1); f.drain(); + assert.deepEqual(await b, { accepted: false, epoch: 1, reason: 'superseded' }); + await f.waitFor(() => f.starts.length === 2); assert.equal(f.starts[1]!.prompt.text, 'C'); + f.starts[1]!.written.resolve(); assert.deepEqual(await c, { accepted: true, epoch: 2 }); +}); + +test('a later intent cannot retract a request that has entered dispatch', async () => { + const f = fixture(); await f.ready(); + const b = f.control.replace({ text: 'B' }); f.drain(); await f.waitFor(() => f.starts.length === 2); + const c = f.control.replace({ text: 'C' }); + assert.equal(f.cancels.length, 2); assert.equal(f.cancels[1]!.epoch, 2); + f.starts[1]!.written.resolve(); assert.deepEqual(await b, { accepted: true, epoch: 2 }); + f.drain(); await f.waitFor(() => f.starts.length === 3); f.starts[2]!.written.resolve(); + assert.deepEqual(await c, { accepted: true, epoch: 3 }); +}); + +test('stop latches immediately, cancels once and suppresses pending replacements', async () => { + const f = fixture(); await f.ready(); + const replacement = f.control.replace({ text: 'B' }); const stop = f.control.stop(); + assert.equal(f.control.isStopped, true); assert.equal(f.control.stop(), stop); assert.equal(f.cancels.length, 1); + f.drain(); await stop; + assert.deepEqual(await replacement, { accepted: false, epoch: 1, reason: 'stopped' }); + assert.equal(f.starts.length, 1); + assert.deepEqual(await f.control.replace({ text: 'C' }), { accepted: false, epoch: 1, reason: 'stopped' }); + assert.equal(f.retired.length, 0); +}); + +test('stop before the first queued start performs no IO', async () => { + const f = fixture(); + const first = f.control.first({ text: 'A' }); const rejected = assert.rejects(first, /acp_control_stopped/); + await f.control.stop(); await rejected; + assert.equal(f.starts.length, 0); assert.equal(f.cancels.length, 0); assert.equal(f.retired.length, 0); +}); + +test('stop is reentrant during synchronous registration and still waits the dispatch/drain', async () => { + const f = fixture(); let stop: Promise | undefined; + f.onStart(() => { stop = f.control.stop(); }); + const first = f.control.first({ text: 'A' }); await f.waitFor(() => f.starts.length === 1); + assert.equal(f.control.isStopped, true); assert.equal(f.cancels.length, 1); + f.starts[0]!.written.resolve(); f.drain(); await first; await stop; +}); + +test('replacement is reentrant during a replacement registration without duplicating starts', async () => { + const f = fixture(); await f.ready(); let c: ReturnType | undefined; + f.onStart(epoch => { if (epoch === 2) c = f.control.replace({ text: 'C' }); }); + const b = f.control.replace({ text: 'B' }); f.drain(); await f.waitFor(() => f.starts.length === 2); + assert.equal(f.cancels.length, 2); f.starts[1]!.written.resolve(); assert.equal((await b).accepted, true); + f.drain(); await f.waitFor(() => f.starts.length === 3); f.starts[2]!.written.resolve(); + assert.deepEqual(await c, { accepted: true, epoch: 3 }); +}); + +test('prompt values are captured before waiting; no actual image capability is claimed by this IO fixture', async () => { + const f = fixture(); await f.ready(); + const prompt = { text: 'B', images: [{ mimeType: 'fixture/type', data: 'before' }] }; + const replacement = f.control.replace(prompt); prompt.text = 'changed'; prompt.images[0]!.data = 'changed'; + f.drain(); await f.waitFor(() => f.starts.length === 2); + assert.deepEqual(f.starts[1]!.prompt, { text: 'B', images: [{ mimeType: 'fixture/type', data: 'before' }] }); + f.starts[1]!.written.resolve(); await replacement; +}); + +test('pending transition capacity rejects before cancelling or superseding an admitted intent', async () => { + const f = fixture(); await f.ready(); + const pending = Array.from({ length: 32 }, (_, i) => f.control.replace({ text: String(i) })); + assert.deepEqual(await f.control.replace({ text: 'overflow' }), { accepted: false, epoch: 1, reason: 'capacity' }); + assert.equal(f.cancels.length, 1); f.drain(); await f.waitFor(() => f.starts.length === 2); + assert.equal(f.starts[1]!.prompt.text, '31'); f.starts[1]!.written.resolve(); + const results = await Promise.all(pending); assert.equal(results.filter(result => result.accepted).length, 1); +}); + +test('cancel failure while dispatch is held rejects both operations with one fatal retirement', async () => { + const f = fixture(), cause = new Error('cancel timeout'); + const first = f.control.first({ text: 'A' }); await f.waitFor(() => f.starts.length === 1); + const replacement = f.control.replace({ text: 'B' }); + const a = assert.rejects(first, AcpReplacementError), b = assert.rejects(replacement, AcpReplacementError); + f.cancels[0]!.terminal.reject(cause); await Promise.all([a, b]); + assert.equal(f.retired.length, 1); assert.equal((f.retired[0] as AcpReplacementError).stage, 'cancel'); + assert.equal(f.retired[0]!.cause, cause); assert.equal(f.control.isStopped, true); + await assert.rejects(f.control.replace({ text: 'C' }), AcpReplacementError); assert.equal(f.starts.length, 1); +}); + +test('replacement dispatch failure retires and rejects rather than returning a retryable no-start', async () => { + const f = fixture(); await f.ready(); + const replacement = f.control.replace({ text: 'B' }); const rejected = assert.rejects(replacement, AcpReplacementError); + f.drain(); await f.waitFor(() => f.starts.length === 2); f.starts[1]!.written.reject(new Error('write failed')); + await rejected; assert.equal(f.retired.length, 1); assert.equal((f.retired[0] as AcpReplacementError).stage, 'dispatch'); + await assert.rejects(f.control.replace({ text: 'C' }), AcpReplacementError); assert.equal(f.starts.length, 2); +}); + +test('first failure keeps the fatal type even if retirement itself throws', async () => { + const f = fixture(); f.throwRetirement(); + const first = f.control.first({ text: 'A' }); const rejected = assert.rejects(first, AcpReplacementError); + await f.waitFor(() => f.starts.length === 1); f.starts[0]!.written.reject(new Error('write failed')); + await rejected; assert.equal(f.retired.length, 1); assert.equal(f.control.isStopped, true); +}); + +test('before-first replacement and duplicate first do not disturb the original attempt', async () => { + const f = fixture(); assert.deepEqual(await f.control.replace({ text: 'B' }), { accepted: false, epoch: 0, reason: 'not-started' }); + const first = f.control.first({ text: 'A' }); + await assert.rejects(f.control.first({ text: 'duplicate' }), /acp_control_already_started/); + await f.waitFor(() => f.starts.length === 1); f.starts[0]!.written.resolve(); await first; + assert.equal(f.retired.length, 0); +}); diff --git a/tests/unit/native-acp-runtime-session.test.ts b/tests/unit/native-acp-runtime-session.test.ts new file mode 100644 index 000000000..fcd181532 --- /dev/null +++ b/tests/unit/native-acp-runtime-session.test.ts @@ -0,0 +1,183 @@ +import test, { mock, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import { readFileSync } from 'node:fs'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { RuntimeEvent, RuntimeEventBody } from '../../src/shared/runtime-contract.ts'; +import { RuntimeRequests } from '../../src/agent/runtime/requests.ts'; +import { AcpSession } from '../../src/agent/runtime/acp/session.ts'; +mock.module('../../src/trace/store.js', { namedExports: { appendTraceEvent: () => { throw new Error('Unexpected default database'); } } }); +const { AcpRuntimeSession } = await import('../../src/agent/runtime/acp/runtime-session.ts'); + +type Wire = Record; +let serial = 0; +async function fixture(t: TestContext, patch: Partial[1]> = {}, permissions = 'auto') { + const child = Object.assign(new EventEmitter(), { pid: 60000 + (++serial), exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, stdout: new PassThrough(), stderr: new PassThrough(), stdin: new Writable() }); + const writes: Wire[] = [], events: RuntimeEvent[] = [], notices = new EventEmitter(); + let promptId: unknown, current = true, seq = 0, recordMode = 'ok'; + let context = { runId: 'run-1', sessionId: 'chat', scope: 'scope', turnId: 'turn-1', audience: 'internal' as const, + isCurrent: () => current }; + const send = (value: unknown) => { if (child.exitCode === null) child.stdout.write(JSON.stringify(value) + '\n'); }; + const reply = (id: unknown, result: unknown) => setImmediate(() => send({ jsonrpc: '2.0', id, result })); + const update = (text: string) => send({ jsonrpc: '2.0', method: 'session/update', params: { + sessionId: 'fixture-native', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } } }); + let onPrompt: (message: Wire) => void = message => { update('answer'); reply(message.id, { stopReason: 'end_turn' }); }; + let onReply: (message: Wire) => void = () => {}; + child.stdin = new Writable({ write(chunk, _encoding, callback) { + const message = JSON.parse(String(chunk)); writes.push(message); + if (message.method === 'initialize') reply(message.id, { protocolVersion: 1, agentCapabilities: { loadSession: true }, authMethods: [] }); + else if (message.method === 'session/new') reply(message.id, { sessionId: 'fixture-native' }); + else if (message.method === 'session/prompt') { promptId = message.id; onPrompt(message); } + else if (message.method === 'session/cancel') reply(promptId, { stopReason: 'cancelled' }); + else onReply(message); + callback(); notices.emit('change'); + } }); + const exit = () => { if (child.exitCode !== null) return; child.exitCode = 143; child.emit('exit', 143); child.emit('close', 143); }; + const registry = new RuntimeRequests(); + const protocol = new AcpSession(child as unknown as ChildProcessWithoutNullStreams, { + permissions, registry, promptTimeoutMs: 1000, + ownedProcessOptions: { terminateTree: () => queueMicrotask(exit) }, + }); + await protocol.start({ cwd: process.cwd() }); + const runtime = new AcpRuntimeSession(protocol, { provider: 'cursor', deferTurnEnd: true, registry, + capabilities: { transport: 'native', steer: 'restart', resume: true, tools: true, toolOutput: true, + approvals: true, questions: false, images: false, subagents: false }, + getTurnContext: () => context, + record: (owner, body) => { + if (recordMode === 'throw') throw new Error('private writer failure'); + if (recordMode === 'null') return null; + const event: RuntimeEvent = { ...owner, version: 1, seq: seq += 3, ...body }; + events.push(event); return event; + }, ...patch }); + t.after(async () => { await runtime.close(); notices.removeAllListeners(); }); + return { runtime, protocol, child, events, writes, registry, update, send, reply, exit, + run: (observer: (event: RuntimeEvent) => void = () => {}) => runtime.send({ text: 'fixture prompt' }, observer), + onPrompt: (value: typeof onPrompt) => { onPrompt = value; }, onReply: (value: typeof onReply) => { onReply = value; }, + invalidate: () => { current = false; }, recordMode: (mode: string) => { recordMode = mode; }, + context: () => context, next: () => { context = { ...context, runId: 'run-2', turnId: 'turn-2' }; }, + waitFor: (predicate: () => boolean) => predicate() ? Promise.resolve() : new Promise(resolve => { + const check = () => { if (predicate()) { notices.off('change', check); resolve(); } }; notices.on('change', check); + }), + }; +} + +test('real protocol fixture keeps commentary/tools separate from the full terminal candidate', async t => { + const f = await fixture(t); + const data = JSON.parse(readFileSync(new URL('../fixtures/grok-acp-read-file.json', import.meta.url), 'utf8')); + f.onPrompt(message => { for (const frame of data.frames) f.send(frame); f.reply(message.id, data.result); }); + const outcome = await f.run(); + assert.equal(outcome.finalText, data.expectedFinal); + assert.match(outcome.partialText, /I'll read/); + assert.ok(f.events.some(e => e.kind === 'tool')); + assert.equal(f.events.some(e => e.kind === 'turn-end'), false); + assert.equal(f.runtime.idle, false); + const claim = f.runtime.claimTurnOutcome('turn-1'); + assert.deepEqual(claim, outcome); assert.equal(f.runtime.claimTurnOutcome('turn-1'), claim); + assert.equal(f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'done', finalText: 'application final' }), true); + assert.equal(f.events.at(-1)?.kind, 'turn-end'); + assert.equal((f.events.at(-1) as Extract).finalText, 'application final'); + assert.equal(f.runtime.idle, true); +}); +for (const text of [null, '', ' \n\t ', 'x'.repeat(50_000)]) test(`absent/empty/full answer is independent of preview length: ${text === null ? 'null' : text.length}`, async t => { + const f = await fixture(t); + f.onPrompt(message => { if (text !== null) f.update(text); f.reply(message.id, { stopReason: 'end_turn' }); }); + const result = await f.run(); assert.equal(result.finalText, text); + if (text && text.length > 3000) assert.ok(f.events.filter(e => e.kind === 'message').every(e => e.text.length <= 3000)); +}); +for (const mode of ['null', 'throw']) test(`record ${mode} cannot destroy final or partial`, async t => { + const f = await fixture(t); f.recordMode(mode); + const outcome = await f.run(); + assert.deepEqual(outcome, { status: 'done', finalText: 'answer', partialText: 'answer' }); + assert.equal(f.events.length, 0); assert.ok(f.runtime.claimTurnOutcome('turn-1')); + assert.equal(f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'done', finalText: 'answer' }), true); +}); +test('canonical usage hook and throwing observer are optional, never a whole-send error', async t => { + const f = await fixture(t, { resultUsage: () => ({ kind: 'usage', inputTokens: 0, outputTokens: 12, cachedTokens: 3 }) }); + const result = await f.run(() => { throw new Error('observer'); }); + assert.equal(result.finalText, 'answer'); + const usage = f.events.find(e => e.kind === 'usage'); + assert.ok(usage && usage.kind === 'usage'); assert.equal(usage.inputTokens, 0); assert.equal(usage.cachedTokens, 3); + const bad = await fixture(t, { resultUsage: () => { throw new Error('optional usage'); } }); + assert.equal((await bad.run()).status, 'done'); +}); +test('send captures context once and rejects cross-owner reuse', async t => { + const f = await fixture(t); let reads = 0; + const original = f.context(); + f.onPrompt(message => { original.runId = 'mutated'; f.update('answer'); f.reply(message.id, { stopReason: 'end_turn' }); }); + await f.run(() => { reads++; }); assert.ok(reads > 0); + assert.ok(f.events.every(e => e.runId === 'run-1')); + assert.ok(f.runtime.claimTurnOutcome('turn-1')); + f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'done', finalText: 'answer' }); + original.scope = 'foreign'; + await assert.rejects(f.run(), /owner_changed/); +}); +test('deferred finalization requires exact claim, blocks reuse, and a late old token cannot end B', async t => { + const f = await fixture(t); await f.run(); + assert.equal(f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'done', finalText: 'bad' }), false); + assert.equal(f.runtime.claimTurnOutcome('foreign'), null); + await assert.rejects(f.run(), /busy/); + f.runtime.claimTurnOutcome('turn-1'); + f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'done', finalText: 'answer' }); + f.next(); await f.run(); + assert.equal(f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'done', finalText: 'old' }), false); + assert.equal(f.runtime.claimTurnOutcome('turn-2')?.status, 'done'); +}); +test('stop before claim survives removed main ownership and cannot upgrade back to done', async t => { + const f = await fixture(t); await f.run(); f.invalidate(); await f.runtime.cancel(); + assert.equal(f.runtime.claimTurnOutcome('turn-1')?.status, 'stopped'); + assert.equal(f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'done', finalText: 'bad' }), false); + assert.equal(f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'stopped', finalText: null }), true); +}); +test('late protocol failure before claim downgrades but after claim cannot rewrite the accepted result', async t => { + const before = await fixture(t); await before.run(); before.protocol.retire(); + assert.deepEqual(before.runtime.claimTurnOutcome('turn-1'), { status: 'error', finalText: null, partialText: 'answer' }); + const after = await fixture(t); await after.run(); const claim = after.runtime.claimTurnOutcome('turn-1'); + after.protocol.retire(); assert.equal(after.runtime.claimTurnOutcome('turn-1'), claim); + assert.equal(after.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'done', finalText: 'answer' }), true); + assert.equal(after.runtime.alive, false); +}); +test('real cancellation yields stopped partial and subsequent distinct turn can reuse protocol', async t => { + const f = await fixture(t); f.onPrompt(() => { f.update('partial'); }); + const pending = f.run(); await new Promise(resolve => setImmediate(resolve)); + await f.runtime.cancel(); const outcome = await pending; + assert.deepEqual(outcome, { status: 'stopped', finalText: null, partialText: 'partial' }); + f.runtime.claimTurnOutcome('turn-1'); f.runtime.finalizeTurn('turn-1', { kind: 'turn-end', status: 'stopped', finalText: null }); + f.next(); f.onPrompt(message => { f.update('next'); f.reply(message.id, { stopReason: 'end_turn' }); }); + assert.equal((await f.run()).finalText, 'next'); +}); +test('model error and early process exit preserve accepted partial without final fallback', async t => { + const max = await fixture(t); max.onPrompt(message => { max.update('incomplete'); max.reply(message.id, { stopReason: 'max_tokens' }); }); + assert.deepEqual(await max.run(), { status: 'error', finalText: null, partialText: 'incomplete' }); + const gone = await fixture(t); gone.onPrompt(() => { gone.update('accepted'); }); + const run = gone.run(); await new Promise(resolve => setImmediate(resolve)); gone.exit(); + assert.deepEqual(await run, { status: 'error', finalText: null, partialText: 'accepted' }); +}); +test('permission response uses the captured exact owner and never accepts a stale caller', async t => { + const f = await fixture(t, {}, 'safe'); let promptId: unknown; + f.onPrompt(message => { promptId = message.id; f.send({ jsonrpc: '2.0', id: 'permission', method: 'session/request_permission', params: { + sessionId: 'fixture-native', toolCall: { toolCallId: 'native-tool', title: 'Read' }, + options: [{ optionId: 'native-allow', kind: 'allow_once', name: 'Allow' }], + } }); }); + f.onReply(message => { if (message.id === 'permission') f.reply(promptId, { stopReason: 'end_turn' }); }); + const run = f.run(); const request = f.registry.list('chat')[0]!; + await f.runtime.respond(request.requestId, { optionId: request.view.fields[0]!.options[0]!.id }); + await run; assert.equal(f.writes.find(x => x.id === 'permission')!.result.outcome.optionId, 'native-allow'); + await assert.rejects(f.runtime.respond(request.requestId, { optionId: null }), /request_not_current/); +}); +test('invalid context and unsupported images reject before prompt dispatch', async t => { + const f = await fixture(t); f.context().scope = 'x'.repeat(241); + await assert.rejects(f.run(), /invalid_owner/); + await assert.rejects(f.runtime.send({ text: 'x', images: [{ mimeType: 'image/png', data: 'abc' }] }, () => {}), /prompt_unsupported/); + assert.equal(f.writes.some(x => x.method === 'session/prompt'), false); +}); +test('synchronous start observer cancellation never admits a prompt and standalone terminal cannot reenter', async t => { + const f = await fixture(t); let cancel: Promise | undefined; + const result = await f.run(event => { if (event.kind === 'turn-start') cancel = f.runtime.cancel(); }); + await cancel; assert.equal(result.status, 'stopped'); + assert.equal(f.writes.some(x => x.method === 'session/prompt'), false); + const plain = await fixture(t, { deferTurnEnd: false }); let reentry: Promise | undefined; + await plain.run(event => { if (event.kind === 'turn-end') reentry = assert.rejects(plain.run(), /busy/); }); + await reentry; assert.equal(plain.events.filter(e => e.kind === 'turn-end').length, 1); +}); diff --git a/tests/unit/native-cursor-spawn.test.ts b/tests/unit/native-cursor-spawn.test.ts new file mode 100644 index 000000000..c940d094c --- /dev/null +++ b/tests/unit/native-cursor-spawn.test.ts @@ -0,0 +1,365 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createServer, get } from 'node:http'; +import express from 'express'; +import type { AcpSession } from '../../src/agent/runtime/acp/session.ts'; +import type { ActivityPage, ActivityRunSummary } from '../../src/trace/activity-journal.ts'; + +const root = fs.mkdtempSync(join(tmpdir(), 'native-cursor-spawn-')); +const binary = join(root, 'cursor-agent.mjs'); +fs.writeFileSync(binary, `#!/usr/bin/env node +import readline from 'node:readline'; +const send = value => console.log(JSON.stringify(value)); +let nativeId='private-native-session',model='m1',effort='low',promptId; +const configs=()=>[{id:'model',type:'select',name:'Model',category:'model',currentValue:model,options:[{value:'m1',name:'M1'},{value:'m2',name:'M2'}]}, + {id:'effort',type:'select',name:'Effort',category:'thought_level',currentValue:effort,options:[{value:'low',name:'Low'},{value:'high',name:'High'}]}]; +const update=u=>send({jsonrpc:'2.0',method:'session/update',params:{sessionId:nativeId,update:u}}); +const text=value=>update({sessionUpdate:'agent_message_chunk',content:{type:'text',text:value}}); +for await(const line of readline.createInterface({input:process.stdin})) { + const r=JSON.parse(line),reply=result=>send({jsonrpc:'2.0',id:r.id,result}); + if(r.method==='initialize') reply({protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[{id:'cursor_login'}]}); + if(r.method==='authenticate') reply({}); + if(r.method==='session/new'||r.method==='session/load') {nativeId=r.params.sessionId||nativeId;reply({sessionId:nativeId,configOptions:configs()});} + if(r.method==='session/set_config_option'){if(r.params.configId==='model')model=r.params.value;else effort=r.params.value;reply({configOptions:configs()});} + if(r.method==='session/prompt') { + promptId=r.id; text('PRE-TOOL COMMENTARY'); + if(r.params.prompt[0].text.includes('HOLD_NATIVE_FIXTURE')) continue; + update({sessionUpdate:'tool_call',toolCallId:'private-tool',title:'read',rawInput:{path:'fixture.txt'}}); + update({sessionUpdate:'tool_call_update',toolCallId:'private-tool',status:'completed',content:[{type:'content',content:{type:'text',text:'tool output'}}]}); + text('NATIVE_MAIN_FINAL');reply({stopReason:'end_turn'}); + } + if(r.method==='session/cancel') send({jsonrpc:'2.0',id:promptId,result:{stopReason:'cancelled'}}); +} +`); +fs.chmodSync(binary, 0o755); +const config = await import('../../src/core/config.ts'); +config.settings.multiSession = { enabled: true, maxConcurrent: 4, midRunPolicy: 'steer' }; +test.mock.module('../../src/core/config.js', { namedExports: { ...config, + detectCli: () => ({ available: true, path: binary }) } }); +const factory = await import('../../src/agent/runtime/acp/cursor-session.ts'); +const sessions: AcpSession[] = []; +const inputs: Parameters[0][] = []; +let beforeFactory: (() => Promise) | undefined; +test.mock.module('../../src/agent/runtime/acp/cursor-session.js', { namedExports: { ...factory, + createCursorSession: async (input: Parameters[0]) => { + inputs.push(input); await beforeFactory?.(); + const session = await factory.createCursorSession(input); sessions.push(session); return session; + } } }); +const trace = await import('../../src/trace/store.ts'); +const { spawnAgent, killActiveAgent, waitForExitSettled, activeMainProcesses, enqueueMessage, messageQueue, removeQueuedMessage } = await import('../../src/agent/spawn.ts'); +const { db, getMaxMessageId, getSteerSalvageAfter } = await import('../../src/core/db.ts'); +const database = await import('../../src/core/db.ts'); +const { subscribe } = await import('../../src/core/event-bus.ts'); +const { addBroadcastListener, removeBroadcastListener } = await import('../../src/core/bus.ts'); +const { beginLiveRun, setLiveRunTraceId, getLiveRun, appendLiveRunTool } = await import('../../src/agent/live-run-state.ts'); +const { clearGoalTimers } = await import('../../src/agent/lifecycle-handler.ts'); +const { poolStats } = await import('../../src/agent/runtime-pool.ts'); +const { beginRuntimeSettingsMutation } = await import('../../src/core/runtime-settings-gate.ts'); +const { createChatSession, forkChatSession, setActiveChatSession } = await import('../../src/core/chat-sessions.ts'); +const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); +const { registerTraceRoutes } = await import('../../src/routes/traces.ts'); +let serial = 0; +test.beforeEach(t => { + inputs.length = 0; beforeFactory = undefined; + config.settings.cli = 'cursor'; config.settings.workingDir = root; config.settings.projectDirs = [root]; + config.settings.permissions = 'auto'; config.settings.fallbackOrder = []; config.settings.activeOverrides = {}; + config.settings.perCli = { ...config.settings.perCli, cursor: { model: 'm1', effort: 'low', transport: 'native' } }; + config.settings.memory = { ...config.settings.memory, enabled: false }; + config.settings.multiSession = { enabled: true, maxConcurrent: 4, midRunPolicy: 'steer' }; + fs.mkdirSync(join(config.JAW_HOME, 'prompts'), { recursive: true }); + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network in native fixture'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); +}); +test.afterEach(async () => { + clearGoalTimers(); + for (const session of sessions.splice(0)) await session.close(); + assert.equal(poolStats().busy, 0); +}); +test.after(() => fs.rmSync(root, { recursive: true, force: true })); +function options() { + const id = ++serial; + return { cli: 'cursor', model: 'm1', effort: 'low', origin: 'web', scopeKey: 'native-scope-' + id, + chatSessionId: createChatSession('native-chat-' + id).id, requestId: 'native-request-' + id, + sysPrompt: '', _skipHistory: true, _isSmokeContinuation: true }; +} + +for (const multiSession of [true, false]) test(`native main journal HTTP preserves exact owner, fork denial and replay (multiSession=${multiSession})`, { timeout: 15_000 }, async () => { + config.settings.multiSession.enabled = multiSession; + const opts = options(); + const other = createChatSession('unrelated-active-chat'); + const app = express(); + registerTraceRoutes(app, (_req, _res, next) => next()); + const server = createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); assert.ok(address && typeof address === 'object'); + const base = `http://127.0.0.1:${address.port}/api/traces`; + const read = (path: string, status = 200) => new Promise<{ data: T }>((resolve, reject) => { + const req = get(base + path, { signal: AbortSignal.timeout(3_000) }, res => { + let body = ''; + res.setEncoding('utf8'); res.on('data', value => { body += value; }); + res.on('end', () => { + try { + assert.equal(res.statusCode, status, path); + assert.equal(res.headers['cache-control'], 'no-store'); + resolve(JSON.parse(body)); + } catch (error) { reject(error); } + }); + }); + req.on('error', reject); + }); + const ready = Promise.withResolvers(); + const off = subscribe(event => { + if (event.event === 'agent_runtime' && event.data['kind'] === 'message' + && event.data['sessionId'] === opts.chatSessionId) ready.resolve(String(event.data['runId'])); + }); + let pending: ReturnType | undefined; + const timeout = setTimeout(() => ready.reject(new Error('native message not journaled')), 3_000); + try { + pending = spawnAgent('HOLD_NATIVE_FIXTURE', opts); + const heldId = await ready.promise; + clearTimeout(timeout); + const path = `/${heldId}/activity?session=${opts.chatSessionId}`; + const live = (await read(path)).data; + assert.equal(live.status, 'running'); assert.equal(live.scope, opts.scopeKey); + assert.equal(live.sessionId, opts.chatSessionId); assert.ok(live.events.length >= 2); + assert.equal(live.incomplete, false); + assert.ok(live.events.every((event: { sessionId: string; scope: string }) => + event.sessionId === opts.chatSessionId && event.scope === opts.scopeKey)); + await read(`/${heldId}/activity?session=${other.id}`, 404); + await read(`/${heldId}?session=${other.id}`, 404); + await read(`/${heldId}`, 404); + await read(`/${heldId}?session=${opts.chatSessionId}`); + assert.equal(killActiveAgent(opts.scopeKey, 'user'), true); + await pending.promise; pending = undefined; + const frozen = (await read(path + `&through=${live.through}`)).data; + assert.deepEqual(frozen.events, live.events); + assert.equal(frozen.through, live.through); + const stopped = (await read(path + `&after=${live.through}`)).data; + assert.equal(stopped.events.at(-1)?.kind, 'turn-end'); + assert.equal(stopped.events.at(-1)?.status, 'stopped'); + + const result = await spawnAgent('complete journal fixture', opts).promise; + assert.equal(result.text, 'NATIVE_MAIN_FINAL'); assert.ok(result.traceRunId); + const runId = result.traceRunId; + const row = trace.getTraceRun(runId)!; + assert.equal(row.session_id, opts.chatSessionId); assert.equal(row.scope_key, opts.scopeKey); + const message = db.prepare('SELECT content,trace_run_id FROM messages WHERE id=? AND session_id=?').get(row.message_id, opts.chatSessionId); + assert.deepEqual(message, { content: 'NATIVE_MAIN_FINAL', trace_run_id: runId }); + const replayPath = `/${runId}/activity?session=${opts.chatSessionId}`; + const first = (await read(replayPath + '&limit=1')).data; + let page = first; const replay = [...first.events]; + while (page.hasMore) { + const previous = page.nextAfter; + page = (await read(replayPath + `&limit=1&after=${page.nextAfter}&through=${first.through}`)).data; + assert.ok(page.nextAfter > previous, 'fixed-through replay must advance'); + assert.equal(page.through, first.through); replay.push(...page.events); + } + assert.equal(page.incomplete, false); + assert.equal(replay.filter(event => event.kind === 'turn-end').length, 1); + const end = replay.at(-1); assert.ok(end?.kind === 'turn-end'); + assert.equal(end.finalText, 'NATIVE_MAIN_FINAL'); + assert.ok(replay.some(event => event.kind === 'tool')); + assert.doesNotMatch(JSON.stringify(replay), /private-native-session|private-tool/); + const listed = (await read<{ runs: ActivityRunSummary[] }>(`/activity-runs?session=${opts.chatSessionId}`)).data.runs; + assert.ok(listed.some((run: { id: string }) => run.id === runId)); + const fork = forkChatSession(opts.chatSessionId); + assert.ok(fork.copiedCount > 0); + assert.ok(db.prepare('SELECT id FROM messages WHERE session_id=? AND trace_run_id=?').get(fork.id, runId)); + await read(`/${runId}/activity?session=${fork.id}`, 404); + await read(`/${runId}?session=${fork.id}`, 404); + assert.deepEqual((await read<{ runs: ActivityRunSummary[] }>(`/activity-runs?session=${fork.id}`)).data.runs, []); + await read(replayPath); + } finally { + clearTimeout(timeout); off(); + if (pending) { killActiveAgent(opts.scopeKey, 'user'); await pending.promise; } + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + setActiveChatSession('default'); + } +}); + +test('main actual factory/protocol/pool/lifecycle produces final-only MESSAGE and correlated native events', async () => { + const opts = options(), events: Array<{ type: string; data: Record }> = []; + const off = subscribe(event => events.push({ type: event.event, data: event.data })); + try { + const result = await spawnAgent('fixture', opts).promise; + assert.equal(result.code, 0); assert.equal(result.text, 'NATIVE_MAIN_FINAL'); + assert.equal(result.runtimeOutcome?.partialText, 'PRE-TOOL COMMENTARYNATIVE_MAIN_FINAL'); + assert.equal(inputs.length, 1); assert.equal(inputs[0]!.model, 'm1'); assert.equal(inputs[0]!.effort, 'low'); + assert.equal(events.some(event => event.type === 'agent_output'), false); + const native = events.filter(event => event.type === 'agent_runtime').map(event => event.data); + assert.equal(native.filter(event => event.kind === 'turn-end').length, 1); + assert.ok(native.some(event => event.kind === 'tool')); + assert.ok(native.every(event => event.scope === opts.scopeKey && event.sessionId === opts.chatSessionId)); + assert.doesNotMatch(JSON.stringify(native), /private-native-session|private-tool/); + assert.deepEqual(db.prepare('SELECT content FROM messages WHERE session_id=? AND role=?').all(opts.chatSessionId, 'assistant'), [{ content: 'NATIVE_MAIN_FINAL' }]); + assert.equal(activeMainProcesses.has(opts.scopeKey), false); assert.equal(poolStats().busy, 0); + const again = await spawnAgent('second fixture', opts).promise; + assert.equal(again.code, 0); assert.equal(inputs.length, 1, 'same scoped native process is reused'); + } finally { off(); } +}); +test('journal failure cannot suppress full final, scoped non-text I/O liveness or completion', async () => { + const opts = options(); const liveness: Record[] = []; + db.exec("CREATE TRIGGER cursor_journal_fault BEFORE INSERT ON trace_events WHEN new.source='runtime' BEGIN SELECT RAISE(ABORT,'fixture journal failure'); END"); + try { + const result = await spawnAgent('fixture', { ...opts, lifecycle: { + onActivity: (source, identity) => { assert.equal(source, 'native-runtime'); liveness.push(identity!); }, + onExit: () => { throw new Error('observer-only failure'); }, + } }).promise; + assert.equal(result.text, 'NATIVE_MAIN_FINAL'); assert.equal(result.code, 0); + assert.ok(liveness.length > 0); + assert.ok(liveness.every(value => value.scope === opts.scopeKey && value.sessionId === opts.chatSessionId && value.requestId === opts.requestId)); + assert.deepEqual(db.prepare('SELECT content FROM messages WHERE session_id=? AND role=?').all(opts.chatSessionId, 'assistant'), [{ content: 'NATIVE_MAIN_FINAL' }]); + const run = db.prepare('SELECT id FROM trace_runs WHERE session_id=?').get(opts.chatSessionId) as { id: string }; + const page = readActivityPage({ runId: run.id, sessionId: opts.chatSessionId, after: 0, limit: 40 }); + assert.ok(page?.incomplete, 'real runtime insert failure reached durable loss reporting'); + assert.deepEqual(page.events, []); + } finally { db.exec('DROP TRIGGER cursor_journal_fault'); } +}); +test('native kill-steer preserves partial MESSAGE before the exact exit barrier', async () => { + const opts = options(), watermark = getMaxMessageId(opts.chatSessionId); + let barrier: Promise | undefined, salvage: string | null | undefined; + const off = subscribe(event => { + if (event.event !== 'agent_runtime' || event.data['kind'] !== 'message' || barrier) return; + assert.equal(killActiveAgent(opts.scopeKey, 'steer'), true); + barrier = waitForExitSettled(opts.scopeKey).then(() => { salvage = getSteerSalvageAfter(opts.chatSessionId, watermark); }); + }); + try { + const result = await spawnAgent('HOLD_NATIVE_FIXTURE', opts).promise; + assert.ok(barrier); await barrier; + assert.equal(result.runtimeOutcome?.status, 'stopped'); assert.equal(result.runtimeOutcome.finalText, null); + assert.equal(salvage, '⏹️ [interrupted]\n\nPRE-TOOL COMMENTARY'); + assert.equal(activeMainProcesses.has(opts.scopeKey), false); + } finally { off(); } +}); +test('explicit mention-watch binding survives multi-session off and a different active chat', async () => { + config.settings.multiSession.enabled = false; + const opts = { ...options(), origin: 'heartbeat', scopeKey: 'mention-watch:fixture-thread' }; + const result = await spawnAgent('fixture', opts).promise; + assert.equal(result.text, 'NATIVE_MAIN_FINAL'); assert.equal(inputs[0]!.cwd, fs.realpathSync(root)); + assert.deepEqual(db.prepare('SELECT content FROM messages WHERE session_id=? AND role=?').all(opts.chatSessionId, 'assistant'), [{ content: 'NATIVE_MAIN_FINAL' }]); +}); +test('restrictive native rejection occurs before actual prompt-file writes and factory admission', async t => { + config.settings.permissions = 'safe'; const opts = options(); + const write = t.mock.method(fs, 'writeFileSync', () => { assert.fail('rejected native request wrote a prompt file'); }); + const before = db.prepare('SELECT COUNT(*) AS n FROM messages').get(); + const result = await spawnAgent('not admitted', opts).promise; + assert.equal(result.code, 78); assert.match(result.text, /restrictive/); + assert.equal(inputs.length, 0); assert.equal(write.mock.callCount(), 0); + assert.deepEqual(db.prepare('SELECT COUNT(*) AS n FROM messages').get(), before); +}); +test('old native finalization cannot clear or merge a replacement live run after early release', async () => { + const opts = options(); let replaced = false; + const listener = (type: string, data: Record) => { + if (type !== 'agent_status' || data['running'] !== false || data['scope'] !== opts.scopeKey || replaced) return; + replaced = true; beginLiveRun(opts.scopeKey, 'cursor'); setLiveRunTraceId(opts.scopeKey, 'replacement-trace'); + appendLiveRunTool(opts.scopeKey, { icon: 'x', label: 'foreign replacement', toolType: 'tool' }); + }; + addBroadcastListener(listener); + try { + const result = await spawnAgent('fixture', opts).promise; + assert.equal(result.text, 'NATIVE_MAIN_FINAL'); assert.equal(replaced, true); + assert.equal(getLiveRun(opts.scopeKey).traceRunId, 'replacement-trace'); + const row = db.prepare('SELECT tool_log FROM messages WHERE session_id=? AND role=?').get(opts.chatSessionId, 'assistant') as { tool_log: string }; + assert.doesNotMatch(row.tool_log, /foreign replacement/); + } finally { removeBroadcastListener(listener); } +}); +test('failed user insert releases a lease acquired before ready attachment completed', async t => { + const opts = options(); + t.mock.method(database.insertMessage, 'run', () => { throw new Error('fixture user insert failed'); }); + try { + const result = await spawnAgent('fixture', opts).promise; + assert.notEqual(result.code, 0); + assert.equal(activeMainProcesses.has(opts.scopeKey), false); + assert.equal(poolStats().busy, 0); + } finally { activeMainProcesses.delete(opts.scopeKey); } +}); +for (const stage of ['acquire', 'ready', 'settle'] as const) test(`native ${stage} failure wakes an already queued follow-up exactly once`, async t => { + const opts = { ...options(), _isSmokeContinuation: false }; + const entered = Promise.withResolvers(), gate = Promise.withResolvers(); + beforeFactory = async () => { + if (inputs.length !== 1) return; + entered.resolve(); await gate.promise; + if (stage === 'acquire') throw new Error('fixture acquire failed'); + }; + if (stage !== 'acquire') { + const statement = stage === 'ready' ? database.insertMessage : database.insertMessageWithTraceRun; + const original = statement.run; let first = true; + t.mock.method(statement, 'run', function (...args: any[]) { + if (first) { first = false; throw new Error('fixture insert failed'); } + return Reflect.apply(original, statement, args); + }); + } + const requestId = 'queued-' + opts.requestId; + const terminals: Record[] = []; + const completed = Promise.withResolvers(); + const off = subscribe(event => { + if (event.event === 'orchestrate_done' && event.data['requestId'] === requestId) { + terminals.push(event.data); completed.resolve(); + } + }); + let queueId: string | undefined, deadline: ReturnType | undefined; + try { + const first = spawnAgent('first native run', opts); + await entered.promise; + queueId = enqueueMessage('queued native follow-up', 'web', { scope: opts.scopeKey, chatSessionId: opts.chatSessionId, requestId }); + assert.ok(messageQueue.some(item => item.id === queueId)); + gate.resolve(); + assert.notEqual((await first.promise).code, 0); + await Promise.race([completed.promise, new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error('queued follow-up was not woken after native failure')), 2_000); + })]); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(terminals.length, 1); assert.equal(terminals[0]!['text'], 'NATIVE_MAIN_FINAL'); + assert.equal(messageQueue.some(item => item.id === queueId), false); + assert.equal(inputs.length, 2); assert.equal(activeMainProcesses.has(opts.scopeKey), false); + } finally { + gate.resolve(); if (deadline) clearTimeout(deadline); off(); + if (queueId && messageQueue.some(item => item.id === queueId)) removeQueuedMessage(queueId); + } +}); +test('throwing failure-terminal listener cannot skip canonical finalization or trigger another terminal', async t => { + const opts = options(); const events: Array<{ type: string; data: Record }> = []; + t.mock.method(database.insertMessageWithTraceRun, 'run', () => { throw new Error('fixture assistant insert failed'); }); + const off = subscribe(event => events.push({ type: event.event, data: event.data })); + const fail = (type: string, data: Record) => { + if (type === 'agent_done' && data['sessionId'] === opts.chatSessionId) throw new Error('fixture terminal listener failed'); + }; + addBroadcastListener(fail); + try { + const result = await spawnAgent('fixture', opts).promise; + assert.notEqual(result.code, 0); assert.equal(activeMainProcesses.has(opts.scopeKey), false); + assert.equal(events.filter(event => event.type === 'agent_done').length, 1); + const ends = events.filter(event => event.type === 'agent_runtime' && event.data['kind'] === 'turn-end'); + assert.equal(ends.length, 1); assert.equal(ends[0]!.data['status'], 'error'); + assert.equal(ends[0]!.data['finalText'], null); + assert.equal(poolStats().busy, 0); + } finally { off(); removeBroadcastListener(fail); } +}); +test('actual settings wait preserves captured placement and cannot clear a replacement starting flag', async () => { + const opts = { ...options(), _isSmokeContinuation: false }; + const endMutation = beginRuntimeSettingsMutation(); + const replacement = { process: null, starting: true, steering: false, ownerGeneration: 0, + meta: { origin: 'web', scopeId: opts.scopeKey, chatSessionId: 'replacement-chat' } }; + const listener = (type: string, data: Record) => { + if (type === 'agent_done' && data['sessionId'] === opts.chatSessionId) activeMainProcesses.set(opts.scopeKey, replacement); + }; + addBroadcastListener(listener); + try { + const run = spawnAgent('fixture', opts); + assert.equal(inputs.length, 0); assert.equal(activeMainProcesses.get(opts.scopeKey)?.starting, true); + config.settings.multiSession.enabled = false; endMutation(); + const result = await run.promise; + assert.equal(result.text, 'NATIVE_MAIN_FINAL'); + assert.deepEqual(db.prepare('SELECT content FROM messages WHERE session_id=? AND role=?').all(opts.chatSessionId, 'assistant'), [{ content: 'NATIVE_MAIN_FINAL' }]); + assert.equal(activeMainProcesses.get(opts.scopeKey), replacement); + assert.equal(replacement.starting, true); + } finally { + endMutation(); removeBroadcastListener(listener); + if (activeMainProcesses.get(opts.scopeKey) === replacement) activeMainProcesses.delete(opts.scopeKey); + } +}); diff --git a/tests/unit/native-legacy-prompt-routing.test.ts b/tests/unit/native-legacy-prompt-routing.test.ts new file mode 100644 index 000000000..8bfd12197 --- /dev/null +++ b/tests/unit/native-legacy-prompt-routing.test.ts @@ -0,0 +1,148 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { join } from 'node:path'; + +// Observe the real spawn -> bootstrap -> argv chain. Availability is deliberately +// false at the final preflight, after argv preparation, so no provider is launched. +const calls: string[] = []; +const argvCalls: Array<{ kind: 'fresh' | 'resume'; prompt: string; argv: string[] }> = []; +const bootstrapInputs: Parameters[0][] = []; +const config = await import('../../src/core/config.ts'); +test.mock.module('../../src/core/config.js', { namedExports: { ...config, + detectCli: (cli: string) => { + calls.push(`detect:${cli}`); + return { available: false, path: 'fixture-no-provider' }; + }, +} }); +const capabilities = await import('../../src/agent/agy-capabilities.ts'); +test.mock.module('../../src/agent/agy-capabilities.js', { namedExports: { ...capabilities, + detectAgyCapabilities: () => { + calls.push('capabilities'); + return { print: true, printFlag: '-p', conversation: true, model: false, + printTimeout: false, logFile: false, addDir: false, + dangerousSkipPermissions: false, sandbox: false }; + }, +} }); +const bootstrap = await import('../../src/agent/agy-bootstrap.ts'); +test.mock.module('../../src/agent/agy-bootstrap.js', { namedExports: { ...bootstrap, + buildAgyBootstrapEnvelope: (input: Parameters[0]) => { + calls.push('bootstrap'); bootstrapInputs.push(input); + return bootstrap.buildAgyBootstrapEnvelope(input); + }, +} }); +const args = await import('../../src/agent/args.ts'); +test.mock.module('../../src/agent/args.js', { namedExports: { ...args, + buildArgs: (...input: Parameters) => { + calls.push('argv:fresh'); + const argv = args.buildArgs(...input); + argvCalls.push({ kind: 'fresh', prompt: input[3], argv }); return argv; + }, + buildResumeArgs: (...input: Parameters) => { + calls.push('argv:resume'); + const argv = args.buildResumeArgs(...input); + argvCalls.push({ kind: 'resume', prompt: input[4], argv }); return argv; + }, +} }); +const { spawnAgent, activeMainProcesses } = await import('../../src/agent/spawn.ts'); +const database = await import('../../src/core/db.ts'); +const { runtimeForScope, jawRuntimesByScope } = await import('../../src/agent/jwc-runtime.ts'); +const { appendLiveRunText } = await import('../../src/agent/live-run-state.ts'); +const { setPendingBootstrapPrompt, peekPendingBootstrapPrompt } = await import('../../src/core/main-session.ts'); +const home = config.JAW_HOME; +let serial = 0; +test.beforeEach(t => { + calls.length = 0; argvCalls.length = 0; bootstrapInputs.length = 0; + config.settings.workingDir = home; config.settings.projectDirs = [home]; + config.settings.permissions = 'auto'; config.settings.fallbackOrder = []; + config.settings.activeOverrides = {}; + config.settings.memory = { ...config.settings.memory, enabled: false }; + config.settings.multiSession = { enabled: true, maxConcurrent: 4, midRunPolicy: 'steer' }; + fs.mkdirSync(join(home, 'prompts'), { recursive: true }); + t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 8, 6, 13, 2).getTime() }); + t.mock.method(globalThis, 'fetch', async () => { assert.fail('unexpected provider/network request'); }); +}); +test.afterEach(() => { assert.equal(activeMainProcesses.size, 0); }); +function options(cli: string) { + const id = ++serial; + return { cli, model: 'default', effort: '', origin: 'web', sysPrompt: 'OPERATIONAL_RULES_ONLY', + scopeKey: `legacy-routing-${id}`, chatSessionId: `legacy-chat-${id}`, _isSmokeContinuation: true }; +} + +test('JWC resident branch passes each current prompt directly, without bootstrap/history/argv wrapping', async t => { + const opts = options('jwc'); + config.settings.perCli = { ...config.settings.perCli, + jwc: { model: 'resident-model', provider: 'anthropic' } }; + database.insertMessage.run('user', 'OLD_HISTORY_NOT_CURRENT', 'jwc', 'default', home, opts.chatSessionId); + setPendingBootstrapPrompt('COMPACT_NOT_CURRENT', opts.scopeKey); + const historyRead = t.mock.method(database.getRecentMessages, 'all'); + const resident = runtimeForScope(opts.scopeKey); + const prompt = t.mock.method(resident, 'prompt', async (_cwd: string, _text: string) => { + appendLiveRunText(opts.scopeKey, 'resident final'); + return { text: '', code: 0 }; + }); + try { + for (const current of ['CURRENT_REQUEST_A\nkeep exact whitespace ', 'CURRENT_REQUEST_B']) { + const run = spawnAgent(current, opts); + assert.equal(run.child, null); + assert.deepEqual(await run.promise, { text: 'resident final', code: 0 }); + assert.equal(runtimeForScope(opts.scopeKey), resident, 'reuse the scoped resident'); + } + assert.deepEqual(prompt.mock.calls.map(call => call.arguments), [ + [home, 'CURRENT_REQUEST_A\nkeep exact whitespace '], [home, 'CURRENT_REQUEST_B'], + ]); + assert.equal(historyRead.mock.callCount(), 0); + assert.equal(peekPendingBootstrapPrompt(opts.scopeKey), 'COMPACT_NOT_CURRENT'); + assert.deepEqual(argvCalls, []); assert.deepEqual(bootstrapInputs, []); + assert.deepEqual(calls, [], 'resident must not run CLI capability/preflight probes'); + } finally { + await resident.dispose(); jawRuntimesByScope.delete(opts.scopeKey); + setPendingBootstrapPrompt(null, opts.scopeKey); + } +}); + +for (const resume of [false, true]) for (const order of ['task-first', 'context-first'] as const) { + test(`SF-004b: AGY ${resume ? 'guarded resume' : 'fresh compact handoff'} ${order} prepares enriched argv once`, async () => { + const opts = options('agy'); + config.settings.perCli = { ...config.settings.perCli, + agy: { model: 'default', nativeResume: 'guarded', promptOrder: order } }; + database.insertMessage.run('user', 'HISTORY_ONLY', 'agy', 'default', home, opts.chatSessionId); + if (resume) { + // Real guarded-resume decision from a valid, owned bucket, not a + // mocked isResume boolean or an employee-session shortcut. + const bucket = `agy:${opts.scopeKey}`; + database.upsertSessionBucket.run(bucket, 'saved-agy-conversation', 'default', null, 0); + database.db.prepare('UPDATE session_buckets SET updated_at=? WHERE bucket=?').run(Date.now(), bucket); + database.updateSessionBucketLastRun.run(1, home, + JSON.stringify({ plannerOnly: false, checkpointSeen: false }), bucket); + } else setPendingBootstrapPrompt('COMPACT_HANDOFF_ONLY', opts.scopeKey); + const result = await spawnAgent('CURRENT_AGY_REQUEST', opts).promise; + assert.equal(result.code, 127, 'stop at mocked availability, after real argv preparation'); + assert.deepEqual(calls, ['detect:agy', 'capabilities', 'bootstrap', + resume ? 'argv:resume' : 'argv:fresh', 'detect:agy']); + assert.equal(argvCalls.length, 1); assert.equal(bootstrapInputs.length, 1); + const task = `260906-01:02PM.\nProject root: ${home}\n` + + (resume ? '' : 'COMPACT_HANDOFF_ONLY\n\n---\n\n') + 'CURRENT_AGY_REQUEST'; + const history = resume ? '' : '[Recent Context]\n[user] HISTORY_ONLY'; + assert.deepEqual(bootstrapInputs[0], { taskPrompt: task, historyBlock: history, + workingDir: home, sessionId: resume ? 'saved-agy-conversation' : null, + order, operationalContext: 'OPERATIONAL_RULES_ONLY' }); + const call = argvCalls[0]!; + assert.equal(call.kind, resume ? 'resume' : 'fresh'); + assert.deepEqual(call.argv, resume + ? ['--conversation', 'saved-agy-conversation', '-p', call.prompt] : ['-p', call.prompt]); + // Independent serializer oracle. Only the hash value is opaque; the + // supplied task, rules, history, order and conversation are exact. + const headerEnd = call.prompt.indexOf('\n\n---\n\n'); + const header = call.prompt.slice(0, headerEnd); + assert.match(header, /^\[CLI-JAW AGY BOOTSTRAP\]\nCLI_JAW_BOOTSTRAP_SHA=[a-f0-9]{16}\n/); + assert.equal(header.split('\n').slice(2).join('\n'), `cwd=${home}\nsession=${resume ? 'saved-agy-conversation' : 'fresh'}\nrule=This marker proves the current cli-jaw runtime envelope reached AGY.`); + const taskSection = `[Current cli-jaw task]\n${task}`; + const context = '[Operational Context — cli-jaw Integration]\nThe following operational guidelines apply to this session. Follow these task rules and use the tools/commands described:\n\nOPERATIONAL_RULES_ONLY'; + const extras = [context, ...(resume ? [] : [`[Recent context / history]\n${history}`])]; + assert.equal(call.prompt.slice(headerEnd + '\n\n---\n\n'.length), + (order === 'task-first' ? [taskSection, ...extras] : [...extras, taskSection]).join('\n\n---\n\n')); + assert.equal(peekPendingBootstrapPrompt(opts.scopeKey), null); + }); +} diff --git a/tests/unit/native-main-replacement.test.ts b/tests/unit/native-main-replacement.test.ts new file mode 100644 index 000000000..9df2387f8 --- /dev/null +++ b/tests/unit/native-main-replacement.test.ts @@ -0,0 +1,175 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import type { MainRunState } from '../../src/agent/spawn.ts'; + +function forbidden(name: string) { + return (..._args: unknown[]): never => assert.fail(`common replacement crossed forbidden seam: ${name}`); +} +// A routing regression must not launch a provider or capability probe. +const processSeams = Object.fromEntries( + ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork'].map(name => [name, forbidden(name)]), +); +test.mock.module('node:child_process', { + namedExports: { ...childProcess, ...processSeams }, defaultExport: { ...childProcess, ...processSeams }, +}); +const config = await import('../../src/core/config.ts'); +const settings = { ...config.settings, cli: 'cursor', workingDir: config.JAW_HOME, projectDirs: [config.JAW_HOME], + perCli: {}, activeOverrides: {}, fallbackOrder: [], + multiSession: { enabled: true, maxConcurrent: 4, midRunPolicy: 'steer' } }; +test.mock.module('../../src/core/config.js', { namedExports: { ...config, settings, + detectCli: forbidden('detectCli'), detectAllCli: forbidden('detectAllCli') } }); +const { activeMainProcesses, canSteerAgent, steerAgent, enqueueMessage, messageQueue, killActiveAgent } = await import('../../src/agent/spawn.ts'); +const { MainReplacementOwnerMismatchError } = await import('../../src/agent/runtime/replace-turn.ts'); +const { db } = await import('../../src/core/db.ts'); +const { subscribe } = await import('../../src/core/event-bus.ts'); +const { admitRequest, settleAllPending } = await import('../../src/orchestrator/request-registry.ts'); +const { clearGoalTimers } = await import('../../src/agent/lifecycle-handler.ts'); +const { bumpScopeSessionGeneration, bumpSessionOwnershipGeneration, resetSessionOwnershipGenerationForTest } + = await import('../../src/agent/session-persistence.ts'); + +let serial = 0; +test.beforeEach(t => { + resetSessionOwnershipGenerationForTest(); + t.mock.method(globalThis, 'fetch', forbidden('fetch')); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'error', () => {}); +}); +test.afterEach(() => { + activeMainProcesses.clear(); clearGoalTimers(); settleAllPending('dropped', 'fixture-cleanup'); +}); +function options() { + const id = ++serial; + return { scopeKey: 'replacement-scope-' + id, chatSessionId: 'replacement-chat-' + id, requestId: 'replacement-request-' + id }; +} +function rows(sessionId: string) { + return db.prepare('SELECT role,content FROM messages WHERE session_id=? ORDER BY id').all(sessionId); +} +function capture() { + const events: Array<{ type: string; data: Record }> = []; + const off = subscribe(event => events.push({ type: event.event, data: event.data })); + return { events, off }; +} + +function heldReplacement(opts: ReturnType) { + const entered = Promise.withResolvers(), dispatch = Promise.withResolvers(); + let stopped = false; + const run: MainRunState = { process: null, starting: false, steering: false, ownerGeneration: 7, + meta: { cli: 'cursor', origin: 'web', chatSessionId: opts.chatSessionId }, + cancelTurn: () => { stopped = true; }, + replaceTurn: async (_text, commitInput) => { + entered.resolve(); await dispatch.promise; + commitInput(); return { kind: 'dispatched' }; + } }; + activeMainProcesses.set(opts.scopeKey, run); admitRequest(opts.requestId, opts.scopeKey); + return { run, entered, dispatch, get stopped() { return stopped; } }; +} + +for (const finish of ['stop', 'natural'] as const) test(`held native no-start observes ${finish} without guessing from main-map absence`, async () => { + const opts = options(), f = heldReplacement(opts), { events, off } = capture(); + f.run.replaceTurn = async () => { f.entered.resolve(); await f.dispatch.promise; return { kind: 'race', reason: 'busy' }; }; + const pending = steerAgent(opts.scopeKey, 'uncommitted input', 'web', { requestId: opts.requestId }); + try { + await f.entered.promise; + if (finish === 'stop') killActiveAgent(opts.scopeKey, 'user'); + else activeMainProcesses.delete(opts.scopeKey); + f.dispatch.resolve(); + assert.equal(await pending, finish === 'stop' ? 'cancelled' : 'fallback-queue'); + assert.deepEqual(rows(opts.chatSessionId), []); + const receipts = events.filter(event => event.type === 'request_settled' && event.data['requestId'] === opts.requestId); + assert.equal(receipts.length, finish === 'stop' ? 1 : 0); + if (finish === 'stop') assert.equal(receipts[0]!.data['outcome'], 'cancelled'); + } finally { f.dispatch.resolve(); off(); } +}); + +for (const invalidation of ['map replacement', 'generation change', 'canonical scope reset', 'canonical global reset'] as const) { + test(`deferred replacement refuses stale input after ${invalidation}`, async () => { + const opts = options(), f = heldReplacement(opts), { events, off } = capture(); + const before = db.prepare('SELECT COUNT(*) AS n FROM messages').get(); + let resolved = false; + const steering = steerAgent(opts.scopeKey, 'held B', 'web', { requestId: opts.requestId }).then(outcome => { + resolved = true; + if (outcome === 'fallback-queue') enqueueMessage('held B', 'web', { scope: opts.scopeKey, chatSessionId: opts.chatSessionId }); + return outcome; + }); + try { + await f.entered.promise; + if (invalidation === 'map replacement') activeMainProcesses.set(opts.scopeKey, { ...f.run }); + else if (invalidation === 'generation change') f.run.ownerGeneration++; + else { + if (invalidation === 'canonical scope reset') bumpScopeSessionGeneration(opts.scopeKey); + else bumpSessionOwnershipGeneration(); + assert.equal(activeMainProcesses.get(opts.scopeKey), f.run); + assert.equal(f.run.ownerGeneration, 7, 'canonical resets leave the captured run field unchanged'); + } + const rejected = assert.rejects(steering, error => error instanceof MainReplacementOwnerMismatchError + && error.code === 'native_replacement_owner_mismatch' && error.message === error.code); + f.dispatch.resolve(); await rejected; + assert.equal(resolved, false); + assert.deepEqual(db.prepare('SELECT COUNT(*) AS n FROM messages').get(), before); + assert.deepEqual(events, []); + assert.equal(messageQueue.some(item => item.scope === opts.scopeKey), false); + } finally { f.dispatch.resolve(); off(); } + }); +} + +test('Stop with the same owner still commits an already dispatched replacement once', async () => { + const opts = options(), f = heldReplacement(opts), { events, off } = capture(); + const steering = steerAgent(opts.scopeKey, 'held B', 'web', { requestId: opts.requestId }); + try { + await f.entered.promise; + f.run.cancelTurn!('user'); + assert.equal(f.stopped, true); assert.equal(activeMainProcesses.get(opts.scopeKey), f.run); + f.dispatch.resolve(); assert.equal(await steering, 'steered'); + assert.deepEqual(rows(opts.chatSessionId), [{ role: 'user', content: 'held B' }]); + assert.equal(events.filter(e => e.type === 'new_message').length, 1); + assert.equal(events.filter(e => e.type === 'steer_started' && e.data['localDispatch'] === true).length, 1); + assert.equal(events.filter(e => e.type === 'request_settled' && e.data['outcome'] === 'steered').length, 1); + assert.equal(messageQueue.some(item => item.scope === opts.scopeKey), false); + } finally { f.dispatch.resolve(); off(); } +}); + +test('common replacement hook dispatches for Cursor and other providers before in-band steering', async () => { + for (const cli of ['cursor', 'claude', 'codex-app']) { + const opts = options(), { events, off } = capture(); + const run: MainRunState = { process: null, starting: false, steering: false, ownerGeneration: 0, + meta: { cli, origin: 'web', chatSessionId: opts.chatSessionId }, + replaceTurn: async (text, commitInput) => { + assert.equal(text, 'generic B'); assert.equal(rows(opts.chatSessionId).length, 0); + commitInput(); return { kind: 'dispatched' }; + } }; + activeMainProcesses.set(opts.scopeKey, run); admitRequest(opts.requestId, opts.scopeKey); + try { + assert.equal(canSteerAgent(opts.scopeKey), true, 'replacement hook alone enables CLI steering'); + run.steerTurnInBand = async () => assert.fail('replacement fell through to in-band handler'); + assert.equal(await steerAgent(opts.scopeKey, 'generic B', 'web', { requestId: opts.requestId }), 'steered'); + assert.deepEqual(rows(opts.chatSessionId), [{ role: 'user', content: 'generic B' }]); + assert.equal(events.filter(e => e.type === 'steer_started' && e.data['mode'] === 'cancel-reprompt' + && e.data['localDispatch'] === true).length, 1); + assert.equal(events.filter(e => e.type === 'request_settled' && e.data['requestId'] === opts.requestId).length, 1); + } finally { off(); activeMainProcesses.delete(opts.scopeKey); } + } +}); + +test('common replacement hook distinguishes busy no-start, fatal and inconsistent receipts', async () => { + const opts = options(), { events, off } = capture(); + const run: MainRunState = { process: null, starting: false, steering: false, ownerGeneration: 0, + meta: { cli: 'cursor', origin: 'web', chatSessionId: opts.chatSessionId }, + replaceTurn: async () => ({ kind: 'unavailable', reason: 'busy' }) }; + activeMainProcesses.set(opts.scopeKey, run); + try { + assert.equal(await steerAgent(opts.scopeKey, 'queued C', 'web'), 'fallback-queue'); + assert.equal(events.find(e => e.type === 'steer_rejected')?.data['reason'], 'busy'); + const failure = new Error('fixture fatal replacement'); + run.replaceTurn = async () => ({ kind: 'failed', error: failure }); + await assert.rejects(steerAgent(opts.scopeKey, 'failed C', 'web'), error => error === failure); + run.replaceTurn = async () => ({ kind: 'dispatched' }); + await assert.rejects(steerAgent(opts.scopeKey, 'missing callback', 'web'), /native_replacement_inconsistent_receipt/); + assert.equal(rows(opts.chatSessionId).length, 0); + run.replaceTurn = async (_text, commitInput) => { commitInput(); commitInput(); return { kind: 'dispatched' }; }; + await assert.rejects(steerAgent(opts.scopeKey, 'duplicate callback', 'web'), /native_replacement_duplicate_input/); + assert.deepEqual(rows(opts.chatSessionId), [{ role: 'user', content: 'duplicate callback' }]); + } finally { off(); activeMainProcesses.delete(opts.scopeKey); } +}); diff --git a/tests/unit/native-runtime-liveness.test.ts b/tests/unit/native-runtime-liveness.test.ts new file mode 100644 index 000000000..c49050edf --- /dev/null +++ b/tests/unit/native-runtime-liveness.test.ts @@ -0,0 +1,249 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PassThrough } from 'node:stream'; +import { settings } from '../../src/core/config.ts'; +import { createChatSession, setActiveChatSession } from '../../src/core/chat-sessions.ts'; +import { currentSessionScope } from '../../src/core/session-context.ts'; +import { broadcast } from '../../src/core/bus.ts'; +import { subscribe } from '../../src/core/event-bus.ts'; +import { orchestrateAndCollectData } from '../../src/orchestrator/collect.ts'; +import { orchestrate, drainPendingReplays } from '../../src/orchestrator/pipeline.ts'; +import { claimWorker, finishWorker } from '../../src/orchestrator/worker-registry.ts'; +import { t } from '../../src/core/i18n.ts'; +import type { spawnAgent } from '../../src/agent/spawn.ts'; +import type { RuntimeLivenessIdentity } from '../../src/shared/runtime-contract.ts'; + +type Options = NonNullable[1]>; +type Activity = (source: string, identity?: RuntimeLivenessIdentity) => void; +const MINUTE = 60_000; +const originalMulti = settings['multiSession']; +const differentActiveChat = createChatSession('liveness-active-other').id; +test.afterEach(() => { settings['multiSession'] = originalMulti; setActiveChatSession('default'); }); + +// Only the native-process boundary is faked. Collector, pipeline, ALS and terminal +// publication are real. Physical listener/lease cleanup belongs to spawn's tests. +function nativeIO() { + const started = Promise.withResolvers(); + const completion = Promise.withResolvers<{ text: string; code: number; traceRunId: string; + runtimeOutcome: { status: 'done'; finalText: string; partialText: string } }>(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let identity!: RuntimeLivenessIdentity; + let activity: Activity | undefined; + let captured: ReturnType; + const onData = () => activity?.('native-runtime', identity); + return { + started: started.promise, + spawn: (_prompt: string, opts: Options) => { + identity = Object.freeze({ runId: 'native-run', scope: opts.scopeKey!, + sessionId: opts.chatSessionId!, origin: opts.origin!, + ...(opts.requestId ? { requestId: opts.requestId } : {}) }); + activity = opts.lifecycle?.onActivity; + captured = currentSessionScope(); + stdout.on('data', onData); + stderr.on('data', onData); + started.resolve(); + return { child: null, promise: completion.promise }; + }, + pulse: (stream: 'stdout' | 'stderr' = 'stdout') => { + (stream === 'stdout' ? stdout : stderr).write(Buffer.from('opaque native model-only frame')); + }, + notify: (patch: Record = {}, source = 'native-runtime') => { + activity?.(source, { ...identity, ...patch } as RuntimeLivenessIdentity); + }, + withoutIdentity: () => activity?.('native-runtime'), + terminal: () => broadcast('agent_done', { ...identity, traceRunId: identity.runId, + runtimeFinality: 'present', runtimeStatus: 'done', text: ' final only\n' }), + finish: () => { + stdout.off('data', onData); + stderr.off('data', onData); + stdout.destroy(); + stderr.destroy(); + completion.resolve({ text: ' final only\n', code: 0, traceRunId: identity.runId, + runtimeOutcome: { status: 'done', finalText: ' final only\n', partialText: 'private partial' } }); + }, + get identity() { return identity; }, + get captured() { return captured; }, + }; +} + +const placements = [ + { name: 'unscoped-off', enabled: false, meta: {}, expectedScope: 'default', expectedSession: differentActiveChat }, + { name: 'local-on', enabled: true, meta: { chatSessionId: 'local-chat' }, expectedScope: 'local:local-chat', expectedSession: 'local-chat' }, + ...[false, true].map(enabled => ({ name: `mention-watch-${enabled}`, enabled, + meta: { scope: 'mention-watch:jaw:slack:channel:C1:thread:123', chatSessionId: 'thread-chat' }, + expectedScope: 'mention-watch:jaw:slack:channel:C1:thread:123', expectedSession: 'thread-chat' })), +]; + +for (const placement of placements) { + test(`model-only I/O at minute 19 survives minute 20: ${placement.name}`, async context => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + settings['multiSession'] = { ...originalMulti, enabled: placement.enabled }; + setActiveChatSession(differentActiveChat); + const io = nativeIO(); + const events: string[] = []; + const unsubscribe = subscribe(event => { events.push(event.event); }); + const collecting = orchestrateAndCollectData('task', { ...placement.meta, origin: 'heartbeat', + requestId: 'liveness-request', _skipReplayDrain: true, _spawnAgent: io.spawn }, 'en'); + let settled = false; + void collecting.then(() => { settled = true; }); + try { + await io.started; + assert.deepEqual(io.captured, { scope: placement.expectedScope, chatSessionId: placement.expectedSession }); + assert.equal(io.identity.scope, placement.expectedScope); + const beforeActivity = events.length; + context.mock.timers.tick(19 * MINUTE); + io.pulse('stdout'); + io.pulse('stderr'); + assert.equal(events.length, beforeActivity, 'I/O callback emits no SSE, text, ACK, or queue event'); + context.mock.timers.tick(2 * MINUTE); + await Promise.resolve(); + assert.equal(settled, false, 'native liveness must renew the 20-minute collector deadline'); + io.terminal(); + io.finish(); + const result = await collecting; + assert.equal(result.text, 'final only', 'existing pipeline presentation normalization is unchanged'); + assert.equal(result.data['traceRunId'], 'native-run'); + assert.equal(result.data['sessionId'], placement.expectedSession); + assert.equal(result.data['scope'], placement.expectedScope); + assert.equal(events.filter(event => event === 'orchestrate_done').length, 1); + assert.equal(events.filter(event => event === 'agent_done').length, 1); + const timerSpy = context.mock.method(globalThis, 'setTimeout'); + io.notify(); + assert.equal(timerSpy.mock.callCount(), 0, 'finished collector cannot restart its timer'); + } finally { + io.finish(); + await collecting; + unsubscribe(); + context.mock.timers.reset(); + } + }); +} + +const foreign = [ + { name: 'foreign scope', patch: { scope: 'other-scope' } }, + { name: 'default inbound', patch: { scope: 'default', sessionId: differentActiveChat, origin: 'slack' } }, + { name: 'foreign session', patch: { sessionId: 'other-chat' } }, + { name: 'foreign request', patch: { requestId: 'other-request' } }, + { name: 'foreign origin', patch: { origin: 'slack' } }, + { name: 'foreign run after latch', patch: { runId: 'other-run' } }, + { name: 'missing scope', patch: { scope: undefined } }, + { name: 'missing session', patch: { sessionId: undefined } }, + { name: 'missing request', patch: { requestId: undefined } }, + { name: 'missing origin', patch: { origin: undefined } }, + { name: 'missing run', patch: { runId: undefined } }, + { name: 'empty run', patch: { runId: ' ' } }, + { name: 'invalid request type', patch: { requestId: 17 } }, + { name: 'legacy source', patch: {}, source: 'stdout' }, +]; +for (const scenario of foreign) { + test(`minute-19 ${scenario.name} cannot extend mention-watch collector`, async context => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + settings['multiSession'] = { ...originalMulti, enabled: false }; + setActiveChatSession(differentActiveChat); + const io = nativeIO(); + const collecting = orchestrateAndCollectData('task', { origin: 'heartbeat', requestId: 'liveness-request', + scope: 'mention-watch:remote', chatSessionId: 'thread-chat', _skipReplayDrain: true, + _spawnAgent: io.spawn }, 'en'); + try { + await io.started; + io.pulse(); // Captures native-run; later same-binding foreign runs are stale. + context.mock.timers.tick(19 * MINUTE); + assert.doesNotThrow(() => io.notify(scenario.patch, scenario.source)); + context.mock.timers.tick(MINUTE); + const result = await collecting; + assert.deepEqual(result, { text: t('tg.timeout', {}, 'en'), data: {} }); + const timerSpy = context.mock.method(globalThis, 'setTimeout'); + io.notify(); + assert.equal(timerSpy.mock.callCount(), 0, 'timed-out collector cannot revive'); + } finally { + io.finish(); + await collecting; + context.mock.timers.reset(); + } + }); +} + +test('native agent terminal disposes I/O activity before later pipeline completion', async context => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + const io = nativeIO(); + const collecting = orchestrateAndCollectData('task', { origin: 'heartbeat', scope: 'terminal-scope', + chatSessionId: 'terminal-chat', requestId: 'terminal-request', _skipReplayDrain: true, _spawnAgent: io.spawn }, 'en'); + try { + await io.started; + io.pulse(); + io.terminal(); + context.mock.timers.tick(19 * MINUTE); + io.pulse(); + context.mock.timers.tick(MINUTE); + assert.deepEqual(await collecting, { text: t('tg.timeout', {}, 'en'), data: {} }); + } finally { + io.finish(); + await collecting; + context.mock.timers.reset(); + } +}); + +test('pipeline catches observer throws and forwards only valid native-runtime identities', async () => { + let calls = 0; + const io = nativeIO(); + const running = orchestrate('task', { origin: 'heartbeat', scope: 'observer', chatSessionId: 'observer-chat', + _skipReplayDrain: true, _spawnAgent: io.spawn, + _onRuntimeActivity: () => { calls++; throw new Error('observer failed'); } }); + await io.started; + try { + assert.doesNotThrow(() => io.pulse()); + assert.equal(calls, 1); + io.notify({}, 'acp'); + io.notify({ runId: null }); + io.notify({ sessionId: '' }); + io.withoutIdentity(); + assert.equal(calls, 1); + } finally { io.finish(); await running; } +}); + +test('default origin and absent request are normalized; unrelated first activity cannot latch', async context => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + settings['multiSession'] = { ...originalMulti, enabled: false }; + setActiveChatSession(differentActiveChat); + const io = nativeIO(); + const collecting = orchestrateAndCollectData('task', { _skipReplayDrain: true, _spawnAgent: io.spawn }, 'en'); + try { + await io.started; + assert.deepEqual(io.identity, { runId: 'native-run', scope: 'default', + sessionId: differentActiveChat, origin: 'web' }); + broadcast('agent_done', { error: true, text: 'FOREIGN_LEGACY_DIAGNOSTIC' }); + io.notify({ scope: 'foreign', runId: 'foreign-run' }); + context.mock.timers.tick(19 * MINUTE); + io.pulse(); + context.mock.timers.tick(2 * MINUTE); + let settled = false; + void collecting.then(() => { settled = true; }); + await Promise.resolve(); + assert.equal(settled, false, 'the unrelated first signal must not latch the foreign run'); + context.mock.timers.tick(18 * MINUTE); + assert.deepEqual(await collecting, { text: t('tg.timeout', {}, 'en'), data: {} }, + 'native I/O marks nativeSeen without any native terminal or canonical event'); + } finally { io.finish(); await collecting; context.mock.timers.reset(); } +}); + +test('actual worker replay drain never forwards the original collector activity hook', async () => { + let calls = 0; + let replayed = 0; + claimWorker({ id: 'liveness-worker', name: 'worker' }, 'task', { + origin: 'heartbeat', scopeId: 'worker-scope', chatSessionId: 'worker-chat', + }); + finishWorker('liveness-worker', 'worker result'); + await drainPendingReplays('worker-scope', { + _onRuntimeActivity: () => { calls++; }, + _spawnAgent: (_prompt: string, opts: Options) => { + replayed++; + assert.equal(opts.lifecycle, undefined); + assert.equal(opts.scopeKey, 'worker-scope'); + return { child: null, promise: Promise.resolve({ text: 'replayed', code: 0 }) }; + }, + }); + assert.equal(replayed, 1); + assert.equal(calls, 0); +}); diff --git a/tests/unit/native-runtime-requests.test.ts b/tests/unit/native-runtime-requests.test.ts new file mode 100644 index 000000000..8eeb931de --- /dev/null +++ b/tests/unit/native-runtime-requests.test.ts @@ -0,0 +1,255 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { RuntimeRequests, type RuntimeRequestBinding } from '../../src/agent/runtime/requests.ts'; +import { RUNTIME_BODY_BYTES } from '../../src/trace/runtime-body-codec.ts'; +import { preparePermissionRequest, permissionOptions } from '../../src/agent/runtime/acp/permissions.ts'; + +const binding: RuntimeRequestBinding = { runId: 'run', sessionId: 'chat', scope: 'mention-watch:scope', turnId: 'turn' }; +const cancelled = { outcome: { outcome: 'cancelled' } }; +function view() { + return { title: 'Decision', fields: [{ id: 'field', label: 'Choose', multiSelect: false, allowFreeform: false, + options: [{ id: 'choice', label: 'Yes' }] }] }; +} +function fixture(t: TestContext) { + const registry = new RuntimeRequests(); + t.after(() => { registry.cancelRun('run'); registry.cancelRun('other'); }); + return registry; +} +function input(overrides: Partial[0]> = {}) { + return { ...binding, requestType: 'question' as const, view: view(), cancelled, isCurrent: () => true, + validate: (response: unknown) => response, ...overrides }; +} + +test('exact binding is required and invalid answers remain correctable', async t => { + const registry = fixture(t); + const pending = registry.open(input({ validate: response => { + if (response !== 'yes') throw new Error('invalid_option'); + return { accepted: true }; + } })); + for (const key of ['runId', 'sessionId', 'scope', 'turnId'] as const) { + assert.throws(() => registry.respond(pending.requestId, { ...binding, [key]: 'foreign' }, 'yes'), /request_not_current/); + assert.equal(registry.list('chat').length, 1); + } + assert.throws(() => registry.respond(pending.requestId, binding, 'no'), /invalid_option/); + registry.respond(pending.requestId, binding, 'yes'); + assert.deepEqual(await pending.answer, { accepted: true }); + assert.throws(() => registry.respond(pending.requestId, binding, 'yes'), /request_not_current/); + assert.deepEqual(registry.list('chat'), []); +}); + +test('128 entries fit, overflow rejects, and cancellation releases admission', async t => { + const registry = fixture(t); + const requests = Array.from({ length: 128 }, () => registry.open(input())); + assert.equal(registry.list('chat').length, 128); + assert.throws(() => registry.open(input()), /request_capacity/); + registry.cancelRun('run'); + assert.deepEqual(await Promise.all(requests.map(p => p.answer)), requests.map(() => cancelled)); + const next = registry.open(input()); + next.cancel(); + assert.deepEqual(await next.answer, cancelled); +}); +test('a nested admission cannot push the commit point above128 entries', t => { + const registry = fixture(t); + for (let i = 0; i < 127; i++) registry.open(input()); + let inserted = false; + const nestedView = { ...view(), get title() { + if (!inserted) { inserted = true; registry.open(input({ runId: 'other' })); } + return 'Nested admission'; + } }; + assert.throws(() => registry.open(input({ view: nestedView })), /request_capacity/); + assert.equal(registry.list('chat').length, 128); +}); + +test('expiry settles once at120 seconds and rejects late responses', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 }); + const registry = fixture(t); + const pending = registry.open(input()); + assert.equal(pending.expiresAt, 121_000); + t.mock.timers.tick(119_999); + assert.equal(registry.list('chat').length, 1); + t.mock.timers.tick(1); + assert.deepEqual(await pending.answer, cancelled); + pending.cancel(); registry.cancelRun('run'); + assert.deepEqual(registry.list('chat'), []); + assert.throws(() => registry.respond(pending.requestId, binding, 'yes'), /request_not_current/); +}); + +test('ownership loss or predicate failure prunes and cancels without granting', async t => { + const registry = fixture(t); + let current = true; + const first = registry.open(input({ isCurrent: () => current })); + current = false; + assert.throws(() => registry.respond(first.requestId, binding, 'yes'), /request_not_current/); + assert.deepEqual(await first.answer, cancelled); + let fail = false; + const second = registry.open(input({ isCurrent: () => { if (fail) throw new Error('private'); return true; } })); + fail = true; + assert.deepEqual(registry.list('chat'), []); + assert.deepEqual(await second.answer, cancelled); + assert.throws(() => registry.open(input({ isCurrent: () => false })), /request_not_current/); +}); + +for (const timed of [false, true]) test(`cancelled data is independently frozen for ${timed ? 'timeout' : 'explicit cancel'}`, async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const registry = fixture(t); + const original = { outcome: { outcome: 'cancelled' } }; + const pending = registry.open(input({ cancelled: original })); + original.outcome.outcome = 'selected'; + assert.equal(Object.isFrozen(original), false); + if (timed) t.mock.timers.tick(120_000); else pending.cancel(); + const answer = await pending.answer as typeof original; + assert.deepEqual(answer, cancelled); + assert.notEqual(answer, original); + assert.ok(Object.isFrozen(answer.outcome)); + assert.throws(() => { answer.outcome.outcome = 'selected'; }, TypeError); +}); + +test('timeout captures cancellation once and never re-reads the input object', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const registry = fixture(t); + let reads = 0; + const source = { ...input(), get cancelled() { reads++; return cancelled; } }; + const pending = registry.open(source); + Object.defineProperty(source, 'cancelled', { get: () => { throw new Error('raw input retained'); } }); + t.mock.timers.tick(120_000); + assert.equal(reads, 1); + assert.deepEqual(await pending.answer, cancelled); +}); + +test('unsupported and oversized cancellation values never allocate a timer or entry', t => { + const registry = fixture(t); + const timer = t.mock.method(globalThis, 'setTimeout'); + const cycle: unknown[] = []; cycle.push(cycle); + for (const value of [undefined, 1n, NaN, () => {}, Promise.resolve(null), { then() {} }, + [Promise.resolve(null)], cycle, new Date(), new Map(), [undefined], 'x'.repeat(RUNTIME_BODY_BYTES + 1)]) { + assert.throws(() => registry.open(input({ cancelled: value })), /invalid_cancellation/); + } + assert.equal(timer.mock.callCount(), 0); + assert.deepEqual(registry.list('chat'), []); +}); +test('shared-reference cancellation expansion is rejected before whole-object serialization', t => { + const registry = fixture(t); + let graph: object = { text: 'x'.repeat(32) }; + for (let i = 0; i < 12; i++) graph = { left: graph, right: graph }; + const stringify = t.mock.method(JSON, 'stringify'); + assert.throws(() => registry.open(input({ cancelled: graph })), /invalid_cancellation/); + assert.ok(stringify.mock.calls.every(call => { + const value: unknown = call.arguments[0]; + return !value || typeof value !== 'object' || !Object.hasOwn(value, 'left'); + }), 'must reject the shared graph before expanding its serialized tree'); + assert.deepEqual(registry.list('chat'), []); +}); +test('cancellation byte and depth limits apply before copying, with no accessor execution', async t => { + const registry = fixture(t); + const exact = 'x'.repeat(RUNTIME_BODY_BYTES - 2); + const pending = registry.open(input({ cancelled: exact })); + pending.cancel(); + assert.equal(await pending.answer, exact); + assert.throws(() => registry.open(input({ cancelled: exact + 'x' })), /invalid_cancellation/); + assert.throws(() => registry.open(input({ cancelled: '\u0000'.repeat(6000) })), /invalid_cancellation/); + let deep: unknown = null; + for (let i = 0; i < 33; i++) deep = [deep]; + assert.throws(() => registry.open(input({ cancelled: deep })), /invalid_cancellation/); + let reads = 0; + assert.throws(() => registry.open(input({ cancelled: { get outcome() { reads++; return 'cancelled'; } } })), /invalid_cancellation/); + assert.equal(reads, 0); +}); +test('optional parent identity is included in the actual event byte budget', t => { + const registry = fixture(t); + const nearLimit = { title: 'Decision', fields: Array.from({ length: 4 }, (_, i) => ({ + id: 'field-' + i, label: 'Field', multiSelect: false, allowFreeform: false, + options: Array.from({ length: 17 }, (_, j) => ({ id: 'option-' + j, label: 'x'.repeat(360) })), + })) }; + const pending = registry.open(input({ view: nearLimit })); + pending.cancel(); + assert.throws(() => registry.open(input({ view: nearLimit, parentItemId: '\u0000'.repeat(240) })), /request_view_limit/); +}); + +test('validator cancellation or ownership-side cancellation cannot return accepted', async t => { + const registry = fixture(t); + let pending = registry.open(input({ validate: () => { pending.cancel(); return 'yes'; } })); + assert.throws(() => registry.respond(pending.requestId, binding, 'yes'), /request_not_current/); + assert.deepEqual(await pending.answer, cancelled); + let cancelInPredicate = false; + pending = registry.open(input({ isCurrent: () => { if (cancelInPredicate) pending.cancel(); return true; } })); + cancelInPredicate = true; + assert.throws(() => registry.respond(pending.requestId, binding, 'yes'), /request_not_current/); + assert.deepEqual(await pending.answer, cancelled); +}); + +test('asynchronous validators reject admission without unhandled rejected promises', async t => { + const registry = fixture(t); + for (const validate of [() => Promise.reject(new Error('private-validator-error')), + () => ({ then: (_yes: unknown, no: (error: Error) => void) => no(new Error('thenable-error')) })]) { + const pending = registry.open(input({ validate })); + assert.throws(() => registry.respond(pending.requestId, binding, 'yes'), /invalid_response/); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(registry.list('chat').length, 1); + pending.cancel(); + assert.deepEqual(await pending.answer, cancelled); + } +}); + +test('sanitization, byte budget and owner recheck precede entry/timer creation', t => { + const registry = fixture(t); + const timer = t.mock.method(globalThis, 'setTimeout'); + const huge = { title: 'Decision', fields: Array.from({ length: 8 }, (_, i) => ({ + id: 'f' + i, label: 'Field', multiSelect: false, allowFreeform: false, + options: Array.from({ length: 20 }, (_, j) => ({ id: 'o' + j, label: '한'.repeat(500) })), + })) }; + for (const bad of [null, { ...view(), fields: [view().fields[0], view().fields[0]] }]) { + assert.throws(() => registry.open(input({ view: bad })), /invalid_request_view/); + } + assert.throws(() => registry.open(input({ view: huge })), /request_view_limit/); + assert.throws(() => registry.open(input({ view: { get title() { throw new Error('sanitizer fixture'); } } })), /sanitizer fixture/); + let current = true; + const source = { ...view(), get title() { current = false; return 'changed owner'; } }; + assert.throws(() => registry.open(input({ view: source, isCurrent: () => current })), /request_not_current/); + assert.equal(timer.mock.callCount(), 0); + assert.deepEqual(registry.list('chat'), []); +}); + +test('stored and returned views are immutable snapshots with redaction before clipping', t => { + const registry = fixture(t); + const original = view(); + original.title = '{"password":"CANARY_TITLE"}'; + original.fields[0]!.options[0]!.label = 'x'.repeat(485) + ' Bearer CANARY_LABEL_SECRET_LONG'; + const pending = registry.open(input({ view: original })); + const publicText = JSON.stringify(registry.list('chat')); + assert.ok(!publicText.includes('CANARY')); + assert.ok(pending.view.fields[0]!.options[0]!.label.length <= 500); + original.title = 'forged'; original.fields[0]!.options[0]!.label = 'forged'; + assert.throws(() => { pending.view.title = 'forged'; }, TypeError); + assert.throws(() => { pending.view.fields[0]!.options[0]!.label = 'forged'; }, TypeError); + const listed = registry.list('chat'); + listed[0]!.view.title = 'list mutation'; + assert.deepEqual(registry.list('chat')[0]!.view, pending.view); + assert.deepEqual(registry.list('foreign'), []); + assert.deepEqual(Object.keys(listed[0]!).sort(), ['expiresAt', 'requestId', 'requestType', 'runId', 'scope', 'sessionId', 'turnId', 'view']); +}); + +test('raw native IDs and foreign handles cannot answer an opaque permission request', async t => { + const registry = fixture(t); + const options = permissionOptions([{ optionId: 'CANARY_NATIVE_ID', name: 'Allow', kind: 'allow_once' }]); + const prepared = preparePermissionRequest('[ -f file ]', options); + const other = preparePermissionRequest('[docs](url)', options); + t.after(() => { prepared.dispose(); other.dispose(); }); + const pending = registry.open(input({ view: prepared.view, validate: prepared.validate, requestType: 'approval' })); + assert.ok(!JSON.stringify(registry.list('chat')).includes('CANARY_NATIVE_ID')); + assert.equal(pending.view.title, '[ -f file ]'); + for (const optionId of ['CANARY_NATIVE_ID', other.view.fields[0]!.options[0]!.id, 'altered-handle']) { + assert.throws(() => registry.respond(pending.requestId, binding, { optionId }), /invalid_option/); + } + registry.respond(pending.requestId, binding, { optionId: pending.view.fields[0]!.options[0]!.id }); + assert.deepEqual(await pending.answer, { outcome: { outcome: 'selected', optionId: 'CANARY_NATIVE_ID' } }); +}); + +test('wrong identity and request type cannot bypass the shared event boundary', t => { + const registry = fixture(t); + for (const key of ['runId', 'sessionId', 'scope', 'turnId'] as const) { + assert.throws(() => registry.open(input({ [key]: '' })), /invalid_runtime_event/); + } + assert.throws(() => registry.open(input({ parentItemId: '' })), /invalid_runtime_event/); + assert.throws(() => registry.open(input({ requestType: 'other' as 'approval' })), /invalid_runtime_event/); + assert.deepEqual(registry.list('chat'), []); +}); diff --git a/tests/unit/native-runtime-run.test.ts b/tests/unit/native-runtime-run.test.ts new file mode 100644 index 000000000..b1d672fcc --- /dev/null +++ b/tests/unit/native-runtime-run.test.ts @@ -0,0 +1,396 @@ +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { ChildProcess } from 'node:child_process'; +import { NativeRunFailure, runNativeRuntime, type NativeRunHost, type NativeRunLease } from '../../src/agent/native-runtime-run.ts'; +import type { NativeRuntimeSession } from '../../src/agent/runtime/session.ts'; +import type { RuntimeEvent, RuntimeTurnOutcome } from '../../src/shared/runtime-contract.ts'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +const final: RuntimeTurnOutcome = { status: 'done', finalText: 'FINAL', partialText: 'commentary FINAL' }; +const stopped: RuntimeTurnOutcome = { status: 'stopped', finalText: null, partialText: 'accepted partial' }; +const event: RuntimeEvent = { version: 1, runId: 'run-a', sessionId: 'chat-a', scope: 'scope-a', + turnId: 'turn-a', seq: 1, kind: 'turn-start', provider: 'fixture' }; +type Result = { answer: string | null; origin: 'settle' | 'failed' }; + +function fixture() { + const log: string[] = []; + const settled: Array<{ lease: NativeRunLease | null; outcome: RuntimeTurnOutcome; diagnostic: string | null }> = []; + const failed: Array<{ error: unknown; lease: NativeRunLease | null; outcome: RuntimeTurnOutcome }> = []; + const retireReasons: Error[] = []; + let current = true; + let signal: AbortSignal | undefined; + let reusable = true; + let emit: ((value: RuntimeEvent) => void) | undefined; + const hooks = { + acquire: async (): Promise => lease, + ready: (): void | (() => void) => () => { log.push('cleanup'); }, + send: async (): Promise => final, + claim: (): RuntimeTurnOutcome | null => final, + cancel: async (): Promise => {}, + event: (): void => {}, + settle: async (outcome: RuntimeTurnOutcome): Promise => ({ answer: outcome.finalText, origin: 'settle' }), + failed: async (outcome: RuntimeTurnOutcome): Promise => ({ answer: outcome.finalText, origin: 'failed' }), + retire: async (): Promise => {}, + release: (): void => {}, + finalized: (): void => {}, + }; + const session: NativeRuntimeSession = { + alive: true, nativeSessionId: 'native-a', + capabilities: { transport: 'native', steer: 'cancel-reprompt', resume: true, tools: true, + toolOutput: true, approvals: false, questions: false, images: false, subagents: false }, + send: async (prompt, observer) => { + log.push('send'); assert.equal(prompt.text, 'PROMPT'); emit = observer; + return hooks.send(); + }, + claimTurnOutcome: id => { log.push('claim'); assert.equal(id, 'turn-a'); return hooks.claim(); }, + finalizeTurn: () => { log.push('finalizeTurn'); return true; }, + cancel: async () => { log.push('cancel'); await hooks.cancel(); }, + steer: async () => { throw new Error('unexpected steer'); }, + respond: async () => { throw new Error('unexpected respond'); }, + close: async () => { throw new Error('host must retire through the lease'); }, + }; + const lease: NativeRunLease = { + child: new ChildProcess(), session, + release: () => { log.push('release'); hooks.release(); }, + retire: reason => { + log.push('retire'); reusable = false; retireReasons.push(reason); + return hooks.retire(); + }, + }; + const host: NativeRunHost = { + prompt: { text: 'PROMPT' }, turnId: 'turn-a', + acquire: async value => { log.push('acquire'); signal = value; return hooks.acquire(); }, + isCurrent: () => current, + ready: value => { log.push('ready'); assert.equal(value, lease); return hooks.ready(); }, + event: value => { log.push('event'); assert.equal(value, event); hooks.event(); }, + settle: async (value, outcome, diagnostic) => { + log.push('settle'); settled.push({ lease: value, outcome, diagnostic }); return hooks.settle(outcome); + }, + failed: async (error, value, outcome) => { + log.push('failed'); failed.push({ error, lease: value, outcome }); return hooks.failed(outcome); + }, + finalized: () => { log.push('finalized'); hooks.finalized(); }, + }; + return { host, hooks, lease, log, settled, failed, retireReasons, emit: () => emit!(event), + setCurrent: (value: boolean) => { current = value; }, signal: () => signal, reusable: () => reusable }; +} + +test.beforeEach(() => { mock.method(console, 'warn', () => {}); }); +test.afterEach(() => { mock.restoreAll(); }); + +test('prestart cancel skips acquire; queued start lets caller install hooks first', async () => { + const f = fixture(); + const run = runNativeRuntime(f.host); + assert.deepEqual(f.log, []); + run.cancel(); run.cancel(); + assert.deepEqual(await run.done, { answer: null, origin: 'settle' }); + assert.deepEqual(f.log, ['settle', 'finalized']); + assert.equal(f.settled[0]!.lease, null); + assert.deepEqual(f.settled[0]!.outcome, { status: 'stopped', finalText: null, partialText: '' }); +}); + +test('stale before acquisition never requests a lease', async () => { + const f = fixture(); f.setCurrent(false); + await runNativeRuntime(f.host).done; + assert.deepEqual(f.log, ['settle', 'finalized']); +}); + +for (const reason of ['cancel', 'replacement'] as const) { + test(`late acquisition after ${reason} never attaches or prompts and retires only its lease`, async () => { + const f = fixture(); const acquired = deferred(); const entered = deferred(); + f.hooks.acquire = () => { entered.resolve(); return acquired.promise; }; + const run = runNativeRuntime(f.host); await entered.promise; + if (reason === 'cancel') { run.cancel('stop'); assert.equal(f.signal()!.aborted, true); } + else f.setCurrent(false); + acquired.resolve(f.lease); await run.done; + assert.equal(f.log.includes('ready'), false); assert.equal(f.log.includes('send'), false); + assert.equal(f.log.includes('cancel'), false); + assert.ok(f.log.indexOf('retire') < f.log.indexOf('release')); + assert.equal(f.settled[0]!.lease, f.lease); assert.equal(f.reusable(), false); + }); +} + +for (const method of ['claimTurnOutcome', 'finalizeTurn'] as const) { + test(`missing ${method} fails before ready/send`, async () => { + const f = fixture(); delete f.lease.session[method]; + await runNativeRuntime(f.host).done; + assert.equal(f.log.includes('ready'), false); assert.equal(f.log.includes('send'), false); + assert.equal(f.failed.length, 1); assert.equal(f.reusable(), false); + }); +} + +test('ready reentrant cancel is latched once and checked before prompt', async () => { + const f = fixture(); let run: ReturnType>; + f.hooks.ready = () => { run.cancel(); return () => { f.log.push('cleanup'); run.cancel(); }; }; + f.hooks.cancel = async () => { run.cancel(); }; + run = runNativeRuntime(f.host); await run.done; + assert.equal(f.log.filter(x => x === 'cancel').length, 1); + assert.equal(f.log.includes('send'), false); assert.equal(f.settled.length, 1); + assert.equal(f.log.filter(x => x === 'cleanup').length, 1); +}); + +test('ready replacement is checked before prompt', async () => { + const f = fixture(); f.hooks.ready = () => { f.setCurrent(false); return () => { f.log.push('cleanup'); }; }; + await runNativeRuntime(f.host).done; + assert.equal(f.log.includes('send'), false); assert.equal(f.reusable(), false); +}); + +for (const stage of ['acquire', 'ready', 'send', 'claim'] as const) { + test(`${stage} exception is reported once and every acquired resource is finalized`, async () => { + const f = fixture(); f.hooks[stage] = () => { throw new Error(stage); }; + await runNativeRuntime(f.host).done; + assert.equal(f.failed.length, 1); assert.equal(f.settled.length, 0); + assert.equal(f.log.filter(x => x === 'send').length, stage === 'send' || stage === 'claim' ? 1 : 0); + assert.equal(f.log.at(-1), 'finalized'); + assert.equal(f.log.filter(x => x === 'release').length, stage === 'acquire' ? 0 : 1); + if (stage === 'send') assert.deepEqual(f.failed[0]!.outcome, { status: 'error', finalText: null, partialText: '' }); + if (stage === 'claim') assert.deepEqual(f.failed[0]!.outcome, { status: 'error', finalText: null, partialText: final.partialText }); + }); +} + +test('isCurrent exception still reaches failed and finalized', async () => { + const f = fixture(); f.host.isCurrent = () => { throw new Error('owner read'); }; + await runNativeRuntime(f.host).done; + assert.deepEqual(f.log, ['failed', 'finalized']); +}); + +test('present claim returning null is not a send-result fallback', async () => { + const f = fixture(); f.hooks.claim = () => null; + assert.deepEqual(await runNativeRuntime(f.host).done, { answer: null, origin: 'settle' }); + assert.deepEqual(f.settled[0]!.outcome, { status: 'error', finalText: null, partialText: final.partialText }); + assert.equal(f.settled[0]!.diagnostic, 'Native runtime outcome claim failed'); + assert.equal(f.reusable(), false); +}); + +test('cancel during send waits for captured cancel before claim and preserves accepted partial', async () => { + const f = fixture(); const sent = deferred(); const cancelGate = deferred(); + const entered = deferred(); const cancelling = deferred(); + f.hooks.send = () => { entered.resolve(); return sent.promise; }; + f.hooks.cancel = () => { cancelling.resolve(); return cancelGate.promise; }; + f.hooks.claim = () => stopped; + const run = runNativeRuntime(f.host); await entered.promise; + run.cancel(); await cancelling.promise; sent.resolve(final); + await Promise.resolve(); assert.equal(f.log.includes('claim'), false); + cancelGate.resolve(); await run.done; + assert.deepEqual(f.settled[0]!.outcome, stopped); + assert.ok(f.log.indexOf('cancel') < f.log.indexOf('claim')); +}); + +test('passive claim survives owner removal; postclaim outcome cannot change', async () => { + const f = fixture(); const candidate = { ...final }; let run: ReturnType>; + f.hooks.send = async () => { f.setCurrent(false); return final; }; + f.hooks.claim = () => candidate; + f.hooks.cancel = async () => { candidate.status = 'error'; candidate.finalText = null; }; + f.hooks.settle = async outcome => { run.cancel(); await Promise.resolve(); return { answer: outcome.finalText, origin: 'settle' }; }; + run = runNativeRuntime(f.host); + assert.deepEqual(await run.done, { answer: 'FINAL', origin: 'settle' }); + assert.equal(f.settled[0]!.outcome.status, 'done'); assert.ok(Object.isFrozen(f.settled[0]!.outcome)); +}); + +test('event throws never reject send or lose final; late and stale callbacks are suppressed', async () => { + const f = fixture(); + f.hooks.event = () => { throw new Error('private observer text'); }; + f.hooks.send = async () => { f.emit(); f.emit(); return final; }; + assert.deepEqual(await runNativeRuntime(f.host).done, { answer: 'FINAL', origin: 'settle' }); + assert.equal(f.settled[0]!.diagnostic, 'Native runtime event observer failed'); + assert.equal(f.failed.length, 1); f.emit(); assert.equal(f.log.filter(x => x === 'event').length, 2); + const stale = fixture(); stale.hooks.send = async () => { stale.setCurrent(false); stale.emit(); return final; }; + await runNativeRuntime(stale.host).done; assert.equal(stale.log.includes('event'), false); +}); + +test('settle failure invokes failed once, never retries lifecycle, and retains claimed outcome on rejection', async () => { + const f = fixture(); + f.hooks.settle = async () => { throw new Error('terminal already attempted'); }; + f.hooks.failed = async () => { throw new Error('diagnostic rejected'); }; + await assert.rejects(runNativeRuntime(f.host).done, error => { + assert.ok(error instanceof NativeRunFailure); assert.deepEqual(error.outcome, final); + assert.ok(error.cause instanceof AggregateError); return true; + }); + assert.equal(f.settled.length, 1); assert.equal(f.failed.length, 1); + assert.deepEqual(f.log.slice(-4), ['cleanup', 'retire', 'release', 'finalized']); +}); + +test('failed returns a real R when settle throws', async () => { + const f = fixture(); f.hooks.settle = async () => { throw new Error('settle'); }; + assert.deepEqual(await runNativeRuntime(f.host).done, { answer: 'FINAL', origin: 'failed' }); + assert.equal(f.settled.length, 1); assert.equal(f.failed.length, 1); +}); + +test('cancellation rejection is observed, quarantined and does not replace the claimed result', async () => { + const f = fixture(); let run: ReturnType>; + f.hooks.send = async () => { run.cancel(); return final; }; + f.hooks.cancel = async () => { throw new Error('cancel rejected'); }; + run = runNativeRuntime(f.host); assert.deepEqual(await run.done, { answer: 'FINAL', origin: 'settle' }); + assert.equal(f.reusable(), false); assert.equal(f.failed.length, 1); assert.equal(f.settled.length, 1); +}); + +for (const mode of ['throw', 'reject'] as const) { + test(`cleanup ${mode} is observed before release even when retire also rejects`, async () => { + const f = fixture(); + f.hooks.ready = () => () => { + f.log.push('cleanup'); + if (mode === 'throw') throw new Error('cleanup'); + return Promise.reject(new Error('cleanup')); + }; + f.hooks.retire = async () => { throw new Error('retire rejected'); }; + f.hooks.failed = async () => { throw new Error('failed rejected'); }; + assert.deepEqual(await runNativeRuntime(f.host).done, { answer: 'FINAL', origin: 'settle' }); + assert.deepEqual(f.log.slice(-5), ['cleanup', 'failed', 'retire', 'release', 'finalized']); + assert.equal(f.reusable(), false); assert.equal(f.settled.length, 1); assert.equal(f.failed.length, 1); + }); +} + +for (const stage of ['release', 'finalized'] as const) { + test(`${stage} throw preserves R and attempts exact retirement/final cleanup`, async () => { + const f = fixture(); f.hooks[stage] = () => { throw new Error(stage); }; + assert.deepEqual(await runNativeRuntime(f.host).done, { answer: 'FINAL', origin: 'settle' }); + assert.equal(f.log.filter(x => x === 'release').length, 1); + assert.equal(f.log.filter(x => x === 'finalized').length, 1); + assert.equal(f.retireReasons.length, 1); assert.equal(f.failed.length, 1); + }); +} + +test('every exceptional stage can fail together without skipping the next stage', async () => { + const f = fixture(); let run: ReturnType>; + f.hooks.send = async () => { run.cancel(); return final; }; + f.hooks.cancel = async () => { throw new Error('cancel'); }; + f.hooks.settle = async () => { throw new Error('settle'); }; + f.hooks.failed = () => { throw new Error('failed'); }; + f.hooks.ready = () => () => { f.log.push('cleanup'); throw new Error('cleanup'); }; + f.hooks.retire = () => { throw new Error('retire'); }; + f.hooks.release = () => { throw new Error('release'); }; + f.hooks.finalized = () => { throw new Error('finalized'); }; + run = runNativeRuntime(f.host); + await assert.rejects(run.done, error => { + assert.ok(error instanceof NativeRunFailure); assert.deepEqual(error.outcome, final); + assert.ok(error.cause instanceof AggregateError); assert.equal(error.cause.errors.length, 7); return true; + }); + for (const stage of ['cancel', 'settle', 'failed', 'cleanup', 'retire', 'release', 'finalized']) { + assert.equal(f.log.filter(value => value === stage).length, 1, stage); + } +}); + +test('release waits for both settlement and async owned cleanup; cleanup reentrant cancel is observed', async () => { + const f = fixture(); const settlement = deferred(); const cleanup = deferred(); + const settling = deferred(); const cleaning = deferred(); const cancelGate = deferred(); + const cancelling = deferred(); let run: ReturnType>; + f.hooks.settle = () => { settling.resolve(); return settlement.promise; }; + f.hooks.ready = () => () => { f.log.push('cleanup'); cleaning.resolve(); run.cancel(); return cleanup.promise; }; + f.hooks.cancel = () => { cancelling.resolve(); return cancelGate.promise; }; + run = runNativeRuntime(f.host); await settling.promise; + assert.equal(f.log.includes('release'), false); assert.equal(f.log.includes('cleanup'), false); + const value: Result = { answer: 'policy-selected', origin: 'settle' }; + settlement.resolve(value); await cleaning.promise; await cancelling.promise; + cleanup.resolve(); await Promise.resolve(); assert.equal(f.log.includes('release'), false); + cancelGate.resolve(); assert.equal(await run.done, value); + run.cancel(); assert.equal(f.log.filter(x => x === 'cancel').length, 1); +}); + +test('nonsettling cleanup times out and quarantines; its later rejection stays observed', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(); const cleanup = deferred(); const entered = deferred(); + f.hooks.ready = () => () => { entered.resolve(); return cleanup.promise; }; + const run = runNativeRuntime(f.host); await entered.promise; + t.mock.timers.tick(5_000); + assert.deepEqual(await run.done, { answer: 'FINAL', origin: 'settle' }); + assert.equal(f.reusable(), false); assert.equal(f.log.filter(x => x === 'release').length, 1); + cleanup.reject(new Error('late cleanup rejection')); await Promise.resolve(); +}); + +test('cancelled acquire rejection produces stopped failure with no borrowed partial', async () => { + const f = fixture(); const acquired = deferred(); const entered = deferred(); + f.hooks.acquire = () => { entered.resolve(); return acquired.promise; }; + const run = runNativeRuntime(f.host); await entered.promise; + f.signal()!.addEventListener('abort', () => run.cancel()); + run.cancel(); acquired.reject(new Error('acquire aborted')); + await run.done; + assert.deepEqual(f.failed[0]!.outcome, { status: 'stopped', finalText: null, partialText: '' }); + assert.deepEqual(f.log, ['acquire', 'failed', 'finalized']); +}); + +test('settled native error/stopped outcomes remain semantic results, not thrown admission failures', async () => { + for (const status of ['error', 'stopped'] as const) { + const f = fixture(); const value = { ...stopped, status }; + f.hooks.send = async () => value; f.hooks.claim = () => value; + await runNativeRuntime(f.host).done; + assert.deepEqual(f.settled[0]!.outcome, value); assert.equal(f.failed.length, 0); + assert.equal(f.log.filter(x => x === 'send').length, 1); + } +}); + +test('late stop plus thrown lifecycle preserves even an empty claimed done outcome in typed rejection', async () => { + const f = fixture(); let run: ReturnType>; + const value: RuntimeTurnOutcome = { status: 'done', finalText: null, partialText: '' }; + f.hooks.send = async () => value; f.hooks.claim = () => value; + f.hooks.settle = async () => { run.cancel(); throw new Error('postclaim lifecycle'); }; + f.hooks.failed = async () => { throw new Error('postclaim failed'); }; + run = runNativeRuntime(f.host); + await assert.rejects(run.done, error => { + assert.ok(error instanceof NativeRunFailure); assert.deepEqual(error.outcome, value); return true; + }); +}); + +test('turn identity is captured before queued acquire; release/finalized cannot cancel a successor', async () => { + const f = fixture(); let run: ReturnType>; + f.hooks.release = () => run.cancel(); f.hooks.finalized = () => run.cancel(); + run = runNativeRuntime(f.host); f.host.turnId = 'successor'; + await run.done; assert.equal(f.log.includes('cancel'), false); +}); + +test('a valid undefined R is not replaced by a fabricated fallback', async () => { + const f = fixture(); + const run = runNativeRuntime({ ...f.host, settle: async () => undefined, + failed: () => { throw new Error('must not need fallback'); } }); + assert.equal(await run.done, undefined); +}); + +test('nonsettling cancellation times out before claim and later rejection is observed', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(); const cancellation = deferred(); const entered = deferred(); + let run: ReturnType>; + f.hooks.send = async () => { run.cancel(); return final; }; + f.hooks.cancel = () => { entered.resolve(); return cancellation.promise; }; + run = runNativeRuntime(f.host); await entered.promise; + t.mock.timers.tick(5_000); await run.done; + assert.equal(f.reusable(), false); assert.equal(f.settled.length, 1); + cancellation.reject(new Error('late cancel rejection')); await Promise.resolve(); +}); + +test('nonsettling retirement cannot skip release and finalized or lose settled R', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(); const retirement = deferred(); const entered = deferred(); + f.hooks.ready = () => () => { throw new Error('cleanup'); }; + f.hooks.retire = () => { entered.resolve(); return retirement.promise; }; + const run = runNativeRuntime(f.host); await entered.promise; + assert.equal(f.reusable(), false); assert.equal(f.log.includes('release'), false); + t.mock.timers.tick(6_000); + assert.deepEqual(await run.done, { answer: 'FINAL', origin: 'settle' }); + assert.equal(f.log.filter(x => x === 'finalized').length, 1); + retirement.reject(new Error('late reap rejection')); await Promise.resolve(); +}); + +test('nonsettling failed port cannot prevent teardown or cause an untyped done rejection', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(); const failure = deferred(); const entered = deferred(); + f.hooks.settle = async () => { throw new Error('settle'); }; + f.hooks.failed = () => { entered.resolve(); return failure.promise; }; + const run = runNativeRuntime(f.host); const rejection = assert.rejects(run.done, NativeRunFailure); + await entered.promise; t.mock.timers.tick(5_000); await rejection; + assert.deepEqual(f.log.slice(-4), ['cleanup', 'retire', 'release', 'finalized']); + failure.reject(new Error('late failed rejection')); await Promise.resolve(); +}); + +for (const value of [null, '', ' \n\t ', 'FINAL']) { + test(`normal completion preserves exact final ${JSON.stringify(value)} and releases after lifecycle`, async () => { + const f = fixture(); f.hooks.claim = () => ({ ...final, finalText: value }); + assert.deepEqual(await runNativeRuntime(f.host).done, { answer: value, origin: 'settle' }); + assert.deepEqual(f.log, ['acquire', 'ready', 'send', 'claim', 'settle', 'cleanup', 'release', 'finalized']); + assert.equal(f.reusable(), true); assert.equal(f.failed.length, 0); + }); +} diff --git a/tests/unit/native-steer-handler.test.ts b/tests/unit/native-steer-handler.test.ts new file mode 100644 index 000000000..9c9480786 --- /dev/null +++ b/tests/unit/native-steer-handler.test.ts @@ -0,0 +1,100 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { withSessionScope } from '../../src/core/session-context.ts'; +import type { CliCommandContext } from '../../src/cli/command-context.ts'; +import { cancelSteerInputs } from '../../src/agent/steer-input-guard.ts'; + +const calls: Array<{ name: string; args: unknown[] }> = []; +let busy = true, capable = true; +let outcome: 'steered' | 'fallback-queue' | 'new-run' | 'cancelled' | Error = 'steered'; +let stopBeforeReturn = false; +const record = (name: string, args: unknown[]) => { calls.push({ name, args }); }; +test.mock.module('../../src/agent/spawn.js', { namedExports: { + isAgentBusy: (scope: string) => { record('busy', [scope]); return busy; }, + canSteerAgent: (scope: string) => { record('capable', [scope]); return capable; }, + steerAgent: async (...args: unknown[]) => { + record('steer', args); if (outcome instanceof Error) throw outcome; + if (stopBeforeReturn) cancelSteerInputs(String(args[0])); + return outcome; + }, + killActiveAgent: (...args: unknown[]) => { record('kill', args); return true; }, + waitForProcessEnd: async (...args: unknown[]) => { record('wait-process', args); }, + waitForExitSettled: async (...args: unknown[]) => { record('wait-exit', args); }, + getSteerWaitMsForActiveAgent: () => 5000, +} }); +test.mock.module('../../src/orchestrator/gateway.js', { namedExports: { + submitMessage: (...args: unknown[]) => { record('submit', args); return { action: 'started' }; }, +} }); +const { steerHandler } = await import('../../src/cli/handlers-runtime.ts'); +const binding = { scope: 'slash-native-scope', chatSessionId: 'slash-native-chat' }; +const ctx: CliCommandContext = { interface: 'web', locale: 'en' }; +const invoke = (args = ['use', 'new', 'instruction'], context = ctx) => + withSessionScope(binding, () => steerHandler(args, context)); +test.beforeEach(() => { calls.length = 0; busy = true; capable = true; outcome = 'steered'; stopBeforeReturn = false; }); + +for (const value of ['steered', 'new-run'] as const) test(`actual slash handler never requeues a ${value} result`, async () => { + outcome = value; + const result = await invoke(); + assert.equal(result.ok, true); + assert.deepEqual(calls.map(call => call.name), ['busy', 'capable', 'steer']); + assert.deepEqual(calls[2]!.args, [binding.scope, 'use new instruction', 'web', binding]); +}); + +test('actual slash handler queues a no-start exactly once with captured placement', async () => { + outcome = 'fallback-queue'; + const result = await invoke(); + assert.equal(result.ok, true); + assert.deepEqual(calls.map(call => call.name), ['busy', 'capable', 'steer', 'submit']); + assert.deepEqual(calls[3]!.args, ['use new instruction', { origin: 'web', ...binding, midRunPolicy: 'followup' }]); +}); + +test('actual slash handler propagates fatal replacement without queue, kill or resend', async () => { + const error = new Error('native_replacement_failed'); outcome = error; + await assert.rejects(invoke, actual => actual === error); + assert.deepEqual(calls.map(call => call.name), ['busy', 'capable', 'steer']); +}); + +test('actual slash handler acknowledges a cancelled redirect without follow-up submission', async () => { + outcome = 'cancelled'; + const result = await invoke(); + assert.equal(result.ok, true); assert.equal(result.type, 'success'); + assert.match(result.text!, /cancelled/i); + assert.deepEqual(calls.map(call => call.name), ['busy', 'capable', 'steer']); +}); + +test('slash consumer fences a stale fallback even when the producer returns busy after Stop', async () => { + outcome = 'fallback-queue'; stopBeforeReturn = true; + const result = await invoke(); + assert.equal(result.ok, true); assert.match(result.text!, /cancelled/i); + assert.deepEqual(calls.map(call => call.name), ['busy', 'capable', 'steer']); +}); + +test('Stop cannot retract a producer-reported successful dispatch at the slash consumer', async () => { + stopBeforeReturn = true; + for (const value of ['steered', 'new-run'] as const) { + calls.length = 0; outcome = value; + assert.equal((await invoke()).type, 'steer'); + assert.deepEqual(calls.map(call => call.name), ['busy', 'capable', 'steer']); + } +}); + +test('empty or idle slash steer does not dispatch or queue', async () => { + assert.equal((await invoke([])).ok, false); assert.deepEqual(calls, []); + busy = false; assert.equal((await invoke()).ok, false); + assert.deepEqual(calls.map(call => call.name), ['busy']); +}); + +test('no-capability web handler keeps kill, process wait, exit barrier and one follow-up order', async () => { + capable = false; + assert.equal((await invoke()).ok, true); + assert.deepEqual(calls.map(call => call.name), ['busy', 'capable', 'kill', 'wait-process', 'wait-exit', 'submit']); + assert.deepEqual(calls.at(-1)!.args, ['use new instruction', { origin: 'web', ...binding }]); +}); + +test('no-capability remote handler retains existing handoff without a second submission', async () => { + capable = false; + const result = await invoke(undefined, { interface: 'slack', locale: 'en', clearSession: async () => { record('clear', []); } }); + assert.equal(result.ok, true); assert.equal(result.steerPrompt, 'use new instruction'); + assert.deepEqual(calls.map(call => call.name), ['busy', 'capable', 'kill', 'wait-process', 'wait-exit', 'clear']); +}); diff --git a/tests/unit/pi-runtime-projection.test.ts b/tests/unit/pi-runtime-projection.test.ts index 3d4fdd192..6a1a0b4b6 100644 --- a/tests/unit/pi-runtime-projection.test.ts +++ b/tests/unit/pi-runtime-projection.test.ts @@ -1,8 +1,14 @@ -import test from 'node:test'; +import test, { mock } from 'node:test'; import assert from 'node:assert/strict'; import { parsePiRpcRecord, type PiRuntimeEvent } from '../../src/agent/pi-runtime.ts'; -import { PiProjection, parsePiActivityRecord } from '../../src/agent/runtime/pi-projection.ts'; -import { RuntimeProjection } from '../../src/agent/runtime/projection.ts'; +// Recorders are injected here; module loading must not initialize shared SQLite. +let unexpectedDefaultWrites = 0; +mock.module('../../src/trace/store.js', { namedExports: { appendTraceEvent: () => { + unexpectedDefaultWrites++; throw new Error('Pure projection test reached the default trace writer'); +} } }); +test.after(() => assert.equal(unexpectedDefaultWrites, 0)); +const { PiProjection, parsePiActivityRecord } = await import('../../src/agent/runtime/pi-projection.ts'); +const { RuntimeProjection } = await import('../../src/agent/runtime/projection.ts'); import type { RuntimeEvent } from '../../src/shared/runtime-contract.ts'; import { encodeRuntimeBody, decodeRuntimeBody } from '../../src/trace/runtime-body-codec.ts'; import { stringifyTraceValue } from '../../src/trace/redact.ts'; diff --git a/tests/unit/pi-spawn-pool-contract.test.ts b/tests/unit/pi-spawn-pool-contract.test.ts index a52be7063..4cfdf7f78 100644 --- a/tests/unit/pi-spawn-pool-contract.test.ts +++ b/tests/unit/pi-spawn-pool-contract.test.ts @@ -1,6 +1,8 @@ +import '../setup/isolated-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; +import { activeMainProcesses, killActiveAgent, settleExit } from '../../src/agent/spawn.ts'; test('Pi spawn keeps employees per-turn and sends boss turns through the pool lease', async () => { const source = await readFile(new URL('../../src/agent/spawn.ts', import.meta.url), 'utf8'); @@ -16,6 +18,26 @@ test('Pi spawn keeps employees per-turn and sends boss turns through the pool le // a pi turn registers a cancel hook, and the shared kill path routes pi // through it as a lease cancel. assert.match(branch, /mainRun\.cancelTurn\s*=\s*cancelHook/); - assert.match(source, /getActiveMainCli\(scopeKey\)\s*===\s*'pi'/); + // Exercise the shared dispatcher: spelling the Pi allowlist as equality or + // includes() is immaterial; the captured Pi lease must receive the stop. + const scope = 'pi-pool-contract'; + const foreignScope = 'pi-pool-contract-other'; + const reasons: string[] = []; + const foreign = { process: null, starting: false, steering: false, ownerGeneration: 0, + meta: { origin: 'web', cli: 'pi' }, cancelTurn: () => assert.fail('cancelled another Pi lease') }; + activeMainProcesses.set(foreignScope, foreign); + activeMainProcesses.set(scope, { ...foreign, cancelTurn: reason => { reasons.push(reason); } }); + try { + assert.equal(killActiveAgent(scope, 'steer'), true); + assert.deepEqual(reasons, ['steer']); + assert.equal(activeMainProcesses.has(scope), false); + assert.equal(activeMainProcesses.get(foreignScope), foreign); + assert.equal(killActiveAgent(scope, 'steer'), false, 'removed owner cannot be cancelled twice'); + assert.deepEqual(reasons, ['steer']); + } finally { + settleExit(scope); + activeMainProcesses.delete(scope); + activeMainProcesses.delete(foreignScope); + } assert.match(source, /action=lease\.cancel/); }); diff --git a/tests/unit/pi-spawn-runtime-events.test.ts b/tests/unit/pi-spawn-runtime-events.test.ts index c624f75cc..77e0db4e3 100644 --- a/tests/unit/pi-spawn-runtime-events.test.ts +++ b/tests/unit/pi-spawn-runtime-events.test.ts @@ -97,6 +97,8 @@ test.mock.module('../../src/agent/watchdog.js', { namedExports: { ...watchdog, attachWatchdog: () => ({ markProgress() {}, extendDeadline() {}, stop() { fixture.watchdogStops++; } }), } }); const traces = await import('../../src/trace/store.ts'); +const { db } = await import('../../src/core/db.ts'); +const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); const live = await import('../../src/agent/live-run-state.ts'); const lifecycle = await import('../../src/agent/lifecycle-handler.ts'); test.mock.module('../../src/agent/lifecycle-handler.js', { namedExports: { @@ -120,6 +122,7 @@ const publicEvents: string[] = []; let unsubscribe = () => {}; test.beforeEach(() => { + db.prepare("INSERT OR IGNORE INTO chat_sessions(id,seq,label) VALUES('jaw-chat-id',9001,'Pi owned fixture')").run(); fixture.mode = 'ok'; fixture.calls.length = 0; fixture.acquisitions.length = 0; fixture.contexts.length = 0; fixture.events.length = 0; fixture.lifecycle.length = 0; fixture.legacy.length = 0; fixture.direct = 0; fixture.releases = 0; fixture.watchdogStops = 0; publicEvents.length = 0; @@ -144,6 +147,13 @@ function opts(employee = false) { } function assertCanonicalContext(employee: boolean) { assert.ok(fixture.events.length > 0, 'real spawn must feed the shared runtime emitter'); + const runId = fixture.events[0]!.runId; + const owner = traces.getTraceRun(runId); + assert.equal(owner?.session_id, 'jaw-chat-id'); + assert.equal(owner?.scope_key, 'pi-test-scope'); + const replay = readActivityPage({ runId, sessionId: 'jaw-chat-id', after: 0, limit: 40 }); + if (employee) assert.equal(replay, null, 'internal trace stays private despite captured owner'); + else assert.deepEqual(replay?.events, fixture.events, 'actual spawn, emitter, stored codec and replay agree'); for (const context of fixture.contexts) { assert.equal(context.sessionId, 'jaw-chat-id'); assert.equal(context.scope, 'pi-test-scope'); assert.equal(context.parentItemId, 'jaw-parent-item'); diff --git a/tests/unit/presentation-settings.test.ts b/tests/unit/presentation-settings.test.ts new file mode 100644 index 000000000..75586ecb7 --- /dev/null +++ b/tests/unit/presentation-settings.test.ts @@ -0,0 +1,294 @@ +import test, { after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { once } from 'node:events'; +import express from 'express'; +import type { MainSessionRecord } from '../../src/core/main-session.ts'; + +// Set every filesystem boundary before importing the actual settings owners. +const home = mkdtempSync(join(tmpdir(), 'cli-jaw-presentation-')); +process.env['CLI_JAW_HOME'] = home; +process.env['CLI_JAW_JWC_AGENT_DIR'] = join(home, 'jwc-agent'); +process.env['TMPDIR'] = join(home, 'tmp'); +mkdirSync(process.env['TMPDIR'], { recursive: true }); +mkdirSync(process.env['CLI_JAW_JWC_AGENT_DIR'], { recursive: true }); +const jwcPath = join(process.env['CLI_JAW_JWC_AGENT_DIR'], 'config.yml'); +const jwcSentinel = 'modelRoles:\n default: sentinel/do-not-rewrite\ncustom: keep\n'; + +const config = await import('../../src/core/config.ts'); +const database = await import('../../src/core/db.ts'); +const runtime = await import('../../src/core/runtime-settings.ts'); +const messaging = await import('../../src/messaging/runtime.ts'); +const bus = await import('../../src/core/bus.ts'); +const watcher = await import('../../src/core/settings-watch.ts'); +const { sanitizeSettingsInput } = await import('../../src/core/settings-merge.ts'); +const { registerSettingsRoutes } = await import('../../src/routes/settings.ts'); + +const nativeBucket = 'native-v1:claude:presentation-fixture'; +let resets = 0; +let dispatches = 0; +let starts = 0; +let stops = 0; +const events: Array<{ type: string; data: Record }> = []; +const listener = (type: string, data: Record) => events.push({ type, data }); +bus.addBroadcastListener(listener); + +function writeDocument(value: unknown): void { + writeFileSync(config.SETTINGS_PATH, JSON.stringify(value, null, 2)); +} + +function disk(): string { return readFileSync(config.SETTINGS_PATH, 'utf8'); } +function session(): MainSessionRecord { return database.getSession() as MainSessionRecord; } + +async function apply(patch: Record) { + return runtime.applyRuntimeSettingsPatch(patch, { + resetFallbackState: () => { resets += 1; }, + restartMessaging: async (prev, next, actualPatch) => { + dispatches += 1; + return messaging.restartMessagingRuntime(prev, next, actualPatch); + }, + }); +} + +beforeEach(async () => { + const initial = structuredClone(config.DEFAULT_SETTINGS); + writeDocument({ + ...initial, + cli: 'jwc', + workingDir: home, + presentation: { mode: 'legacy', sibling: 'preserve' }, + perCli: { ...initial.perCli, jwc: { provider: 'anthropic', model: 'settings-model' } }, + messaging: { ...initial.messaging, enabledChannels: ['telegram'], homeChannel: 'telegram' }, + }); + config.loadSettings(); + database.updateSession.run('jwc', 'native-session-sentinel', 'singleton-model', 'auto', home, 'high'); + database.upsertSessionBucket.run(nativeBucket, 'native-resume-sentinel', 'native-model', 'resume-key', 17); + writeFileSync(jwcPath, jwcSentinel); + messaging.__resetTransportRegistryForTests(); + // Only the external transport init/shutdown boundaries are fake. The restart + // dispatcher, registry, running state, persistence, session and bus are real. + for (const channel of ['telegram', 'discord', 'slack'] as const) { + messaging.registerTransport(channel, { + init: async () => { starts += 1; return messaging.transportStarted; }, + shutdown: async () => { stops += 1; }, + }); + } + await messaging.startMessagingTransport('telegram'); + resets = dispatches = starts = stops = 0; + events.length = 0; +}); + +after(() => { + bus.removeBroadcastListener(listener); + messaging.__resetTransportRegistryForTests(); + database.db.close(); + rmSync(home, { recursive: true, force: true }); +}); + +test('presentation-only does not reset fallback state', async () => { + await apply({ presentation: { mode: 'activity' } }); + assert.equal(resets, 0); +}); + +test('presentation-only preserves the actual singleton session row', async () => { + const before = database.getSession(); + await apply({ presentation: { mode: 'activity' } }); + assert.deepEqual(database.getSession(), before); +}); + +test('presentation-only does not write the isolated JWC config', async () => { + await apply({ presentation: { mode: 'activity' } }); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); +}); + +test('presentation-only persists, broadcasts and dispatches without changing transport or native identity', async () => { + const perCli = structuredClone(config.settings['perCli']); + const bucket = database.getSessionBucket.get(nativeBucket); + await apply({ presentation: { mode: 'activity' } }); + assert.deepEqual(config.settings['presentation'], { mode: 'activity', sibling: 'preserve' }); + assert.deepEqual(JSON.parse(disk()).presentation, { mode: 'activity', sibling: 'preserve' }); + assert.equal(config.settings['cli'], 'jwc'); + assert.deepEqual(config.settings['perCli'], perCli); + assert.deepEqual(database.getSessionBucket.get(nativeBucket), bucket); + assert.equal(dispatches, 1); + assert.equal(starts, 0); + assert.equal(stops, 0); + assert.deepEqual(messaging.getRunningMessagingTransports(), ['telegram']); + assert.deepEqual(events.filter(e => e.type === 'settings_change').map(e => e.data['changedKeys']), [['presentation']]); + config.loadSettings(); + assert.deepEqual(config.settings['presentation'], { mode: 'activity', sibling: 'preserve' }); + assert.deepEqual(config.settings['perCli'], perCli); +}); + +test('empty and partial presentation blocks retain explicit legacy and siblings', async () => { + await apply({ presentation: {} }); + await apply({ presentation: { sibling: 'updated' } }); + assert.deepEqual(config.settings['presentation'], { mode: 'legacy', sibling: 'updated' }); + assert.equal(resets, 0); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + config.loadSettings(); + assert.deepEqual(config.settings['presentation'], { mode: 'legacy', sibling: 'updated' }); +}); + +for (const [name, patch] of [ + ['mixed', { presentation: { mode: 'activity' }, locale: 'en' }], + ['empty', {}], +] as const) { + test(`${name} patch preserves existing fallback, session and JWC side effects`, async () => { + await apply(patch); + assert.equal(resets, 1); + assert.equal(session().model, 'settings-model'); + assert.equal(session().session_id, 'native-session-sentinel'); + assert.equal(readFileSync(jwcPath, 'utf8'), 'modelRoles:\n default: anthropic/settings-model\ncustom: keep\n'); + assert.equal(dispatches, 1); + assert.equal(starts, name === 'mixed' ? 1 : 0); + assert.equal(stops, name === 'mixed' ? 1 : 0); + }); +} + +test('inherited presentation is an empty patch, while inherited unrelated keys do not defeat an own presentation-only patch', async () => { + await apply(Object.create({ presentation: { mode: 'activity' } })); + assert.equal(resets, 1); + assert.equal(session().model, 'settings-model'); + assert.equal(config.settings['presentation'].mode, 'legacy'); + await apply(Object.assign(Object.create({ locale: 'en' }), { presentation: { mode: 'activity' } })); + assert.equal(resets, 1); + assert.equal(starts, 0); + assert.equal(stops, 0); +}); + +test('presentation-only write failure leaves disk, memory, session and broadcasts untouched', async () => { + const before = config.snapshotSettingsState(); + const raw = disk(); + await assert.rejects(runtime.applyRuntimeSettingsPatch({ presentation: { mode: 'activity' } }, { + writeSettings: () => { throw new Error('presentation write failed'); }, + resetFallbackState: () => { resets += 1; }, + }), /presentation write failed/); + assert.deepEqual(config.snapshotSettingsState(), before); + assert.equal(disk(), raw); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + assert.equal(resets, 0); + assert.equal(events.length, 0); +}); + +test('presentation-only dispatcher failure retains the existing persistence rollback', async () => { + config.saveSettings(config.settings); + const before = config.snapshotSettingsState(); + const raw = disk(); + await assert.rejects(runtime.applyRuntimeSettingsPatch({ presentation: { mode: 'activity' } }, { + restartMessaging: async () => { throw new Error('presentation dispatcher failed'); }, + resetFallbackState: () => { resets += 1; }, + }), /presentation dispatcher failed/); + assert.deepEqual(config.snapshotSettingsState(), before); + assert.equal(disk(), raw); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + assert.equal(resets, 0); + assert.equal(events.length, 0); +}); + +test('fresh defaults and old absent documents resolve activity; explicit legacy survives save/reload', () => { + assert.deepEqual(config.DEFAULT_SETTINGS.presentation, { mode: 'activity' }); + assert.deepEqual(config.settingsForHomeWithoutSettingsFile().presentation, { mode: 'activity' }); + for (const presentation of [undefined, { mode: 'legacy', sibling: 'kept' }]) { + writeDocument({ cli: 'jwc', workingDir: home, ...(presentation ? { presentation } : {}) }); + config.loadSettings(); + const expected = presentation ?? { mode: 'activity' }; + assert.deepEqual(config.settings['presentation'], expected); + config.saveSettings(config.settings); + assert.deepEqual(JSON.parse(disk()).presentation, expected); + config.loadSettings(); + assert.deepEqual(config.settings['presentation'], expected); + } +}); + +test('boot repairs invalid presentation values and direct migration never spreads malformed blocks', () => { + for (const presentation of [null, [], 'legacy', 42, { mode: 'native', sibling: 'kept' }]) { + writeDocument({ cli: 'jwc', workingDir: home, presentation }); + config.loadSettings(); + const expected = presentation && typeof presentation === 'object' && !Array.isArray(presentation) + ? { mode: 'activity', sibling: 'kept' } : { mode: 'activity' }; + assert.deepEqual(config.settings['presentation'], expected); + assert.deepEqual(config.migrateSettings({ ...config.settings, presentation })['presentation'], expected); + config.saveSettings(config.settings); + config.loadSettings(); + assert.deepEqual(config.settings['presentation'], expected); + } +}); + +test('invalid API presentation is rejected with no memory, disk or runtime side effects', async () => { + const app = express(); + app.use(express.json()); + registerSettingsRoutes(app, (_req, _res, next) => next(), apply, process.cwd()); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + try { + for (const presentation of [null, [], 'legacy', 1, { mode: 'native' }, { mode: null }, { mode: 1 }]) { + const patch = { presentation }; + const before = config.snapshotSettingsState(); + const raw = disk(); + const expectedPath = presentation && typeof presentation === 'object' && !Array.isArray(presentation) + ? 'presentation.mode' : 'presentation'; + assert.deepEqual(sanitizeSettingsInput(patch, 'api').invalidPaths, [expectedPath]); + await assert.rejects(apply(patch), /invalid_settings_field/); + const response = await fetch(`http://127.0.0.1:${address.port}/api/settings`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), signal: AbortSignal.timeout(5000), + }); + assert.equal(response.status, 400); + assert.equal((await response.json()).error, 'invalid_settings_field'); + assert.equal(disk(), raw); + assert.deepEqual(config.snapshotSettingsState(), before); + } + assert.equal(resets, 0); + assert.equal(dispatches, 0); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + assert.equal(events.length, 0); + const response = await fetch(`http://127.0.0.1:${address.port}/api/settings`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ presentation: { mode: 'activity' } }), signal: AbortSignal.timeout(5000), + }); + assert.equal(response.status, 200); + assert.deepEqual((await response.json()).data.presentation, { mode: 'activity', sibling: 'preserve' }); + assert.deepEqual(JSON.parse(disk()).presentation, { mode: 'activity', sibling: 'preserve' }); + assert.equal(resets, 0); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + assert.equal(dispatches, 1); + assert.equal(starts, 0); + assert.equal(stops, 0); + } finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } +}); + +test('real disk watcher ingress sanitizes invalid fields, retains legacy and merges siblings', () => { + const warnings: string[] = []; + const previousWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.join(' ')); }; + try { + for (const presentation of [null, [], 'bad', { mode: 'invalid', sibling: 'updated' }]) { + writeDocument({ presentation }); + const raw = disk(); + assert.equal(watcher.reloadSettingsFromDisk({ lastSavedRaw: null }), true); + assert.equal(config.settings['presentation'].mode, 'legacy'); + assert.equal(disk(), raw, 'watch reload does not rewrite the external file'); + } + assert.deepEqual(config.settings['presentation'], { mode: 'legacy', sibling: 'updated' }); + assert.ok(warnings.some(line => line.includes('presentation.mode'))); + assert.ok(warnings.some(line => line.endsWith('presentation'))); + writeDocument({ presentation: { mode: 'activity' } }); + assert.equal(watcher.reloadSettingsFromDisk({ lastSavedRaw: null }), true); + assert.deepEqual(config.settings['presentation'], { mode: 'activity', sibling: 'updated' }); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + } finally { + console.warn = previousWarn; + } +}); diff --git a/tests/unit/presentation.test.ts b/tests/unit/presentation.test.ts new file mode 100644 index 000000000..f62afd8ba --- /dev/null +++ b/tests/unit/presentation.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isPresentationMode, presentationMode, parseActivityIdentity } from '../../src/shared/presentation.js'; +import { parseRuntimeEvent, parseRuntimeRequestView } from '../../src/shared/runtime-event-parse.js'; + +test('display defaults are independent of provider transport and preserve explicit legacy', () => { + for (const input of [undefined, null, [], 42, {}, { presentation: null }, { presentation: [] }, + { presentation: { mode: 'native' } }, { perCli: { claude: { transport: 'native' } } }]) { + assert.equal(presentationMode(input), 'activity'); + } + assert.equal(presentationMode({ presentation: { mode: 'legacy' } }), 'legacy'); + for (const mode of ['activity', 'legacy']) assert.equal(isPresentationMode(mode), true); + for (const mode of ['', 'native', 'print', null, true]) assert.equal(isPresentationMode(mode), false); +}); + +test('server identity is bounded, detached and contains only the public pair', () => { + const input = { sessionId: 'jaw-chat', scope: 'mention-watch:separate', providerSession: 'private' }; + const parsed = parseActivityIdentity(input); + assert.deepEqual(parsed, { sessionId: 'jaw-chat', scope: 'mention-watch:separate' }); + input.scope = 'changed'; + assert.equal(parsed?.scope, 'mention-watch:separate'); + for (const invalid of [null, [], {}, { sessionId: '', scope: 'a' }, { sessionId: 'a', scope: 1 }, + { sessionId: 'a'.repeat(241), scope: 'b' }, { sessionId: 'a', scope: 'b'.repeat(241) }]) { + assert.equal(parseActivityIdentity(invalid), null); + } + assert.ok(parseActivityIdentity({ sessionId: 'a'.repeat(240), scope: 'b'.repeat(240) })); +}); + +test('public request view export uses the existing event parser policy', () => { + const field = { id: 'decision', label: 'Permission', options: [{ id: 'allow', label: 'Allow' }], + multiSelect: false, allowFreeform: false }; + const view = { title: 'Continue?', fields: [field], callback: 'private' }; + const safe = parseRuntimeRequestView(view); + assert.deepEqual(safe, { title: 'Continue?', fields: [field] }); + assert.notEqual(safe?.fields[0], field); + const event = parseRuntimeEvent({ version: 1, runId: 'run', sessionId: 'chat', scope: 'scope', + turnId: 'turn', seq: 1, kind: 'request', requestType: 'approval', requestId: 'request', view }); + assert.ok(event?.kind === 'request'); + assert.deepEqual(event.view, safe); + assert.equal(parseRuntimeRequestView({ ...view, fields: [field, field] }), null); + assert.equal(parseRuntimeRequestView({ ...view, fields: [{ ...field, options: [field.options[0], field.options[0]] }] }), null); + assert.equal(parseRuntimeRequestView({ ...view, title: 'x'.repeat(501) }), null); +}); diff --git a/tests/unit/print-activity-lifecycle.test.ts b/tests/unit/print-activity-lifecycle.test.ts new file mode 100644 index 000000000..c081a262a --- /dev/null +++ b/tests/unit/print-activity-lifecycle.test.ts @@ -0,0 +1,149 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { db } from '../../src/core/db.js'; +import { settings } from '../../src/core/config.js'; +import { startTraceRun, getTraceRun } from '../../src/trace/store.js'; +import { readActivityPage } from '../../src/trace/activity-journal.js'; +import { createPrintActivity } from '../../src/agent/runtime/print-activity.js'; +import { extractFromEvent } from '../../src/agent/events/index.js'; +import { handleAgentExit, clearGoalTimers, setSpawnAgent, type ExitHandlerParams } from '../../src/agent/lifecycle-handler.js'; +import { addBroadcastListener, removeBroadcastListener } from '../../src/core/bus.js'; +import { subscribe, type BusEvent } from '../../src/core/event-bus.js'; +import { createSlackForwarder } from '../../src/slack/forwarder.js'; +import { resetGoalStore } from '../../src/goal/store.js'; +import { beginLiveRun, appendLiveRunTool, clearLiveRun } from '../../src/agent/live-run-state.js'; +import type { SpawnContext } from '../../src/types/agent.js'; + +let serial = 0; +test.beforeEach(() => { + resetGoalStore(); clearGoalTimers(); + settings.memory.enabled = false; settings.fallbackOrder = []; +}); +test.afterEach(() => { clearGoalTimers(); resetGoalStore(); }); + +function fixture() { + const n = ++serial; + const sessionId = `print-chat-${n}`, scope = `print-scope-${n}`; + db.prepare('INSERT INTO chat_sessions(id,seq,label) VALUES(?,?,?)').run(sessionId, 8000 + n, 'print fixture'); + const runId = startTraceRun({ cli: 'codex', sessionId, scopeKey: scope }); + const ctx: SpawnContext = { fullText: '', toolLog: [], traceLog: [], stderrBuf: '', seenToolKeys: new Set(), + hasClaudeStreamEvents: false, sessionId: 'provider-private', cost: null, turns: null, duration: null, tokens: null, + traceRunId: runId, traceAudience: 'public', liveScope: scope, activityIdentity: { sessionId, scope } }; + ctx.printActivity = createPrintActivity({ runId, sessionId, scope, turnId: runId, audience: 'public' }, 'codex'); + let result: Parameters[0] | undefined; + let resolves = 0, ends = 0, respawns = 0; + setSpawnAgent(() => { respawns++; return { promise: Promise.resolve({ text: 'unexpected retry', code: 0 }) }; }); + const params: ExitHandlerParams = { + ctx, code: 0, cli: 'codex', model: 'fixture', resumeKey: null, agentLabel: 'fixture', mainManaged: true, + origin: 'web', prompt: 'fixture', opts: { _skipSessionPersist: true, _isSmokeContinuation: true }, cfg: {}, + ownerGeneration: 1, persistenceOwner: { global: 0, scope: 0 }, forceNew: false, empSid: null, + isResume: false, wasKilled: false, wasSteer: false, + smokeResult: { isSmoke: false, confidence: 'low', matchedPattern: null, reason: '' }, + effortDefault: '', costLine: '', resolve: value => { resolves++; result = value; }, + activeProcesses: new Map(), scopeKey: scope, chatSessionId: sessionId, childProcess: null, + releaseMainRun: () => false, retryState: { setTimer() {}, setResolve() {}, setOrigin() {}, setIsEmployee() {} }, + fallbackState: new Map(), fallbackMaxRetries: 0, processQueue() {}, + onRuntimeEnd: end => { ends++; ctx.printActivity?.finish(end); }, + }; + return { ctx, params, runId, sessionId, scope, + result: () => result, calls: () => ({ resolves, ends, respawns }), + rows: () => db.prepare("SELECT content,trace_run_id FROM messages WHERE session_id=? AND role='assistant'").all(sessionId) as { content: string; trace_run_id: string }[] }; +} + +for (const fault of ['none', 'append', 'terminal', 'link', 'finalize'] as const) { + test(`accepted print parser → real lifecycle → journal; ${fault} failure never changes one final send`, async t => { + let sends = 0; + t.mock.method(globalThis, 'fetch', async () => { + sends++; + return new Response(JSON.stringify({ ok: true, ts: 'fixture-ts' }), { headers: { 'content-type': 'application/json' } }); + }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + const events: BusEvent[] = [], legacy: Array<{ type: string; data: Record }> = []; + const pending: Promise[] = []; + const forward = createSlackForwarder({ getToken: () => 'fixture-token', + getLastTarget: () => ({ channel: 'slack', targetKind: 'channel', peerKind: 'channel', targetId: 'C-fixture' }) }); + const listener = (type: string, data: Record) => { legacy.push({ type, data }); pending.push(forward(type, data)); }; + addBroadcastListener(listener); const unsubscribe = subscribe(e => events.push(e)); + const f = fixture(); + let trigger = false; + try { + if (fault === 'append' || fault === 'terminal') { + db.exec(`CREATE TRIGGER print_fault BEFORE INSERT ON trace_events + WHEN new.source='runtime' ${fault === 'terminal' ? "AND new.event_type='turn-end'" : ''} + BEGIN SELECT RAISE(ABORT,'print fixture fault'); END`); + trigger = true; + } else if (fault === 'link' || fault === 'finalize') { + db.exec(`CREATE TRIGGER print_fault BEFORE UPDATE OF ${fault === 'link' ? 'message_id' : 'status'} ON trace_runs + BEGIN SELECT RAISE(ABORT,'print fixture fault'); END`); + trigger = true; + } + extractFromEvent('codex', { type: 'item.completed', item: { type: 'agent_message', text: 'prelude', channel: 'commentary' } }, f.ctx, 'fixture'); + extractFromEvent('codex', { type: 'item.completed', item: { type: 'agent_message', text: 'selected answer' } }, f.ctx, 'fixture'); + await assert.doesNotReject(handleAgentExit(f.params)); + await Promise.all(pending); + assert.deepEqual(f.calls(), { resolves: 1, ends: 1, respawns: 0 }); + assert.equal(f.result()?.runtimeOutcome, undefined, 'print never invents native outcome'); + assert.deepEqual(f.rows(), [{ content: 'selected answer', trace_run_id: f.runId }]); + const final = legacy.filter(e => e.type === 'agent_done'); + assert.equal(final.length, 1); assert.equal(final[0]?.data['text'], 'selected answer'); + assert.equal(sends, 1, 'real forwarder called only the stubbed HTTP boundary once'); + assert.equal(legacy.some(e => e.type === 'agent_runtime' || e.type === 'agent_runtime_gap'), false); + const p = readActivityPage({ runId: f.runId, sessionId: f.sessionId, after: 0, limit: 40 })!; + if (fault === 'append' || fault === 'terminal') { + assert.equal(p.incomplete, true); assert.equal(p.events.some(e => e.kind === 'turn-end'), false); + assert.equal(events.filter(e => e.event === 'agent_runtime_gap').length, 1); + } else { + const end = p.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, 'selected answer'); + assert.ok(p.events.some(e => e.kind === 'message' && e.phase === 'commentary' && e.text === 'prelude')); + const compat = events.findIndex(e => e.event === 'agent_done'); + const canonical = events.findIndex(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-end'); + assert.ok(compat >= 0 && canonical > compat, 'existing compatibility-first ordering preserved'); + } + if (fault === 'link') assert.equal(getTraceRun(f.runId)?.message_id, null); + else assert.ok(getTraceRun(f.runId)?.message_id); + } finally { + if (trigger) db.exec('DROP TRIGGER print_fault'); + unsubscribe(); removeBroadcastListener(listener); + } + }); +} + +test('print tool-only completion links its authoritative empty MESSAGE to journal discovery', async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); + const f = fixture(); + extractFromEvent('codex', { type: 'item.completed', item: { id: 'tool-only', type: 'command_execution', + command: 'echo fixture', status: 'completed', exit_code: 0, aggregated_output: '' } }, f.ctx, 'fixture'); + assert.ok(f.ctx.toolLog.length > 0); + await handleAgentExit(f.params); + assert.deepEqual(f.rows(), [{ content: '', trace_run_id: f.runId }]); + assert.ok(getTraceRun(f.runId)?.message_id); + const p = readActivityPage({ runId: f.runId, sessionId: f.sessionId, after: 0, limit: 40 })!; + const end = p.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, ''); + assert.equal(p.incomplete, false); assert.deepEqual(f.calls(), { resolves: 1, ends: 1, respawns: 0 }); +}); + +test('real lifecycle persists and broadcasts the same bounded boss/worker tool union', async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); + const f = fixture(); f.ctx.fullText = 'final'; + f.ctx.toolLog = Array.from({ length: 200 }, (_, i) => ({ icon: 'x', label: `boss-${i}`, toolType: 'tool', + stepRef: `boss-${i}`, traceRunId: f.runId, detail: 'x'.repeat(2000), status: 'done' })); + beginLiveRun(f.scope, 'codex'); + appendLiveRunTool(f.scope, { icon: 'x', label: 'worker mirror', toolType: 'tool', stepRef: 'worker', + traceRunId: 'tr_worker1234567890', isEmployee: true, detail: 'worker detail' }); + let broadcastTools: unknown; + const listener = (type: string, data: Record) => { if (type === 'agent_done') broadcastTools = data['toolLog']; }; + addBroadcastListener(listener); + try { + await handleAgentExit(f.params); + const row = db.prepare("SELECT tool_log FROM messages WHERE trace_run_id=? AND role='assistant'").get(f.runId) as { tool_log: string }; + const stored = JSON.parse(row.tool_log) as Array<{ stepRef?: string; label: string }>; + assert.equal(stored.length, 160); assert.equal(stored.filter(tool => tool.stepRef).length, 159); + assert.ok(stored.some(tool => tool.label === 'worker mirror')); + assert.ok(stored.some(tool => tool.label.startsWith('boss-'))); + assert.ok(row.tool_log.length <= 64_000); + assert.deepEqual(broadcastTools, stored); + } finally { removeBroadcastListener(listener); clearLiveRun(f.scope); } +}); diff --git a/tests/unit/print-activity-projection.test.ts b/tests/unit/print-activity-projection.test.ts new file mode 100644 index 000000000..5ad055ed8 --- /dev/null +++ b/tests/unit/print-activity-projection.test.ts @@ -0,0 +1,158 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPrintActivityProjection } from '../../src/agent/runtime/print-projection.js'; +import { createPrintActivity, finishPrintActivity } from '../../src/agent/runtime/print-activity.js'; +import { startTraceRun, finalizeTraceRun, getTraceRun } from '../../src/trace/store.js'; +import { db } from '../../src/core/db.js'; +import { readActivityPage } from '../../src/trace/activity-journal.js'; +import { subscribe, type BusEvent } from '../../src/core/event-bus.js'; +import { settings } from '../../src/core/config.js'; +import { emitAgentTool } from '../../src/agent/events/helpers.js'; +import { addBroadcastListener, removeBroadcastListener } from '../../src/core/bus.js'; +import type { RuntimeEventBody } from '../../src/shared/runtime-contract.js'; +import type { SpawnContext } from '../../src/types/agent.js'; + +test('pure observer keeps message boundaries, operations, unknown phase and stable tool references', () => { + const seen: RuntimeEventBody[] = []; + const observer = createPrintActivityProjection(body => seen.push(body)); + observer.message('A', 'append'); observer.message('B', 'append'); observer.nextMessage(); + observer.message('commentary', 'replace', 'commentary'); + observer.reasoning('think', 'append'); observer.reasoning(' more', 'append'); + observer.tool({ icon: 'x', label: 'cmd', toolType: 'tool', stepRef: 'stable', status: 'running' }); + observer.tool({ icon: 'x', label: 'cmd', toolType: 'tool', stepRef: 'stable', status: 'done', detail: '' }); + assert.equal(seen[0]?.kind, 'message'); + assert.equal(seen[0]?.kind === 'message' && seen[0].phase, 'unknown'); + assert.equal(seen[0]?.kind === 'message' && seen[0].itemId, seen[1]?.kind === 'message' && seen[1].itemId); + assert.notEqual(seen[0]?.kind === 'message' && seen[0].itemId, seen[2]?.kind === 'message' && seen[2].itemId); + assert.equal(seen[3]?.kind === 'reasoning' && seen[3].itemId, seen[4]?.kind === 'reasoning' && seen[4].itemId); + assert.equal(seen[5]?.kind === 'tool' && seen[5].itemId, seen[6]?.kind === 'tool' && seen[6].itemId); + assert.equal(seen[6]?.kind === 'tool' && seen[6].detail, ''); +}); + +test('anonymous tools stay distinct; narration and thinking never become real tools', () => { + const seen: RuntimeEventBody[] = []; + const observer = createPrintActivityProjection(body => seen.push(body)); + observer.tool({ icon: '💬', label: 'narration', toolType: 'thinking' }); + observer.tool({ icon: '💭', label: 'thought', toolType: 'thinking' }); + observer.tool({ icon: 'x', label: 'same', toolType: 'tool' }); + observer.tool({ icon: 'x', label: 'same', toolType: 'tool' }); + observer.tool({ icon: 'x', label: 'same', toolType: 'tool', traceSeq: 2 }); + observer.tool({ icon: 'x', label: 'same', toolType: 'tool', stepRef: 'trace:2' }); + assert.deepEqual(seen.map(e => e.kind), ['message', 'reasoning', 'tool', 'tool', 'tool', 'tool']); + assert.equal(new Set(seen.filter(e => e.kind === 'tool').map(e => e.itemId)).size, 4); +}); + +test('pure observer finishes exactly once and contains a throwing sink', t => { + const seen: RuntimeEventBody[] = []; + const observer = createPrintActivityProjection(body => seen.push(body)); + observer.finish({ kind: 'turn-end', status: 'done', finalText: '' }); + observer.finish({ kind: 'turn-end', status: 'done', finalText: 'late' }); + observer.message('late', 'append'); + assert.deepEqual(seen, [{ kind: 'turn-end', status: 'done', finalText: '' }]); + t.mock.method(console, 'warn', () => {}); + let calls = 0; + const failed = createPrintActivityProjection(() => { calls++; throw new Error('fixture'); }); + assert.doesNotThrow(() => { failed.message('A', 'append'); failed.message('B', 'append'); failed.finish({ kind: 'turn-end', status: 'error', finalText: null }); }); + assert.equal(calls, 1); +}); + +test('factory uses actual journal and preserves selected null/empty/whitespace application finals', () => { + for (const finalText of [null, '', ' \n', 'selected final']) { + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + observer.message('unfinished narration', 'append'); + observer.finish({ kind: 'turn-end', status: 'done', finalText }); finalizeTraceRun(runId, 'done'); + const p = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!; + const end = p.events.at(-1); + assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, finalText); assert.equal(p.incomplete, false); + assert.equal(p.events.filter(e => e.kind === 'turn-start').length, 1); + } +}); + +test('preview capacity fails visibly through one gap, with no fabricated terminal', () => { + const seen: BusEvent[] = []; const unsubscribe = subscribe(e => seen.push(e)); + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + try { + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + observer.message('x'.repeat(4000), 'append'); observer.message('late', 'append'); + observer.finish({ kind: 'turn-end', status: 'done', finalText: 'still selected by lifecycle' }); + const p = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(p.incomplete, true); assert.equal(p.loss, 'run_limit'); + assert.equal(p.events.some(e => e.kind === 'turn-end'), false); + assert.equal(seen.filter(e => e.event === 'agent_runtime_gap').length, 1); + } finally { unsubscribe(); } +}); + +test('bypass observer failure and trace failure are independently contained', t => { + t.mock.method(console, 'warn', () => {}); + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + let calls = 0; + assert.doesNotThrow(() => finishPrintActivity({ traceRunId: runId, printActivity: { ...observer, + finish: () => { calls++; throw new Error('fixture observer'); }, + } }, { kind: 'turn-end', status: 'error', finalText: null })); + assert.equal(calls, 1); assert.equal(getTraceRun(runId)?.status, 'error', 'trace still finalized after observer throw'); + db.exec("CREATE TRIGGER bypass_trace_failure BEFORE UPDATE OF status ON trace_runs BEGIN SELECT RAISE(ABORT,'fixture'); END"); + try { + assert.doesNotThrow(() => finishPrintActivity({ traceRunId: runId, printActivity: { ...observer, + finish: () => { calls++; }, + } }, { kind: 'turn-end', status: 'error', finalText: null })); + assert.equal(calls, 2, 'observer still called exactly once before trace failure'); + } finally { db.exec('DROP TRIGGER bypass_trace_failure'); } +}); + +test('factory preserves a newer terminal tool detail on the same canonical item', () => { + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + const tool = { icon: 'x', label: 'command', toolType: 'tool', stepRef: 'stable' }; + observer.tool({ ...tool, status: 'running', detail: 'start' }); + observer.tool({ ...tool, status: 'done', detail: 'first result' }); + observer.tool({ ...tool, status: 'done', detail: 'updated result' }); + observer.tool({ ...tool, status: 'running', detail: 'stale start' }); + const tools = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!.events.filter(e => e.kind === 'tool'); + assert.equal(tools.length, 3); + assert.equal(new Set(tools.map(e => e.itemId)).size, 1); + assert.equal(tools.at(-1)?.status, 'done'); assert.equal(tools.at(-1)?.detail, 'updated result'); +}); + +test('print opt-in follows explicit terminal status updates without inferring recovery from a running update', () => { + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + const tool = { icon: 'x', label: 'command', toolType: 'tool', stepRef: 'severity' }; + observer.tool({ ...tool, status: 'error', detail: 'failed' }); + observer.tool({ ...tool, status: 'running', detail: 'stale' }); + let tools = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!.events.filter(e => e.kind === 'tool'); + assert.equal(tools.length, 1); assert.equal(tools[0]?.status, 'error'); assert.equal(tools[0]?.detail, 'failed'); + // Legacy print providers explicitly revise completed snapshots. Native callers + // retain their existing frozen error/result policy unless they opt in. + observer.tool({ ...tool, status: 'done', detail: '' }); + tools = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!.events.filter(e => e.kind === 'tool'); + assert.equal(tools.length, 2); assert.equal(tools[1]?.status, 'done'); assert.equal(tools[1]?.detail, ''); + assert.equal(tools[0]?.itemId, tools[1]?.itemId); +}); + +function context(): SpawnContext { + return { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), hasClaudeStreamEvents: false, + sessionId: 'native-private', cost: null, turns: null, duration: null, tokens: null, stderrBuf: '', + traceRunId: 'tr_captured1234567890', activityIdentity: { sessionId: 'jaw-captured', scope: 'captured-scope' } }; +} + +test('tool wire identity is captured after payload overrides even with multi-session disabled', () => { + const before = settings.multiSession.enabled; settings.multiSession.enabled = false; + const seen: BusEvent[] = []; const legacy: Record[] = []; + const unsubscribe = subscribe(e => seen.push(e)); + const listener = (_type: string, data: Record) => legacy.push(data); + addBroadcastListener(listener); + try { + const ctx = context(); + emitAgentTool(ctx, 'fixture', { icon: 'x', label: 'cmd', toolType: 'tool', traceRunId: 'spoof', sessionId: 'spoof', scope: 'spoof' }, + { sessionId: 'emp-spoof', scope: 'emp-spoof', traceRunId: 'emp-spoof' }); + assert.equal(seen[0]?.data['sessionId'], 'jaw-captured'); assert.equal(seen[0]?.data['scope'], 'captured-scope'); + assert.equal(seen[0]?.data['traceRunId'], ctx.traceRunId); + assert.ok(!JSON.stringify(seen).includes('native-private')); + ctx.traceAudience = 'internal'; emitAgentTool(ctx, undefined, { icon: 'x', label: 'cmd', toolType: 'tool' }, {}); + assert.equal(seen.length, 1, 'internal audience has no public SSE'); + assert.equal(legacy.length, 2, 'legacy internal listener contract remains'); + } finally { unsubscribe(); removeBroadcastListener(listener); settings.multiSession.enabled = before; } +}); diff --git a/tests/unit/print-bypass-paths.test.ts b/tests/unit/print-bypass-paths.test.ts new file mode 100644 index 000000000..b0e222a24 --- /dev/null +++ b/tests/unit/print-bypass-paths.test.ts @@ -0,0 +1,110 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import childProcess from 'node:child_process'; + +const home = process.env['CLI_JAW_HOME']!; +process.env['TMPDIR'] = join(home, 'tmp'); +mkdirSync(process.env['TMPDIR'], { recursive: true }); +mkdirSync(join(home, 'prompts'), { recursive: true }); +mkdirSync(join(home, '.copilot'), { recursive: true }); +writeFileSync(join(home, '.copilot/config.json'), '{"model":"fixture"}'); +const isolatedOs = { ...os, homedir: () => home }; +test.mock.module('node:os', { namedExports: isolatedOs, defaultExport: isolatedOs }); +assert.equal((await import('os')).default.homedir(), home, 'Copilot config writes must be isolated before importing spawn'); + +let lastAcp: ErrorAcp | null = null; +class ErrorAcp extends EventEmitter { + proc = Object.assign(new EventEmitter(), { pid: undefined, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough() }); + constructor() { super(); lastAcp = this; } + spawn() { queueMicrotask(() => { this.emit('error', new Error('fixture ACP failure')); this.emit('exit', { code: 1, signal: null }); }); } + initialize() { return new Promise(() => {}); } + kill() {} +} +test.mock.module('../../src/cli/acp-client.js', { namedExports: { AcpClient: ErrorAcp } }); +let launches = 0; +test.mock.module('node:child_process', { namedExports: { ...childProcess, + spawn: (command: string, args: readonly string[], options: childProcess.SpawnOptions) => { + assert.equal(command, process.execPath, 'no provider process may launch'); + assert.deepEqual(args, []); launches++; + const output = launches === 1 ? 'Warning: conversation "stale" not found\n' : 'Fresh answer\n'; + return childProcess.spawn(command, ['--input-type=module', '-e', + `process.stdin.resume();process.stdin.on('end',()=>process.stdout.write(${JSON.stringify(output)}));`], options); + }, +} }); +const config = await import('../../src/core/config.ts'); +test.mock.module('../../src/core/config.js', { namedExports: { ...config, + detectCli: () => ({ available: true, path: process.execPath }), +} }); +const argsModule = await import('../../src/agent/args.ts'); +test.mock.module('../../src/agent/args.js', { namedExports: { ...argsModule, buildArgs: () => [], buildResumeArgs: () => [] } }); +const capabilities = await import('../../src/agent/agy-capabilities.ts'); +test.mock.module('../../src/agent/agy-capabilities.js', { namedExports: { ...capabilities, + detectAgyCapabilities: () => ({ ...capabilities.DEFAULT_AGY_CAPABILITIES, usedFallback: false }), +} }); +const resume = await import('../../src/agent/spawn/resume.ts'); +test.mock.module('../../src/agent/spawn/resume.js', { namedExports: { ...resume, + canGuardedAgyResume: (input: { freshBootstrap: boolean }) => ({ ok: !input.freshBootstrap, reason: 'fixture' }), + shouldResumeBucketSession: () => true, +} }); +const watcher = await import('../../src/agent/agy-transcript-watcher.ts'); +test.mock.module('../../src/agent/agy-transcript-watcher.js', { namedExports: { ...watcher, + startAgyTranscriptWatcher: () => ({ stop() {} }), +} }); +const { spawnAgent, activeMainProcesses, activeProcesses } = await import('../../src/agent/spawn.ts'); +const { subscribe } = await import('../../src/core/event-bus.ts'); +const { db, upsertSessionBucket } = await import('../../src/core/db.ts'); +const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); + +test.beforeEach(t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + config.settings.workingDir = home; config.settings.memory.enabled = false; + config.settings.multiSession.enabled = false; config.settings.activeOverrides = {}; config.settings.fallbackOrder = []; +}); + +test('EG-007b: Copilot ACP error reentry and exit preserve one completion and close the journal', { timeout: 10_000 }, async () => { + const seen: Array<{ event: string; data: Record }> = []; + const unsubscribe = subscribe(e => seen.push(e)); + try { + let exits = 0; + const result = await spawnAgent('ACP fixture', { cli: 'copilot', model: 'fixture', effort: '', sysPrompt: 'fixture system', origin: 'web', + _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true, + lifecycle: { onExit: () => { exits++; assert.ok(lastAcp); lastAcp.emit('error', new Error('reentrant ACP fixture')); } }, + }).promise; + assert.equal(result.code, 1); assert.equal(launches, 0); + assert.equal(exits, 1, 'settled guard precedes reentrant error and subsequent exit'); + const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; + const p = readActivityPage({ runId: String(start.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(p.status, 'error'); assert.equal(p.events.at(-1)?.kind, 'turn-end'); + assert.equal(seen.filter(e => e.event === 'agent_done').length, 1); + assert.equal(activeMainProcesses.size, 0); assert.equal(activeProcesses.size, 0); + assert.deepEqual(JSON.parse(readFileSync(join(home, '.copilot/config.json'), 'utf8')), { model: 'fixture' }); + } finally { unsubscribe(); } +}); + +test('AGY guarded stale retry closes the old journal before the one existing fresh attempt', { timeout: 15_000 }, async () => { + config.settings.perCli.agy = { model: 'fixture', effort: '', nativeResume: 'guarded' }; + upsertSessionBucket.run('agy', 'stale-conversation', 'fixture', null, 0); + const seen: Array<{ event: string; data: Record }> = []; + const unsubscribe = subscribe(e => seen.push(e)); + try { + const result = await spawnAgent('AGY fixture', { cli: 'agy', model: 'fixture', effort: '', sysPrompt: 'fixture system', origin: 'web', + _skipInsert: true, _skipHistory: true, _skipSessionPersist: true, _isSmokeContinuation: true }).promise; + assert.equal(result.code, 0); assert.equal(launches, 2, 'only the existing stale-resume retry'); + const starts = seen.filter(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start'); + assert.equal(starts.length, 2); + const old = readActivityPage({ runId: String(starts[0]!.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; + const fresh = readActivityPage({ runId: String(starts[1]!.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(old.status, 'interrupted'); assert.equal(old.events.at(-1)?.kind, 'turn-end'); + const end = fresh.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, 'Fresh answer'); + assert.equal(seen.filter(e => e.event === 'agent_done').length, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE role='assistant'").get() as { n: number }).n, 1); + assert.equal(activeMainProcesses.size, 0); + } finally { unsubscribe(); } +}); diff --git a/tests/unit/print-provider-observation.test.ts b/tests/unit/print-provider-observation.test.ts new file mode 100644 index 000000000..d551ec85a --- /dev/null +++ b/tests/unit/print-provider-observation.test.ts @@ -0,0 +1,425 @@ +import '../setup/isolated-home.ts'; +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { extractFromEvent, extractOutputChunk } from '../../src/agent/events/index.ts'; +import { flushOpenCodeBuffers } from '../../src/agent/events/opencode.ts'; +import { FULLTEXT_MAX_CHARS } from '../../src/agent/events/fulltext-bound.ts'; +import type { SpawnContext } from '../../src/types/agent.ts'; +import type { PrintActivityProjection, PrintToolInput } from '../../src/agent/runtime/print-projection.ts'; +import type { CliEventRecord } from '../../src/types/cli-events.ts'; +import { startTraceRun, listToolEntriesForRun, getTraceEvent } from '../../src/trace/store.ts'; +import { addBroadcastListener, removeBroadcastListener } from '../../src/core/bus.ts'; + +function fixture(cli: string) { + const observed = { + nextMessage: mock.fn(), + message: mock.fn(), + reasoning: mock.fn(), + // Snapshot arguments: parsers mutate the same ToolEntry on completion. + tool: mock.fn((entry: PrintToolInput) => ({ ...entry })), + finish: mock.fn(), + } satisfies PrintActivityProjection; + const ctx: SpawnContext = { + fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), + hasClaudeStreamEvents: false, sessionId: null, cost: null, turns: null, + duration: null, tokens: null, stderrBuf: '', printActivity: observed, + traceRunId: startTraceRun({ cli, audience: 'public' }), + }; + return { ctx, observed, accept: (event: CliEventRecord) => extractFromEvent(cli, event, ctx, cli) }; +} + +const assistant = (id: string, text: string): CliEventRecord => ({ + type: 'assistant', message: { id, content: [{ type: 'text', text }] }, +}); +const stream = (type: string, text: string): CliEventRecord => ({ + type: 'stream_event', event: { type: 'content_block_delta', delta: { + type, ...(type === 'thinking_delta' ? { thinking: text } : { text }), + } }, +}); + +test('Codex messages retain commentary before LAST-WINS and only explicit phases are final', () => { + const { ctx, observed, accept } = fixture('codex'); + accept({ type: 'item.completed', item: { type: 'agent_message', text: 'Plan', channel: 'commentary' } }); + assert.equal(ctx.fullText, ''); + accept({ type: 'item.completed', item: { type: 'agent_message', text: 'Unknown' } }); + accept({ type: 'item.completed', item: { type: 'agent_message', text: 'Answer', channel: 'final' } }); + accept({ type: 'turn.completed', usage: { input_tokens: 1 } }); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['Plan', 'replace', 'commentary'], ['Unknown', 'replace', 'unknown'], ['Answer', 'replace', 'final'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 3); + assert.equal(observed.tool.mock.callCount(), 0, 'narration cards must not double-observe'); + assert.equal(observed.finish.mock.callCount(), 0, 'only lifecycle owns finish'); + assert.equal(ctx.fullText, 'Answer'); +}); + +test('plain Claude same-ID A/B append while changed-ID text starts a new message', () => { + const { ctx, observed, accept } = fixture('claude'); + accept(assistant('m1', 'A')); + accept(assistant('m1', 'B')); + assert.equal(ctx.fullText, 'A\n- B', 'retain legacy segment formatting'); + assert.equal(observed.nextMessage.mock.callCount(), 0); + accept(assistant('m2', 'Final')); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['A', 'append', 'unknown'], ['B', 'append', 'unknown'], ['Final', 'append', 'unknown'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.equal(ctx.fullText, 'Final'); +}); + +test('Claude text deltas reconcile by replacement; the next stream message resets once', () => { + const { ctx, observed, accept } = fixture('claude'); + accept(stream('text_delta', 'Hel')); + accept(stream('text_delta', 'lo')); + accept(assistant('m1', 'Hello')); + assert.equal(ctx.fullText, 'Hello'); + assert.equal(extractOutputChunk('claude', {}, ctx), 'Hello'); + accept({ type: 'stream_event', event: { type: 'message_start', message: { id: 'm2' } } }); + accept(stream('text_delta', 'Bye')); + accept(assistant('m2', 'Bye')); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['Hel', 'append', 'unknown'], ['lo', 'append', 'unknown'], ['Hello', 'replace', 'unknown'], + ['Bye', 'append', 'unknown'], ['Bye', 'replace', 'unknown'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.equal(ctx.fullText, 'Bye'); +}); + +test('claude-e cumulative snapshots replace, reject duplicate/shorter snapshots and reset on ID change', () => { + const { ctx, observed, accept } = fixture('claude-e'); + accept(assistant('m1', 'A')); + accept(assistant('m1', 'AB')); + accept(assistant('m1', 'AB')); + accept(assistant('m1', 'A')); + accept(assistant('m2', 'Answer')); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['A', 'replace', 'unknown'], ['AB', 'replace', 'unknown'], ['Answer', 'replace', 'unknown'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.equal(ctx.fullText, 'Answer'); +}); + +test('Claude thinking deltas are observed once and synthetic flush cards add no tool observation', () => { + const { observed, accept } = fixture('claude'); + accept({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'thinking' } } }); + accept(stream('thinking_delta', 'Think ')); + accept(stream('thinking_delta', 'carefully')); + accept({ type: 'stream_event', event: { type: 'content_block_stop' } }); + assert.deepEqual(observed.reasoning.mock.calls.map(c => c.arguments), [['Think ', 'append'], ['carefully', 'append']]); + assert.equal(observed.tool.mock.callCount(), 0); +}); + +test('complete Codex and nonstream Claude thinking observe accepted plaintext without duplicate tools', () => { + const codex = fixture('codex'); + const reasoning = { type: 'item.completed', item: { type: 'reasoning', text: 'Consider this' } }; + codex.accept(reasoning); + codex.accept(reasoning); + assert.deepEqual(codex.observed.reasoning.mock.calls.map(c => c.arguments), [['Consider this', 'replace']]); + assert.equal(codex.observed.tool.mock.callCount(), 0); + const claude = fixture('claude'); + claude.accept({ type: 'assistant', message: { id: 'thought', content: [{ type: 'thinking', thinking: 'Consider that' }] } }); + assert.deepEqual(claude.observed.reasoning.mock.calls.map(c => c.arguments), [['Consider that', 'replace']]); + assert.equal(claude.observed.tool.mock.callCount(), 0); +}); + +test('Cursor observes only accepted normalized segments and retains pre-tool narration', () => { + const { ctx, observed, accept } = fixture('cursor'); + accept({ type: 'assistant', subtype: 'delta', text: 'Plan\\n' }); + accept({ type: 'assistant', subtype: 'delta', text: 'first' }); + accept(assistant('m1', 'Plan\\nfirst')); + accept({ type: 'tool_call', subtype: 'started', call_id: 'c', name: 'shell' }); + accept(assistant('m1', 'Plan\\nfirst')); + accept(assistant('m1', 'Plan\\nfirst done')); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['Plan\n', 'append', 'unknown'], ['first', 'append', 'unknown'], [' done', 'append', 'unknown'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.equal(ctx.fullText, ' done'); +}); + +test('Cursor message-ID boundary preserves a shared prefix; result fallback remains unknown', () => { + const { ctx, observed, accept } = fixture('cursor'); + accept(assistant('m1', 'Sum')); + accept(assistant('m2', 'Summary')); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [['Sum', 'append', 'unknown'], ['Summary', 'append', 'unknown']]); + assert.equal(ctx.fullText, 'Summary'); + accept({ type: 'result', subtype: 'success', result: 'ignored' }); + assert.equal(observed.message.mock.callCount(), 2); + const fallback = fixture('cursor'); + fallback.accept({ type: 'result', subtype: 'success', result: 'done\\nnext' }); + assert.deepEqual(fallback.observed.message.mock.calls[0]?.arguments, ['done\nnext', 'append', 'unknown']); + assert.equal(fallback.observed.finish.mock.callCount(), 0); +}); + +test('Grok observes complete thought and text chunks before legacy bounds, without final inference', () => { + const { ctx, observed, accept } = fixture('grok'); + const thought = 't'.repeat(102_401); + ctx.fullText = 'x'.repeat(FULLTEXT_MAX_CHARS - 1); + accept({ type: 'thought', data: thought }); + assert.equal(ctx.grokThoughtBuf?.length, 102_400); + accept({ type: 'text', data: 'answer' }); + accept({ type: 'end', stopReason: 'done' }); + assert.deepEqual(observed.reasoning.mock.calls[0]?.arguments, [thought, 'append']); + assert.deepEqual(observed.message.mock.calls[0]?.arguments, ['answer', 'append', 'unknown']); + assert.equal(ctx.fullText.length, FULLTEXT_MAX_CHARS); + assert.equal(ctx.fullTextTruncated, true); + assert.equal(observed.tool.mock.callCount(), 0); + assert.equal(observed.finish.mock.callCount(), 0); +}); + +test('OpenCode retains pre-tool text and reasoning before step discard and reset', () => { + const { ctx, observed, accept } = fixture('opencode'); + accept({ type: 'step_start' }); + accept({ type: 'reasoning', part: { text: 'Reason' } }); + accept({ type: 'text', part: { text: 'Plan' } }); + accept({ type: 'tool_use', part: { tool: 'read', callID: 'c', state: { input: { path: '/tmp/a' } } } }); + accept({ type: 'step_finish', part: { reason: 'tool-calls' } }); + assert.equal(ctx.fullText, ''); + accept({ type: 'step_start' }); + accept({ type: 'text', part: { text: 'Answer' } }); + accept({ type: 'step_finish', part: { reason: 'stop' } }); + assert.equal(ctx.fullText, 'Answer'); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [['Plan', 'append', 'unknown'], ['Answer', 'append', 'unknown']]); + assert.deepEqual(observed.reasoning.mock.calls.map(c => c.arguments), [['Reason', 'replace']]); + assert.equal(observed.nextMessage.mock.callCount(), 2); + assert.equal(observed.tool.mock.callCount(), 2, 'only the actual tool start and completion'); + assert.equal(observed.finish.mock.callCount(), 0); +}); + +for (const cli of ['cursor', 'grok', 'opencode']) { + test(`${cli} running detail refresh and evicted completion update the same durable pointer once`, () => { + const { ctx, observed, accept } = fixture(cli); + const event = (detail: string, done = false): CliEventRecord => cli === 'cursor' + ? { type: 'tool_call', subtype: done ? 'completed' : 'started', call_id: 'one', name: 'Read', input: { path: detail } } + : cli === 'grok' + ? { type: done ? 'tool_result' : 'tool_use', id: 'one', name: 'Read', input: { path: detail } } + : { type: 'tool_use', part: { tool: 'Read', callID: 'one', state: { status: done ? 'completed' : 'running', input: { path: detail } } } }; + accept(event('/tmp/old')); + const pointer = { traceRunId: ctx.toolLog[0]?.traceRunId, traceSeq: ctx.toolLog[0]?.traceSeq }; + assert.ok(pointer.traceRunId && pointer.traceSeq); + accept(event('/tmp/new')); + let rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(ctx.toolLog.length, 1); + assert.equal(rows[0]?.detail, '/tmp/new', 'equal counts must still update detail'); + assert.equal(observed.tool.mock.callCount(), 2); + if (cli !== 'grok') { + accept(event('/tmp/new')); + assert.equal(observed.tool.mock.callCount(), 2, 'existing dedupe still rejects exact replay'); + } + ctx.toolLog.length = 0; // emulate the existing RAM cap, preserving stamp-time index + accept(event('/tmp/final', true)); + rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, 'done'); + assert.equal(rows[0]?.detail, '/tmp/final'); + assert.equal(rows[0]?.traceSeq, pointer.traceSeq); + assert.equal(observed.tool.mock.callCount(), 3); + assert.equal(observed.tool.mock.calls[2]?.result?.traceSeq, pointer.traceSeq); + }); +} + +test('Claude evicted completion observes after durable update without a new legacy broadcast', () => { + const { ctx, observed, accept } = fixture('claude'); + accept({ type: 'assistant', message: { id: 'm1', content: [{ type: 'tool_use', id: 'one', name: 'Read', input: { path: '/tmp/a' } }] } }); + const pointer = ctx.toolTraceIndex?.get('claude:tooluse:one'); + assert.ok(pointer); + ctx.toolLog.length = 0; + let legacyCalls = 0; + const listener = (type: string) => { if (type === 'agent_tool') legacyCalls++; }; + observed.tool.mock.mockImplementation(entry => { + const row = getTraceEvent(pointer.traceRunId, pointer.traceSeq); + assert.equal(JSON.parse(row!.raw).status, 'done', 'durable update precedes observer'); + return { ...entry }; + }); + addBroadcastListener(listener); + try { + accept({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'one', content: 'complete' }] } }); + } finally { removeBroadcastListener(listener); } + assert.equal(legacyCalls, 0); + assert.equal(observed.tool.mock.callCount(), 2); + assert.equal(observed.tool.mock.calls[1]?.result?.traceSeq, pointer.traceSeq); + assert.equal(listToolEntriesForRun(ctx.traceRunId!).length, 1); +}); + +test('OpenCode pending-tool flush recovers an evicted pointer and does not re-complete it', () => { + const { ctx, observed, accept } = fixture('opencode'); + accept({ type: 'step_start' }); + accept({ type: 'tool_use', part: { tool: 'Read', callID: 'pending', state: { input: { path: '/tmp/a' } } } }); + const seq = ctx.toolLog[0]?.traceSeq; + ctx.toolLog.length = 0; + flushOpenCodeBuffers(ctx, 'opencode'); + flushOpenCodeBuffers(ctx, 'opencode'); + const rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, 'done'); + assert.equal(rows[0]?.traceSeq, seq); + assert.equal(observed.tool.mock.callCount(), 2); +}); + +test('Grok throttled thought detail and evicted thought completion converge durably', () => { + const { ctx, observed, accept } = fixture('grok'); + accept({ type: 'thought', data: 'A' }); + ctx.grokLastThoughtEmitAt = Date.now() + 60_000; + accept({ type: 'thought', data: 'B' }); + assert.equal(listToolEntriesForRun(ctx.traceRunId!)[0]?.detail, 'AB'); + ctx.toolLog.length = 0; + accept({ type: 'end' }); + const rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, 'done'); + assert.equal(rows[0]?.detail, 'AB'); + assert.equal(observed.tool.mock.callCount(), 0); +}); + +for (const cli of ['cursor', 'grok', 'opencode']) { + test(`${cli} same-label calls keep distinct pointers through running eviction and error completion`, () => { + const { ctx, observed, accept } = fixture(cli); + const event = (id: string, detail: string, failed = false): CliEventRecord => cli === 'cursor' + ? { type: 'tool_call', subtype: failed ? 'error' : 'started', call_id: id, name: 'Read', input: { path: detail } } + : cli === 'grok' + ? { type: failed ? 'tool_result' : 'tool_use', id, name: 'Read', input: { path: detail }, ...(failed ? { is_error: true } : {}) } + : { type: 'tool_use', part: { tool: 'Read', callID: id, state: { status: failed ? 'error' : 'running', input: { path: detail } } } }; + accept(event('first', '/tmp/first')); + accept(event('second', '/tmp/second')); + const firstSeq = ctx.toolLog[0]?.traceSeq; + const secondSeq = ctx.toolLog[1]?.traceSeq; + assert.notEqual(firstSeq, secondSeq); + ctx.toolLog.length = 0; + accept(event('first', '/tmp/refreshed')); + assert.equal(listToolEntriesForRun(ctx.traceRunId!)[0]?.detail, '/tmp/refreshed'); + ctx.toolLog.length = 0; + accept(event('second', '/tmp/failed', true)); + const rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 2, 'a label is never a new identity index'); + assert.equal(rows[0]?.traceSeq, firstSeq); + assert.equal(rows[1]?.traceSeq, secondSeq); + assert.equal(rows[1]?.status, 'error'); + assert.equal(rows[1]?.detail, '/tmp/failed'); + assert.equal(observed.tool.mock.callCount(), 4); + }); +} + +for (const cli of ['cursor', 'grok', 'opencode']) { + for (const status of ['done', 'error'] as const) { + for (const evicted of [false, true]) { + test(`${cli} start(old) -> ${status}(final) -> start(old) preserves terminal ${evicted ? 'after eviction' : 'in RAM'}`, () => { + const { ctx, observed, accept } = fixture(cli); + const event = (detail: string, terminal = false): CliEventRecord => cli === 'cursor' + ? { type: 'tool_call', subtype: terminal ? (status === 'done' ? 'completed' : 'error') : 'started', + call_id: 'stale', name: 'Read', input: { path: detail } } + : cli === 'grok' + ? { type: terminal ? 'tool_result' : 'tool_use', id: 'stale', name: 'Read', + input: { path: detail }, ...(terminal && status === 'error' ? { is_error: true } : {}) } + : { type: 'tool_use', part: { tool: 'Read', callID: 'stale', state: { + status: terminal ? (status === 'done' ? 'completed' : 'error') : 'running', input: { path: detail }, + } } }; + accept(event('/tmp/old')); + accept(event('/tmp/final', true)); + const terminalRows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(terminalRows.length, 1); + assert.equal(terminalRows[0]?.status, status); + assert.equal(terminalRows[0]?.detail, '/tmp/final'); + if (evicted) ctx.toolLog.length = 0; + const terminalRam = ctx.toolLog.map(entry => ({ ...entry })); + accept(event('/tmp/old')); + assert.deepEqual(listToolEntriesForRun(ctx.traceRunId!), terminalRows, 'stale start must not write durable state'); + assert.deepEqual(ctx.toolLog, terminalRam, 'stale start must not mutate or repopulate RAM'); + assert.equal(observed.tool.mock.callCount(), 2, 'stale start must not reach the observer'); + // A newer terminal detail remains admissible; the guard is not a blanket freeze. + accept(event('/tmp/final-updated', true)); + const updated = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(updated.length, 1); + assert.equal(updated[0]?.status, status); + assert.equal(updated[0]?.detail, '/tmp/final-updated'); + assert.equal(updated[0]?.traceSeq, terminalRows[0]?.traceSeq); + assert.equal(observed.tool.mock.callCount(), 3); + }); + } + } +} + +for (const evicted of [false, true]) { + test(`Cursor done -> answer -> stale start preserves answer and message boundary ${evicted ? 'after eviction' : 'in RAM'}`, () => { + const { ctx, observed, accept } = fixture('cursor'); + const start: CliEventRecord = { type: 'tool_call', subtype: 'started', call_id: 'old', name: 'Read', input: { path: '/tmp/old' } }; + accept(start); + accept({ ...start, subtype: 'completed', input: { path: '/tmp/final' } }); + accept(assistant('answer', 'Final answer')); + assert.equal(ctx.fullText, 'Final answer'); + const answer = { + fullText: ctx.fullText, outputTextStarted: ctx.outputTextStarted, + pendingOutputChunk: ctx.pendingOutputChunk, + cursorAssistantText: ctx.cursorAssistantText, cursorAssistantMessageId: ctx.cursorAssistantMessageId, + }; + const boundaries = observed.nextMessage.mock.callCount(); + const messages = observed.message.mock.calls.map(c => c.arguments); + const terminalRows = listToolEntriesForRun(ctx.traceRunId!); + if (evicted) ctx.toolLog.length = 0; + const terminalRam = ctx.toolLog.map(entry => ({ ...entry })); + accept(start); + assert.equal(ctx.fullText, 'Final answer', 'stale start must not erase the legacy answer'); + assert.deepEqual({ + fullText: ctx.fullText, outputTextStarted: ctx.outputTextStarted, + pendingOutputChunk: ctx.pendingOutputChunk, + cursorAssistantText: ctx.cursorAssistantText, cursorAssistantMessageId: ctx.cursorAssistantMessageId, + }, answer); + assert.equal(observed.nextMessage.mock.callCount(), boundaries, 'current print message must not advance'); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), messages); + assert.deepEqual(ctx.toolLog, terminalRam); + assert.deepEqual(listToolEntriesForRun(ctx.traceRunId!), terminalRows); + assert.equal(observed.tool.mock.callCount(), 2); + // A genuinely new tool still clears preceding text and advances the message. + accept({ ...start, call_id: 'new' }); + assert.equal(ctx.fullText, ''); + assert.equal(ctx.outputTextStarted, false); + assert.equal(ctx.cursorAssistantText, 'Final answer', 'keep the cumulative dedupe baseline'); + assert.equal(observed.nextMessage.mock.callCount(), boundaries + 1); + assert.equal(observed.tool.mock.callCount(), 3); + assert.equal(listToolEntriesForRun(ctx.traceRunId!).length, 2); + }); +} + +for (const cli of ['cursor', 'grok', 'opencode', 'claude']) { + test(`${cli} spilled tool read failure cannot escape completion or erase the legacy answer`, () => { + const { ctx, observed, accept } = fixture(cli); + const detail = '/tmp/' + 'x'.repeat(96_001); + const start: CliEventRecord = cli === 'cursor' + ? { type: 'tool_call', subtype: 'started', call_id: 'spill', name: 'Read', input: { path: detail } } + : cli === 'grok' + ? { type: 'tool_use', id: 'spill', name: 'Read', input: { path: detail } } + : cli === 'opencode' + ? { type: 'tool_use', part: { tool: 'Read', callID: 'spill', state: { input: { path: detail } } } } + : { type: 'assistant', message: { id: 'm1', content: [{ type: 'tool_use', id: 'spill', name: 'Read', input: { path: detail } }] } }; + accept(start); + const pointer = { traceRunId: ctx.toolLog[0]?.traceRunId, traceSeq: ctx.toolLog[0]?.traceSeq }; + assert.ok(pointer.traceRunId && pointer.traceSeq); + assert.ok(getTraceEvent(pointer.traceRunId, pointer.traceSeq)?.raw_path, 'exercise a real spilled row'); + ctx.toolLog.length = 0; + ctx.fullText = 'Existing legacy answer'; + const complete: CliEventRecord = cli === 'cursor' + ? { ...start, subtype: 'completed', input: { path: '/tmp/final' } } + : cli === 'grok' + ? { type: 'tool_result', id: 'spill', name: 'Read', input: { path: '/tmp/final' } } + : cli === 'opencode' + ? { type: 'tool_use', part: { tool: 'Read', callID: 'spill', state: { status: 'completed', input: { path: '/tmp/final' } } } } + : { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'spill', content: '/tmp/final' }] } }; + const brokenRead = mock.method(fs, 'readFileSync', () => { throw new Error('injected spill read failure'); }); + try { + assert.doesNotThrow(() => accept(complete)); + assert.equal(brokenRead.mock.callCount(), 1, 'parser attempted the failing spill read'); + } finally { brokenRead.mock.restore(); } + const rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.traceSeq, pointer.traceSeq); + assert.equal(rows[0]?.status, 'done'); + assert.equal(rows[0]?.detail, '/tmp/final', 'accepted payload supplies terminal detail without recovered raw'); + assert.equal(ctx.fullText, 'Existing legacy answer'); + assert.equal(observed.tool.mock.callCount(), 2); + assert.equal(observed.tool.mock.calls[1]?.result?.status, 'done'); + assert.equal(observed.tool.mock.calls[1]?.result?.stepRef, observed.tool.mock.calls[0]?.result?.stepRef, + 'unreadable raw storage must not split one observed tool identity'); + }); +} diff --git a/tests/unit/print-spawn-journal.test.ts b/tests/unit/print-spawn-journal.test.ts new file mode 100644 index 000000000..47728c650 --- /dev/null +++ b/tests/unit/print-spawn-journal.test.ts @@ -0,0 +1,109 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import childProcess from 'node:child_process'; + +const home = process.env['CLI_JAW_HOME']!; +const script = join(home, 'print-provider.mjs'); +writeFileSync(script, `process.stdin.resume();process.stdin.on('end',()=>{ + const frames=[ + {type:'thread.started',thread_id:'provider-private-id'}, + {type:'item.completed',item:{type:'agent_message',text:'kept commentary',channel:'commentary'}}, + {type:'item.completed',item:{id:'tool-1',type:'command_execution',command:'echo fixture',status:'completed',exit_code:0,aggregated_output:'fixture'}}, + {type:'item.completed',item:{type:'agent_message',text:'print fixture final'}} + ];process.stderr.write('stderr-not-an-assistant-message\\n'); + for(const frame of frames)process.stdout.write(JSON.stringify(frame)+'\\n'); +});`); +let launches = 0; +let launchError = false; +test.mock.module('node:child_process', { namedExports: { ...childProcess, + spawn: (command: string, args: readonly string[], options: childProcess.SpawnOptions) => { + assert.equal(command, process.execPath, 'no provider process may launch'); + assert.deepEqual(args, [script]); launches++; + if (launchError) return childProcess.spawn(join(home, 'missing-fixture-runtime'), [], options); + return childProcess.spawn(command, [...args], options); + }, +} }); +const config = await import('../../src/core/config.ts'); +test.mock.module('../../src/core/config.js', { namedExports: { ...config, + detectCli: () => ({ available: true, path: process.execPath }), +} }); +const argsModule = await import('../../src/agent/args.ts'); +test.mock.module('../../src/agent/args.js', { namedExports: { ...argsModule, + buildArgs: () => [script], buildResumeArgs: () => [script], +} }); +const { spawnAgent, activeMainProcesses, activeProcesses } = await import('../../src/agent/spawn.ts'); +const { subscribe } = await import('../../src/core/event-bus.ts'); +const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); +const { db } = await import('../../src/core/db.ts'); +const { createChatSession, setActiveChatSession } = await import('../../src/core/chat-sessions.ts'); + +test('real print child traverses spawn, accepted parser, lifecycle and durable journal with captured identity', { timeout: 15_000 }, async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + config.settings.workingDir = home; + config.settings.cli = 'codex'; config.settings.memory.enabled = false; + config.settings.multiSession.enabled = false; config.settings.activeOverrides = {}; config.settings.fallbackOrder = []; + config.settings.perCli.codex = { model: 'fixture', effort: '' }; + mkdirSync(join(home, 'prompts'), { recursive: true }); + const seen: Array<{ event: string; data: Record }> = []; + const unsubscribe = subscribe(event => seen.push(event)); + const owner = createChatSession('captured-print-owner'); + const scope = 'captured-print-scope'; + setActiveChatSession('default'); + try { + const run = spawnAgent('fixture input', { cli: 'codex', model: 'fixture', sysPrompt: 'fixture system', + scopeKey: scope, chatSessionId: owner.id, origin: 'web', + _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true }); + const result = await run.promise; + assert.equal(result.code, 0); assert.equal(launches, 1); + const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; + assert.ok(start); + const runId = String(start.data['runId']); + const replay = readActivityPage({ runId, sessionId: owner.id, after: 0, limit: 40 })!; + assert.equal(replay.incomplete, false); + const end = replay.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, 'print fixture final'); + assert.ok(replay.events.some(e => e.kind === 'message' && e.phase === 'commentary' && e.text === 'kept commentary')); + assert.ok(!JSON.stringify(replay).includes('provider-private-id')); + assert.ok(!JSON.stringify(replay).includes('stderr-not-an-assistant-message')); + for (const packet of seen.filter(e => e.event === 'agent_output' || e.event === 'agent_tool')) { + assert.equal(packet.data['traceRunId'], runId); + assert.equal(packet.data['sessionId'], owner.id); assert.equal(packet.data['scope'], scope); + } + assert.ok(seen.some(e => e.event === 'agent_output')); + assert.ok(seen.some(e => e.event === 'agent_tool')); + assert.equal(seen.filter(e => e.event === 'agent_done').length, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE role='assistant' AND trace_run_id=?").get(runId) as { n: number }).n, 1); + assert.equal(activeMainProcesses.size, 0); assert.equal(activeProcesses.size, 0); + } finally { unsubscribe(); } +}); + +test('EG-007a: real print error reentry and close settle only once and close the journal', { timeout: 15_000 }, async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + launchError = true; + const before = launches; + const seen: Array<{ event: string; data: Record }> = []; + const unsubscribe = subscribe(event => seen.push(event)); + try { + let child: childProcess.ChildProcess | null = null; + let exits = 0; + const run = spawnAgent('fixture error', { cli: 'codex', model: 'fixture', sysPrompt: 'fixture system', origin: 'web', + _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true, + lifecycle: { onExit: () => { exits++; assert.ok(child); child.emit('error', new Error('reentrant fixture')); } }, + }); + child = run.child; + const result = await run.promise; + assert.equal(result.code, 127); assert.equal(launches, before + 1); + assert.equal(exits, 1, 'settled guard precedes any reentrant lifecycle callback'); + const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; + assert.ok(start); + const p = readActivityPage({ runId: String(start.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(p.status, 'error'); + assert.equal(p.events.at(-1)?.kind, 'turn-end'); + assert.equal(seen.filter(e => e.event === 'agent_done').length, 1); + assert.equal(activeMainProcesses.size, 0); + } finally { launchError = false; unsubscribe(); } +}); diff --git a/tests/unit/runtime-event-emitter.test.ts b/tests/unit/runtime-event-emitter.test.ts index 58783cc63..d829fa8e2 100644 --- a/tests/unit/runtime-event-emitter.test.ts +++ b/tests/unit/runtime-event-emitter.test.ts @@ -11,9 +11,12 @@ const publications: Array<{ topic: string; event: string; data: Record { + markActivityFailure: () => {}, + appendActivityBody: (input: { runId: string; eventType: string; raw: unknown }): TracePointer | null => { + const entry: TraceEventInput = { runId: input.runId, source: 'runtime', eventType: input.eventType, + raw: input.raw, preview: input.eventType }; attempts.push(structuredClone(entry)); if (appendMode === 'null') return null; if (appendMode === 'throw') throw new Error('fixture append failure'); diff --git a/tests/unit/runtime-execution-binding.test.ts b/tests/unit/runtime-execution-binding.test.ts new file mode 100644 index 000000000..c53ccff46 --- /dev/null +++ b/tests/unit/runtime-execution-binding.test.ts @@ -0,0 +1,175 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveExecutionBinding } from '../../src/orchestrator/scope.ts'; +import { settings } from '../../src/core/config.ts'; +import { createChatSession, getActiveChatSession, setActiveChatSession } from '../../src/core/chat-sessions.ts'; +import { currentSessionScope, withSessionScope } from '../../src/core/session-context.ts'; +import { orchestrateAndCollectData } from '../../src/orchestrator/collect.ts'; +import { orchestrate } from '../../src/orchestrator/pipeline.ts'; +import type { spawnAgent } from '../../src/agent/spawn.ts'; + +const originalMulti = settings['multiSession']; +const active = createChatSession('binding-active').id; +const laterActive = createChatSession('binding-later').id; +test.afterEach(() => { settings['multiSession'] = originalMulti; setActiveChatSession('default'); }); + +const pipelinePlacements = [ + { name: 'forged persisted hint', meta: { chatSessionId: 'pipeline-chat', persistedScopeId: 'jaw:slack:channel:forged' }, + expected: { scope: 'local:pipeline-chat', chatSessionId: 'pipeline-chat' } }, + { name: 'stale persisted hint', meta: { chatSessionId: 'pipeline-chat', persistedScopeId: 'local:previous-chat' }, + expected: { scope: 'local:pipeline-chat', chatSessionId: 'pipeline-chat' } }, + { name: 'remote key', meta: { chatSessionId: 'pipeline-chat', remoteKey: 'jaw:slack:channel:owned', persistedScopeId: 'stale' }, + expected: { scope: 'jaw:slack:channel:owned', chatSessionId: 'pipeline-chat' } }, + { name: 'explicit scope', meta: { scope: 'explicit-pipeline', chatSessionId: 'pipeline-chat', persistedScopeId: 'stale' }, + expected: { scope: 'explicit-pipeline', chatSessionId: 'pipeline-chat' } }, + { name: 'captured scope', meta: { persistedScopeId: 'stale' }, + captured: { scope: 'captured-pipeline', chatSessionId: 'captured-chat' }, + expected: { scope: 'captured-pipeline', chatSessionId: 'captured-chat' } }, +]; +for (const placement of pipelinePlacements) { + test(`direct pipeline uses owned placement for ${placement.name}`, { timeout: 5000 }, async t => { + settings['multiSession'] = { ...originalMulti, enabled: true }; + const network = t.mock.method(globalThis, 'fetch', async () => assert.fail('unexpected pipeline network request')); + t.mock.method(console, 'log', () => {}); + let calls = 0; + const meta = { origin: 'heartbeat', requestId: `pipeline-${placement.name}`, _skipReplayDrain: true, + ...placement.meta, + _spawnAgent: (_prompt: string, opts: NonNullable[1]>) => { + calls++; + assert.deepEqual({ scope: opts.scopeKey, chatSessionId: opts.chatSessionId }, placement.expected); + assert.deepEqual(currentSessionScope(), placement.expected); + return { child: null, promise: Promise.resolve({ text: 'answer', code: 0, traceRunId: 'pipeline-binding', + runtimeOutcome: { status: 'done', finalText: 'answer', partialText: '' } }) }; + }, + }; + const invoke = () => orchestrate('text-only fixture', meta); + if ('captured' in placement && placement.captured) await withSessionScope(placement.captured, invoke); + else { assert.equal(currentSessionScope(), undefined); await invoke(); } + assert.equal(calls, 1); + assert.equal(network.mock.callCount(), 0); + }); +} + +test('binding precedence is explicit, captured, then existing gated scope and active chat', () => { + const base = { activeChatSessionId: 'active-chat', multiSessionEnabled: true, + captured: { scope: 'captured-scope', chatSessionId: 'captured-chat' }, + persistedScopeId: 'persisted-fallback' }; + const binding = resolveExecutionBinding({ ...base, scope: 'explicit-scope', chatSessionId: 'explicit-chat' }); + assert.deepEqual(binding, { scope: 'explicit-scope', chatSessionId: 'explicit-chat' }); + assert.equal(Object.isFrozen(binding), true); + assert.deepEqual(resolveExecutionBinding(base), base.captured); + assert.deepEqual(resolveExecutionBinding({ ...base, captured: null }), { + scope: 'persisted-fallback', chatSessionId: 'active-chat', + }); + assert.deepEqual(resolveExecutionBinding({ activeChatSessionId: 'active-chat', multiSessionEnabled: true }), { + scope: 'default', chatSessionId: 'active-chat', + }); + assert.deepEqual(resolveExecutionBinding({ activeChatSessionId: 'active-chat', multiSessionEnabled: false, + persistedScopeId: 'ignored-by-gate' }), { scope: 'default', chatSessionId: 'active-chat' }); + assert.deepEqual(resolveExecutionBinding({ activeChatSessionId: 'active-chat', multiSessionEnabled: true, + chatSessionId: 'local-chat' }), { scope: 'local:local-chat', chatSessionId: 'local-chat' }); +}); + +test('binding keeps independent explicit overrides and empty-string fallback without coercion', () => { + const captured = { scope: 'captured-scope', chatSessionId: 'captured-chat' }; + const base = { captured, activeChatSessionId: 'active-chat', multiSessionEnabled: false }; + assert.deepEqual(resolveExecutionBinding({ ...base, scope: 'explicit' }), { scope: 'explicit', chatSessionId: 'captured-chat' }); + assert.deepEqual(resolveExecutionBinding({ ...base, chatSessionId: 'explicit' }), { scope: 'captured-scope', chatSessionId: 'explicit' }); + assert.deepEqual(resolveExecutionBinding({ ...base, scope: '', chatSessionId: '' }), captured); + assert.deepEqual(resolveExecutionBinding({ activeChatSessionId: '', scope: '', chatSessionId: '' }), { + scope: 'default', chatSessionId: 'default', + }); + const frozen = resolveExecutionBinding(base); + captured.scope = 'changed'; + captured.chatSessionId = 'changed'; + assert.deepEqual(frozen, { scope: 'captured-scope', chatSessionId: 'captured-chat' }); +}); + +for (const key of ['scope', 'chatSessionId'] as const) { + for (const value of [null, false, 0, 1, {}, []]) { + test(`binding rejects invalid explicit ${key}: ${JSON.stringify(value)}`, () => { + // Exercise the actual untyped orchestration ingress, not a valid typed caller. + const input = { activeChatSessionId: 'active', [key]: value }; + assert.throws(() => resolveExecutionBinding(input as Parameters[0]), TypeError); + }); + } +} + +const placements = [ + { name: 'ordinary default', enabled: false, meta: {}, expected: { scope: 'default', chatSessionId: active } }, + { name: 'ordinary multi-on', enabled: true, meta: { chatSessionId: 'local-chat' }, + expected: { scope: 'local:local-chat', chatSessionId: 'local-chat' } }, + ...[false, true].map(enabled => ({ name: `mention-watch multi=${enabled}`, enabled, + meta: { scope: 'mention-watch:jaw:slack:channel:C1:thread:123', chatSessionId: 'thread-chat' }, + expected: { scope: 'mention-watch:jaw:slack:channel:C1:thread:123', chatSessionId: 'thread-chat' } })), +]; + +for (const placement of placements) { + test(`real collector/pipeline preserves ${placement.name} across async settings wait`, async () => { + settings['multiSession'] = { ...originalMulti, enabled: placement.enabled }; + setActiveChatSession(active); + const wait = Promise.withResolvers(); + const entered = Promise.withResolvers(); + let observed: ReturnType; + let reentered: ReturnType | undefined; + const meta = { origin: 'heartbeat', requestId: `binding-${placement.name}`, ...placement.meta, + _spawnAgent: (_prompt: string, opts: NonNullable[1]>) => { + assert.equal(opts.scopeKey, placement.expected.scope); + assert.equal(opts.chatSessionId, placement.expected.chatSessionId); + observed = currentSessionScope(); + entered.resolve(); + return { child: null, promise: (async () => { + await wait.promise; + // Same arguments as the owned spawn settings-wait re-entry. + reentered = resolveExecutionBinding({ scope: opts.scopeKey, chatSessionId: opts.chatSessionId, + captured: currentSessionScope(), activeChatSessionId: getActiveChatSession(), + multiSessionEnabled: settings['multiSession']?.enabled === true }); + return { text: ' final answer\n', code: 0, traceRunId: 'binding-run', + runtimeOutcome: { status: 'done', finalText: ' final answer\n', partialText: 'private' } }; + })() }; + }, + }; + const collecting = orchestrateAndCollectData('task', meta, 'en'); + // Pipeline itself awaits replay draining before spawn: capture precedes that await too. + setActiveChatSession(laterActive); + settings['multiSession'] = { ...originalMulti, enabled: !placement.enabled }; + await entered.promise; + assert.deepEqual(observed, placement.expected); + assert.equal('_onRuntimeActivity' in meta, false, 'caller metadata is not mutated'); + meta.requestId = 'mutated-after-capture'; + meta.origin = 'slack'; + wait.resolve(); + const result = await collecting; + assert.deepEqual(reentered, placement.expected); + assert.equal(result.text, 'final answer', 'existing pipeline presentation normalization is unchanged'); + assert.equal(result.data['scope'], placement.expected.scope); + assert.equal(result.data['sessionId'], placement.expected.chatSessionId); + assert.equal('_onRuntimeActivity' in result.data, false); + }); +} + +test('invalid explicit placement also rejects at collector ingress instead of defaulting', async () => { + await assert.rejects(orchestrateAndCollectData('task', { scope: 0 }), TypeError); + await assert.rejects(orchestrateAndCollectData('task', { chatSessionId: null }), TypeError); +}); + +for (const enabled of [false, true]) { + test(`captured ALS placement reaches actual collector/pipeline with gate=${enabled}`, async () => { + settings['multiSession'] = { ...originalMulti, enabled }; + setActiveChatSession(active); + const captured = { scope: 'captured:dedicated', chatSessionId: 'captured-thread' }; + const result = await withSessionScope(captured, () => orchestrateAndCollectData('task', { + origin: 'heartbeat', requestId: 'captured-request', _skipReplayDrain: true, + _spawnAgent: (_prompt: string, opts: NonNullable[1]>) => { + assert.deepEqual(currentSessionScope(), captured); + assert.equal(opts.scopeKey, captured.scope); + assert.equal(opts.chatSessionId, captured.chatSessionId); + return { child: null, promise: Promise.resolve({ text: 'answer', code: 0, + runtimeOutcome: { status: 'done', finalText: 'answer', partialText: '' } }) }; + }, + }, 'en')); + assert.equal(result.text, 'answer'); + assert.equal(result.data['sessionId'], captured.chatSessionId); + }); +} diff --git a/tests/unit/runtime-projection-lookup.test.ts b/tests/unit/runtime-projection-lookup.test.ts new file mode 100644 index 000000000..3129872d9 --- /dev/null +++ b/tests/unit/runtime-projection-lookup.test.ts @@ -0,0 +1,37 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { RuntimeProjection } from '../../src/agent/runtime/projection.ts'; +import type { RuntimeEvent } from '../../src/shared/runtime-contract.ts'; +function fixture() { + const events: RuntimeEvent[] = []; let failing = false; + const projection = new RuntimeProjection({ runId: 'run', sessionId: 'chat', scope: 's', turnId: 't', audience: 'internal' }, + (ctx, body) => { if (failing) return null; const event = { ...ctx, version: 1 as const, seq: events.length + 1, ...body }; events.push(event); return event; }, () => {}); + return { projection, events, fail: () => { failing = true; } }; +} +test('lookup never allocates or emits and returns actual published terminal tool identity', () => { + const f = fixture(); + assert.equal(f.projection.itemId('tool', 'native-private'), null); + assert.equal(f.projection.diagnostics().items, 0); assert.equal(f.events.length, 0); + f.projection.tool('native-private', { name: 'Read', status: 'done', output: 'ok' }); + const event = f.events[0]!; assert.equal(event.kind, 'tool'); + assert.equal(f.projection.itemId('tool', 'native-private'), event.itemId); + assert.equal(event.itemId, 'item-1'); assert.equal(f.events.length, 1); + assert.ok(!JSON.stringify(f.events).includes('native-private')); + assert.equal(f.projection.itemId('message', 'native-private'), null); +}); +test('recording failure makes previous display mapping unavailable without new events', () => { + const f = fixture(); f.projection.tool('a', { status: 'running' }); + assert.ok(f.projection.itemId('tool', 'a')); + f.fail(); f.projection.text('message', 'b', 'lost', 'replace'); + const count = f.events.length; + assert.equal(f.projection.itemId('tool', 'a'), null); assert.equal(f.projection.itemId('message', 'b'), null); + assert.equal(f.events.length, count); +}); +test('capacity-rejected item never gains a fabricated lookup identity', () => { + const f = fixture(); + for (let i = 0; i < 160; i++) f.projection.tool(`id-${i}`, { status: 'done' }); + const count = f.events.length; + f.projection.tool('overflow', { status: 'running' }); + assert.equal(f.projection.itemId('tool', 'overflow'), null); assert.equal(f.events.length, count); +}); diff --git a/tests/unit/runtime-settings-race.test.ts b/tests/unit/runtime-settings-race.test.ts index a9dee00fd..187a39022 100644 --- a/tests/unit/runtime-settings-race.test.ts +++ b/tests/unit/runtime-settings-race.test.ts @@ -1,7 +1,9 @@ +import '../setup/isolated-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import childProcess from 'node:child_process'; const ROOT = process.cwd(); const GATE = path.join(ROOT, 'src/core/runtime-settings-gate.ts'); @@ -48,13 +50,54 @@ test('RSR-004: gated main spawn contributes to busy state and queue gating', () 'isAgentBusy must delegate the retry check to the queue controller'); }); -test('RSR-005: stop cancels a pending gated main spawn', () => { - // Same refactor: the cancel hook is stored on the scoped run - // (`MainRunState.cancelPending`) instead of a module-level binding. - assert.match(spawnSrc, /cancelPending\?:\s*\(reason:\s*string\)\s*=>\s*void/); - assert.match(spawnSrc, /mainRun!\.cancelPending\s*=\s*cancelThisSpawn/); - assert.match(spawnSrc, /run\?\.cancelPending\s*\?\s*\(\s*run\.cancelPending\(reason\),\s*true\s*\)/); - assert.match(spawnSrc, /if\s*\(\s*cancelled\s*\)\s*\{[\s\S]*code:\s*-1[\s\S]*\}/); +test('RSR-005: stop cancels a pending gated main spawn', { timeout: 5000 }, async t => { + const forbiddenCalls: string[] = []; + const forbidden = (name: string) => (..._args: unknown[]): never => { + forbiddenCalls.push(name); assert.fail(`cancelled gated spawn reached ${name}`); + }; + const processSeams = Object.fromEntries( + ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork'].map(name => [name, forbidden(name)]), + ); + t.mock.module('node:child_process', { + namedExports: { ...childProcess, ...processSeams }, defaultExport: { ...childProcess, ...processSeams }, + }); + const config = await import('../../src/core/config.ts'); + t.mock.module('../../src/core/config.js', { namedExports: { ...config, + settings: { ...config.settings, cli: 'codex-app', workingDir: config.JAW_HOME, projectDirs: [config.JAW_HOME], + fallbackOrder: [], multiSession: { enabled: true, maxConcurrent: 4, midRunPolicy: 'steer' } }, + detectCli: forbidden('detectCli'), detectAllCli: forbidden('detectAllCli'), + getProjectDirs: () => [config.JAW_HOME], + } }); + const { spawnAgent, killActiveAgent, activeMainProcesses, isAgentBusy } = await import('../../src/agent/spawn.ts'); + const { db } = await import('../../src/core/db.ts'); + const { clearGoalTimers } = await import('../../src/agent/lifecycle-handler.ts'); + const { beginRuntimeSettingsMutation, isRuntimeSettingsMutationInFlight } = await import('../../src/core/runtime-settings-gate.ts'); + const snapshot = () => ({ messages: db.prepare('SELECT * FROM messages ORDER BY id').all(), + buckets: db.prepare('SELECT * FROM session_buckets ORDER BY bucket').all() }); + const before = snapshot(); + t.mock.method(fs, 'writeFileSync', forbidden('writeFileSync')); + t.mock.method(fs, 'mkdirSync', forbidden('mkdirSync')); + t.mock.method(globalThis, 'fetch', forbidden('fetch')); + t.mock.method(console, 'log', () => {}); + const scopeKey = 'settings-gated-cancel', finishMutation = beginRuntimeSettingsMutation(); + try { + const run = spawnAgent('pending user turn', { cli: 'codex-app', scopeKey, chatSessionId: 'settings-gated-chat', + sysPrompt: '', _skipHistory: true }); + assert.equal(run.child, null); assert.equal(isAgentBusy(scopeKey), true); + assert.equal(activeMainProcesses.get(scopeKey)?.starting, true); + let settled = false; + void run.promise.then(() => { settled = true; }, () => { settled = true; }); + await Promise.resolve(); + assert.equal(settled, false, 'the actual spawn remains behind the open settings gate'); + assert.equal(killActiveAgent(scopeKey, 'user'), true); + assert.equal(activeMainProcesses.has(scopeKey), false); + finishMutation(); + assert.deepEqual(await run.promise, { text: '⏹️ [user]', code: -1 }); + assert.equal(isRuntimeSettingsMutationInFlight(), false); + assert.equal(isAgentBusy(scopeKey), false); + assert.deepEqual(snapshot(), before, 'cancellation must not persist a resumed user turn'); + assert.deepEqual(forbiddenCalls, [], 'no detection, provider process, prompt preparation or network'); + } finally { finishMutation(); activeMainProcesses.delete(scopeKey); clearGoalTimers(); } }); test('RSR-006: settings gate only delays direct main user spawns', () => { diff --git a/tests/unit/runtime-tool-terminal-enrichment.test.ts b/tests/unit/runtime-tool-terminal-enrichment.test.ts new file mode 100644 index 000000000..58a557524 --- /dev/null +++ b/tests/unit/runtime-tool-terminal-enrichment.test.ts @@ -0,0 +1,63 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { RuntimeProjection } from '../../src/agent/runtime/projection.ts'; +import type { RuntimeEvent } from '../../src/shared/runtime-contract.ts'; + +function fixture() { + const events: RuntimeEvent[] = []; + const projection = new RuntimeProjection({ runId: 'r', sessionId: 's', scope: 'scope', turnId: 't', audience: 'internal' }, + (context, body) => { const event = { ...context, version: 1 as const, seq: events.length * 3 + 2, ...body }; events.push(event); return event; }, () => {}); + projection.start('claude'); + return { projection, events, tool: () => events.filter(e => e.kind === 'tool').at(-1)! }; +} +test('late start enriches missing terminal metadata without reopening or changing result', () => { + const f = fixture(); + f.projection.tool('id', { status: 'error', output: 'result', detail: 'failure' }); + const id = f.tool().itemId; + f.projection.tool('id', { name: 'Read', input: '{"path":"a"}', inputStructured: true, status: 'running', output: 'wrong', detail: 'wrong' }); + assert.equal(f.tool().name, 'Read'); assert.equal(f.tool().input, '{"path":"a"}'); + assert.equal(f.tool().itemId, id); assert.equal(f.tool().status, 'error'); + assert.equal(f.tool().output, 'result'); assert.equal(f.tool().detail, 'failure'); + const count = f.events.length; + f.projection.tool('id', { name: 'Overwrite', input: 'overwrite', status: 'done', output: 'overwrite' }); + assert.equal(f.events.length, count); +}); +test('late input uses existing structured redaction and malformed content withholding', () => { + const f = fixture(); + f.projection.tool('a', { status: 'done' }); + f.projection.tool('a', { name: 'tool', input: '{"api_key":"secret-value","path":"safe"}', inputStructured: true }); + assert.ok(!JSON.stringify(f.events).includes('secret-value')); + f.projection.tool('b', { status: 'stopped' }); + f.projection.tool('b', { input: '{"token":"secret-fragment', inputStructured: true }); + assert.ok(!JSON.stringify(f.events).includes('secret-fragment')); + assert.equal(f.tool().status, 'stopped'); +}); +test('terminal enrichment respects preview budgets and closed projection boundary', () => { + const f = fixture(); + f.projection.tool('id', { status: 'done', output: 'ok' }); + f.projection.tool('id', { name: 'N'.repeat(200), input: 'x'.repeat(100_000) }); + assert.ok(f.tool().name.length <= 120); assert.ok((f.tool().input?.length ?? 0) <= 3000); + assert.ok(f.projection.diagnostics().withinSnapshotCap); + f.projection.close({ kind: 'turn-end', status: 'done', finalText: null }); + const count = f.events.length; f.projection.tool('id', { name: 'late' }); + assert.equal(f.events.length, count); +}); +test('known terminal metadata and ordinary start-complete sequence remain immutable', () => { + const f = fixture(); + f.projection.tool('id', { name: 'command', input: 'pwd', status: 'running' }); + f.projection.tool('id', { status: 'done', output: '/tmp' }); + const count = f.events.length; + f.projection.tool('id', { name: 'other', input: 'rm', status: 'running', output: 'overwrite' }); + assert.equal(f.events.length, count); assert.equal(f.tool().input, 'pwd'); assert.equal(f.tool().output, '/tmp'); +}); +test('saturated aggregate budget rejects enrichment instead of erasing terminal output', () => { + const f = fixture(); + f.projection.tool('target', { status: 'done', output: 'R'.repeat(1000), detail: 'D'.repeat(1000) }); + const original = f.tool(); + for (let i = 0; i < 7; i++) f.projection.tool(`fill-${i}`, { status: 'done', output: 'F'.repeat(3000) }); + assert.ok(f.projection.diagnostics().previewChars > 21_000); + f.projection.tool('target', { name: 'Read', input: 'I'.repeat(3000), status: 'running' }); + const target = f.events.filter(e => e.kind === 'tool' && e.itemId === original.itemId).at(-1)!; + assert.equal(target.output, original.output); assert.equal(target.detail, original.detail); assert.equal(target.status, 'done'); +}); diff --git a/tests/unit/runtime-transport-guard.test.ts b/tests/unit/runtime-transport-guard.test.ts index 297bc0e12..d90ee2094 100644 --- a/tests/unit/runtime-transport-guard.test.ts +++ b/tests/unit/runtime-transport-guard.test.ts @@ -39,7 +39,8 @@ let simulatedMainAdapter = false; const workerEligibility = test.mock.fn((cli: string) => selection.isNativeWorkerImplemented(cli)); test.mock.module('../../src/agent/runtime/selection.js', { namedExports: { ...selection, - isNativeAdapterImplemented: (cli: string) => simulatedMainAdapter || selection.isNativeAdapterImplemented(cli), + isNativeAdapterImplemented: (cli: string) => simulatedMainAdapter + || (!selection.isSwitchableNativeCli(cli) && selection.isNativeAdapterImplemented(cli)), isNativeWorkerImplemented: workerEligibility, } }); const database = await import('../../src/core/db.ts'); @@ -55,7 +56,7 @@ test.mock.module('../../src/core/main-session.js', { namedExports: { // Do not import builder to spread its exports: builder imports spawn eagerly, // which would bind argv/queue seams before this fixture has installed them. test.mock.module('../../src/prompt/builder.js', { namedExports: { - regenerateB: () => {}, getSystemPrompt: () => 'fixture system prompt', + regenerateB: forbidden('regenerateB'), getSystemPrompt: () => 'fixture system prompt', A1_PATH: `${home}/prompts/A-1.md`, A2_PATH: `${home}/prompts/A-2.md`, HEARTBEAT_PATH: `${home}/prompts/HEARTBEAT.md`, ...Object.fromEntries([ diff --git a/tests/unit/runtime-transport-selection.test.ts b/tests/unit/runtime-transport-selection.test.ts index 03a07166e..cc841db7b 100644 --- a/tests/unit/runtime-transport-selection.test.ts +++ b/tests/unit/runtime-transport-selection.test.ts @@ -27,7 +27,7 @@ test('compiled main and worker support are explicit, not binary/auth claims', () for (const cli of ['cursor', 'grok', 'claude']) { assert.equal(isSwitchableNativeCli(cli), true); assert.deepEqual(runtimeSelectionStatus(cli, 'native'), { - transport: 'native', nativeAdapterImplemented: false, nativeWorkerImplemented: false, + transport: 'native', nativeAdapterImplemented: cli === 'cursor', nativeWorkerImplemented: false, }); } for (const cli of ['codex-app', 'pi']) { diff --git a/tests/unit/runtime-transport-status.test.ts b/tests/unit/runtime-transport-status.test.ts index 1fd406083..969d16750 100644 --- a/tests/unit/runtime-transport-status.test.ts +++ b/tests/unit/runtime-transport-status.test.ts @@ -87,7 +87,7 @@ test('actual status route adds transport support without rewriting cached readin assert.deepEqual(Object.keys(payload), Object.keys(cached)); for (const cli of ['cursor', 'grok', 'claude']) { assert.deepEqual(payload[cli]?.runtimeSelection, { - transport: 'native', nativeAdapterImplemented: false, nativeWorkerImplemented: false, + transport: 'native', nativeAdapterImplemented: cli === 'cursor', nativeWorkerImplemented: false, }, cli); } for (const cli of ['codex-app', 'pi']) { @@ -111,7 +111,7 @@ for (const [query, forced] of [['?force=1', true], ['?force=true', true], ['?for assert.equal(getForced.mock.callCount(), forced ? 1 : 0); assert.equal(getCached.mock.callCount(), forced ? 0 : 1); assert.deepEqual(payload['cursor']?.runtimeSelection, { - transport: 'native', nativeAdapterImplemented: false, nativeWorkerImplemented: false, + transport: 'native', nativeAdapterImplemented: true, nativeWorkerImplemented: false, }); assert.deepEqual(cached, originalCache); assert.equal(forbidden.mock.callCount(), 0, 'the route adds no capability probe'); @@ -123,7 +123,7 @@ test('transport diagnostics follow settings changes without refreshing or mutati try { const payload = await readStatus(); assert.deepEqual(payload['cursor']?.runtimeSelection, { - transport: 'print', nativeAdapterImplemented: false, nativeWorkerImplemented: false, + transport: 'print', nativeAdapterImplemented: true, nativeWorkerImplemented: false, }); assert.deepEqual(cached, originalCache); assert.equal(getCached.mock.callCount(), 1); diff --git a/tests/unit/slack-slash-commands.test.ts b/tests/unit/slack-slash-commands.test.ts index 845a9e45e..b85c5d6f0 100644 --- a/tests/unit/slack-slash-commands.test.ts +++ b/tests/unit/slack-slash-commands.test.ts @@ -18,23 +18,18 @@ const state: { executeResult: { text?: string; steerPrompt?: string } | null; onExecute?: (parsed: unknown, ctx: { interface?: string }) => void; onOrchestrate?: (prompt: string, meta: Record) => void; - multiSessionEnabled?: boolean; scopeOverride?: string; scopeCalls: Array<{ sessionId: string; remoteKey?: string; gateEnabled?: boolean }>; } = { posted: [], parsedSeen: [], executeResult: { text: 'command output' }, scopeCalls: [] }; +const actualScope = await import('../../src/orchestrator/scope.ts'); mock.module('../../src/orchestrator/scope.ts', { namedExports: { - LOCAL_SESSION_SCOPE_ACTIVATION: true, - LOCAL_SESSION_SCOPE_PREFIX: 'local:', - isRemoteBindingScope: (scope?: string | null) => typeof scope === 'string' && scope.startsWith('jaw:'), - channelGateOn: () => true, - resolveOrcScope: () => 'default', + ...actualScope, scopeForChatSession: (sessionId: string, remoteKey?: string, gateEnabled?: boolean) => { state.scopeCalls.push({ sessionId, ...(remoteKey ? { remoteKey } : {}), ...(gateEnabled === undefined ? {} : { gateEnabled }) }); if (state.scopeOverride) return state.scopeOverride; - if (!gateEnabled || sessionId === 'default') return 'default'; - return remoteKey || `local:${sessionId}`; + return actualScope.scopeForChatSession(sessionId, remoteKey, gateEnabled); }, }, }); @@ -75,11 +70,17 @@ mock.module('../../src/orchestrator/collect.ts', { }, }); +test.beforeEach(t => { + const network = t.mock.method(globalThis, 'fetch', async () => assert.fail('unexpected real network request')); + t.after(() => assert.equal(network.mock.callCount(), 0, 'Slack routing must use only the fake transports')); +}); + async function loadHandler(options: { executeResult?: { text?: string; steerPrompt?: string } | null; channelIds?: string[]; onExecute?: (parsed: unknown, ctx: { interface?: string }) => void; onOrchestrate?: (prompt: string, meta: Record) => void; + multiSessionEnabled?: boolean; scopeOverride?: string; } = {}) { state.posted = []; @@ -111,8 +112,7 @@ test('a slash command reaches executeCommand with a slack context', async () => assert.deepEqual(parsedSeen, ['/status'], 'command and args must be joined for the shared parser'); assert.equal(seenInterface, 'slack', 'the command context must identify slack'); - assert.equal(posted.length, 1); - assert.equal(posted[0]!.text, 'command output'); + assert.deepEqual(posted, [{ channel: 'C1', text: 'command output' }]); }); test('slash command arguments are joined onto the command name', async () => { @@ -135,7 +135,7 @@ test('a steerPrompt result orchestrates with the slack target preserved', async const target = captured['target'] as { channel?: string; targetId?: string }; assert.equal(target.channel, 'slack'); assert.equal(target.targetId, 'C7', 'the steer path must keep the originating conversation'); - assert.deepEqual(posted.map(p => p.text), ['redirecting', 'steered reply']); + assert.deepEqual(posted, [{ channel: 'C7', text: 'redirecting' }, { channel: 'C7', text: 'steered reply' }]); }); test('multi-session slash and steer keep C1 scope isolated from C2 and the global pointer', async () => { @@ -193,23 +193,25 @@ test('a slash command from a non-allowlisted channel never executes', async () = // Without this gate a slash command would bypass the allowlist that the // ordinary message path enforces. let executed = false; - const { handleSlackSlashCommand, posted } = await loadHandler({ + const { handleSlackSlashCommand, posted, parsedSeen } = await loadHandler({ channelIds: ['C123'], onExecute: () => { executed = true; }, }); await handleSlackSlashCommand({ command: '/status', text: '', channel_id: 'C999' }); assert.equal(executed, false, 'allowlist bypassed via slash command'); + assert.deepEqual(parsedSeen, [], 'a blocked command must never reach the parser'); assert.equal(posted.length, 0, 'a blocked command must stay silent'); }); test('a slash command from a DM proceeds despite a non-matching allowlist', async () => { let executed = false; - const { handleSlackSlashCommand } = await loadHandler({ + const { handleSlackSlashCommand, posted } = await loadHandler({ channelIds: ['C123'], onExecute: () => { executed = true; }, }); await handleSlackSlashCommand({ command: '/status', text: '', channel_id: 'D555' }); assert.equal(executed, true, 'DMs are self-authorizing and must not be blocked'); + assert.deepEqual(posted, [{ channel: 'D555', text: 'command output' }]); }); test('an empty result still posts something rather than going silent', async () => { @@ -219,7 +221,8 @@ test('an empty result still posts something rather than going silent', async () }); test('a payload missing its command id is ignored', async () => { - const { handleSlackSlashCommand, posted } = await loadHandler(); + const { handleSlackSlashCommand, posted, parsedSeen } = await loadHandler(); await handleSlackSlashCommand({ text: 'orphan', channel_id: 'C1' }); assert.equal(posted.length, 0); + assert.deepEqual(parsedSeen, []); }); diff --git a/tests/unit/steer-flow.test.ts b/tests/unit/steer-flow.test.ts index a979b93d8..5aba47640 100644 --- a/tests/unit/steer-flow.test.ts +++ b/tests/unit/steer-flow.test.ts @@ -141,26 +141,8 @@ test('SF-004: buildHistoryBlock filters stale worklog continue artifacts', () => assert.ok(src.includes('Continuing from previous worklog.'), 'old English locale marker should be filtered'); }); -test('SF-004b: resume argv CLIs keep enriched promptForArgs for agy and compact handoff', () => { - const src = fs.readFileSync(join(__dirname, '../../src/agent/spawn.ts'), 'utf8'); - const promptForArgsIdx = src.indexOf('let promptForArgs ='); - const buildCurrentArgsIdx = src.indexOf('const buildCurrentArgs ='); - const agyBootstrapIdx = src.indexOf('agyBootstrap = buildAgyBootstrapEnvelope'); - const agyPromptAssignIdx = src.indexOf('promptForArgs = agyBootstrap.prompt'); - - assert.ok(promptForArgsIdx > 0, 'spawn should compute promptForArgs before argv construction'); - assert.ok(buildCurrentArgsIdx > promptForArgsIdx, 'argv builder should be defined after promptForArgs'); - assert.ok(agyBootstrapIdx > buildCurrentArgsIdx, 'AGY bootstrap should be built after argv builder setup'); - assert.ok(agyPromptAssignIdx > agyBootstrapIdx, 'AGY promptForArgs should be replaced with bootstrap prompt before args are built'); - assert.ok( - src.includes('buildResumeArgs(cli, runtimeModel, effort, sid, promptForArgs, permissions, options)'), - 'resume argv CLIs must receive promptForArgs, not raw prompt', - ); - assert.ok( - src.includes("if (cli !== 'agy') args = buildCurrentArgs(argOptions);"), - 'AGY must defer argv construction until after bootstrap prompt replacement', - ); -}); +// SF-004b lives in native-legacy-prompt-routing.test.ts: real spawn call-site +// checks for prepared AGY fresh/resume argv and compact handoff, not source text. test('SF-004c: agy passes configured prompt order to the bootstrap envelope', () => { const src = fs.readFileSync(join(__dirname, '../../src/agent/spawn.ts'), 'utf8'); diff --git a/tests/unit/steer-input-guard.test.ts b/tests/unit/steer-input-guard.test.ts new file mode 100644 index 000000000..6e6dcd312 --- /dev/null +++ b/tests/unit/steer-input-guard.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { beginSteerInput, cancelSteerInputs, cancelAllSteerInputs } from '../../src/agent/steer-input-guard.ts'; + +test.afterEach(cancelAllSteerInputs); +test('Stop invalidates all pending inputs in its scope, not another scope', () => { + const a = beginSteerInput('a'), b = beginSteerInput('a'), other = beginSteerInput('b'); + assert.equal(a.isCancelled(), false); cancelSteerInputs('a'); + assert.equal(a.isCancelled(), true); assert.equal(b.isCancelled(), true); assert.equal(other.isCancelled(), false); + a.release(); b.release(); other.release(); +}); +test('Stop after the main run disappears still invalidates a pending input', () => { + const input = beginSteerInput('finished-run'); + // Guard ownership does not depend on the lifetime of activeMainProcesses. + cancelSteerInputs('finished-run'); assert.equal(input.isCancelled(), true); input.release(); +}); +test('fresh input after Stop is admitted; old release cannot remove its cancellation set', () => { + const old = beginSteerInput('a'); cancelSteerInputs('a'); + const next = beginSteerInput('a'); old.release(); old.release(); + assert.equal(next.isCancelled(), false); + cancelSteerInputs('a'); assert.equal(next.isCancelled(), true); next.release(); +}); +test('normal release is idempotent and leaves other pending inputs cancellable', () => { + const released = beginSteerInput('a'), pending = beginSteerInput('a'); + released.release(); released.release(); assert.equal(released.isCancelled(), true); + assert.equal(pending.isCancelled(), false); cancelSteerInputs('a'); assert.equal(pending.isCancelled(), true); pending.release(); +}); +test('aggregate Stop invalidates pending scopes and does not block later new input', () => { + const a = beginSteerInput('a'), b = beginSteerInput('b'); cancelAllSteerInputs(); + assert.equal(a.isCancelled(), true); assert.equal(b.isCancelled(), true); + const next = beginSteerInput('a'); a.release(); b.release(); assert.equal(next.isCancelled(), false); + cancelAllSteerInputs(); assert.equal(next.isCancelled(), true); next.release(); +}); diff --git a/tests/unit/steer-input-handoff.test.ts b/tests/unit/steer-input-handoff.test.ts new file mode 100644 index 000000000..963de12d7 --- /dev/null +++ b/tests/unit/steer-input-handoff.test.ts @@ -0,0 +1,65 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cancelSteerInputs, cancelAllSteerInputs } from '../../src/agent/steer-input-guard.ts'; +import { settings } from '../../src/core/config.ts'; +import { subscribe } from '../../src/core/event-bus.ts'; +import { settleOnce, settleAllPending } from '../../src/orchestrator/request-registry.ts'; + +let mode: 'scoped-stop' | 'aggregate-stop' | 'natural' | 'dispatched' | 'new-run' | 'failure' = 'natural'; +const queued: unknown[][] = []; +const steered: unknown[][] = []; +test.mock.module('../../src/agent/spawn.js', { namedExports: { + isAgentBusy: () => true, messageQueue: [], purgeQueueOnStop: () => {}, + canSteerAgent: () => assert.fail('gateway must delegate capability choice, not pre-queue'), + killActiveAgent: () => assert.fail('gateway must delegate steer interruption to steerAgent'), + enqueueMessage: (...args: unknown[]) => { queued.push(args); return 'fixture-queued'; }, + steerAgent: (scope: string, _text: string, _origin: string, meta: { requestId: string }) => { + steered.push([scope, _text, _origin, meta]); + if (mode === 'failure') return Promise.reject(new Error('fixture steer failure')); + if (mode === 'dispatched') settleOnce(meta.requestId, 'steered'); + const result = Promise.resolve(mode === 'dispatched' ? 'steered' : mode === 'new-run' ? 'new-run' : 'fallback-queue'); + // Producer has already returned its outcome. Stop wins before the consumer's queued continuation. + if (mode === 'scoped-stop' || mode === 'dispatched') queueMicrotask(() => cancelSteerInputs(scope)); + if (mode === 'aggregate-stop') queueMicrotask(cancelAllSteerInputs); + return result; + }, +} }); +test.mock.module('../../src/orchestrator/pipeline.js', { namedExports: { + orchestrate: () => assert.fail('handoff must not start inference'), + orchestrateContinue: () => assert.fail('unexpected continue'), + orchestrateReset: () => assert.fail('unexpected reset'), + isContinueIntent: () => false, isResetIntent: () => false, +} }); +const { submitMessage, __resetSubmitDedupForTest } = await import('../../src/orchestrator/gateway.ts'); +test.beforeEach(t => { + queued.length = 0; steered.length = 0; cancelAllSteerInputs(); __resetSubmitDedupForTest(); + settings.multiSession = { enabled: true, maxConcurrent: 4, midRunPolicy: 'steer' }; + t.mock.method(globalThis, 'fetch', async () => assert.fail('unexpected network')); + t.mock.method(console, 'error', () => {}); +}); +test.afterEach(() => { cancelAllSteerInputs(); settleAllPending('dropped', 'test-cleanup'); }); + +for (const scenario of ['scoped-stop', 'aggregate-stop', 'natural', 'dispatched', 'new-run', 'failure'] as const) { + test(`gateway's final enqueue boundary handles ${scenario} after producer resolution`, async () => { + mode = scenario; + const events: Array<{ event: string; data: Record }> = []; + const off = subscribe(event => events.push(event)); + try { + const result = submitMessage('handoff-' + scenario, { origin: 'web', scope: 'handoff-scope', chatSessionId: 'handoff-chat' }); + assert.equal(result.action, 'started'); assert.ok(result.requestId); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(steered.length, 1, 'every steer policy call delegates exactly once'); + assert.deepEqual(steered[0]!.slice(0, 3), ['handoff-scope', 'handoff-' + scenario, 'web']); + const receipts = events.filter(event => event.event === 'request_settled' && event.data['requestId'] === result.requestId); + if (scenario === 'natural') { + assert.equal(queued.length, 1); assert.equal(receipts.length, 0); + } else if (scenario === 'new-run') { + assert.equal(queued.length, 0); assert.equal(receipts.length, 0, 'the actual new run owns its later terminal'); + } else { + assert.equal(queued.length, 0); assert.equal(receipts.length, 1); + assert.equal(receipts[0]!.data['outcome'], scenario === 'dispatched' ? 'steered' : scenario === 'failure' ? 'failed' : 'cancelled'); + } + } finally { off(); } + }); +} diff --git a/tests/unit/submit-message.test.ts b/tests/unit/submit-message.test.ts index ff7b51fac..864425e56 100644 --- a/tests/unit/submit-message.test.ts +++ b/tests/unit/submit-message.test.ts @@ -182,21 +182,8 @@ test('SM-015: mid-run policy precedence is request, session, then global default assert.ok(requestIdx >= 0 && sessionIdx > requestIdx && globalIdx > sessionIdx); }); -test('SM-016: steer policy never queues for missing capability — steerAgent owns in-band vs kill-steer', () => { - const policy = gatewaySrc.slice( - gatewaySrc.indexOf('function applyMidRunPolicy'), - gatewaySrc.indexOf('// ── 5s dedup window'), - ); - const steer = policy.slice(policy.indexOf("if (policy === 'steer')"), policy.indexOf("if (policy === 'collect')")); - // The steer policy's promise is that the message steers the agent: in-band - // where supported, kill-steer (with salvage) everywhere else. A capability - // gate that silently queues would break that promise; queueing lives in - // the followup/collect policies and in the in-band failure outcome handler. - assert.ok(!steer.includes('canSteerAgent'), 'no capability queue-gate in the steer branch'); - assert.ok(steer.includes('steerAgent(ctx.scopeKey')); - assert.ok(!steer.includes('killActiveAgent'), 'kill stays inside steerAgent, not the gateway'); - assert.ok(steer.includes("outcome === 'fallback-queue'"), 'in-band failure still lands in queue'); -}); +// SM-016 is executed in steer-input-handoff.test.ts: actual gateway delegation, +// forbidden capability/kill seams, new-run without queue, and real fallback enqueue. test('SM-017: collect and interrupt remain scoped queue operations', () => { const policy = gatewaySrc.slice( diff --git a/tests/unit/tool-log-memory-boundaries.test.ts b/tests/unit/tool-log-memory-boundaries.test.ts index d0844fdbb..481273f3a 100644 --- a/tests/unit/tool-log-memory-boundaries.test.ts +++ b/tests/unit/tool-log-memory-boundaries.test.ts @@ -7,42 +7,9 @@ function src(path: string): string { return readFileSync(join(process.cwd(), path), 'utf8'); } -test('backend agent_done DB and broadcast boundaries use sanitized tool logs', () => { - const source = src('src/agent/lifecycle-handler.ts'); - - // Persist unions ctx.toolLog (boss) + liveRun.toolLog (worker mirrors) by stepRef - // since the tool-card hydration fix (devlog 260620 R1) — the old pick-one ternary - // discarded the array that held the worker mirrors. - assert.ok(source.includes('sanitizeToolLogForDurableStorage(unionToolLog)')); - assert.ok(source.includes('for (const t of liveRun.toolLog) pushUnionTool(t)'), 'persist must union ctx + liveRun'); - assert.ok(!source.includes('liveRun.toolLog.length > ctx.toolLog.length ? liveRun.toolLog : ctx.toolLog'), 'pick-one ternary must be gone'); - assert.ok(source.includes('serializeSanitizedToolLog(sanitizedToolLog)')); - // runTag(ctx) rides first since the replay-idempotency patch (260612 audit 08). - // The text expression grew a presentation-only suffix when a watchdog-killed - // turn started saying so (#405), so this matches the ORDER and the sanitized - // tool log rather than the literal text argument. - assert.match( - source, - /broadcast\('agent_done', \{ \.\.\.runTag\(ctx\), text: [^,]+, toolLog: sanitizedToolLog/, - ); -}); - -test('message and orchestrate snapshot API boundaries sanitize before res.json', () => { - // /api/messages lives in routes/messages.ts since the Phase 2 extraction (devlog 260609, 20). - const server = src('src/routes/messages.ts'); - const orchestrate = src('src/routes/orchestrate.ts'); - - // /api/messages routes tool_log through resolveToolLog (Option D, devlog 260620 P3), - // which sanitizes internally: blob → sanitizeSerializedToolLog, trace → serializeSanitizedToolLog. - assert.ok(server.includes('resolveToolLog(row["id"], row["tool_log"]'), 'main read resolves tool_log via Option D boundary'); - assert.ok(server.includes('return sanitizeSerializedToolLog(blobToolLog)'), 'resolveToolLog blob fallback still sanitizes'); - assert.ok(orchestrate.includes('function getSafeLiveRun(scope: string)')); - // WP4 (devlog 260703 doc 12): the RAM log is sanitized on entry, and the - // trace-hydration fallback re-sanitizes the merged rebuild before res.json. - assert.ok(orchestrate.includes('let toolLog = sanitizeToolLogForDurableStorage(liveRun.toolLog)')); - assert.ok(orchestrate.includes('toolLog = sanitizeToolLogForDurableStorage([...boss, ...mirrors])')); - assert.ok(orchestrate.includes('activeRun: getSafeLiveRun(scope)')); -}); +// Backend persistence/broadcast and HTTP boundaries now run behavioral checks in +// print-activity-lifecycle.test.ts and live-run-trace-hydration.test.ts. Source-shape +// assertions here could not verify those boundaries and broke on equivalent refactors. test('frontend history, cache, active-run, and virtual item paths use bounded tool logs', () => { const ui = src('public/js/ui.ts'); diff --git a/tests/unit/trace-drawer-runtime.test.ts b/tests/unit/trace-drawer-runtime.test.ts index cf68e96a3..0df020abb 100644 --- a/tests/unit/trace-drawer-runtime.test.ts +++ b/tests/unit/trace-drawer-runtime.test.ts @@ -53,7 +53,7 @@ test.afterEach(() => { resetWebUiDom(); }); -test('openTraceDrawer loads the page containing the clicked seq and selects it', async () => { +test('openTraceDrawer selects sparse seq directly without treating it as a row offset', async () => { setupWebUiDom(); installScrollIntoView(); const calls: string[] = []; @@ -74,7 +74,7 @@ test('openTraceDrawer loads the page containing the clicked seq and selects it', startedAt: 1, }); } - if (url === '/api/traces/tr_run/events?offset=80&limit=80') { + if (url === '/api/traces/tr_run/events?offset=0&limit=80') { return apiData({ total: 145, events: [ @@ -100,14 +100,108 @@ test('openTraceDrawer loads the page containing the clicked seq and selects it', await openTraceDrawer('tr_run', 143); await nextTick(); - assert.ok(calls.includes('/api/traces/tr_run/events?offset=80&limit=80')); - assert.equal(calls.includes('/api/traces/tr_run/events?offset=0&limit=80'), false); + assert.ok(calls.includes('/api/traces/tr_run/events?offset=0&limit=80')); + assert.equal(calls.includes('/api/traces/tr_run/events?offset=80&limit=80'), false); assert.equal(document.getElementById('traceEventRaw')?.textContent, 'RAW-143'); const selected = document.querySelector('.trace-event-row[aria-current="true"]'); assert.equal(selected?.dataset['seq'], '143'); assert.equal(selected?.dataset['runId'], 'tr_run'); }); +test('summary, page and detail retain the session captured before an awaited open', async () => { + setupWebUiDom(); installScrollIntoView(); + let sessionId = 'chat-old'; + const summary = deferredResponse(); + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); calls.push(url); + if (url === '/api/auth/token') return jsonResponse({token:''}); + if (url === '/api/traces/tr_owned?session=chat-old') return summary.promise; + if (url === '/api/traces/tr_owned/events?offset=0&limit=80&session=chat-old') { + return apiData({total:1,events:[{seq:900,source:'runtime',eventType:'tool',preview:'safe'}]}); + } + if (url === '/api/traces/tr_owned/events/900?session=chat-old') return apiData({runId:'tr_owned',seq:900,raw:'OWNED'}); + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + const {openTraceDrawer} = await import('../../public/js/features/trace-drawer.ts'); + const pending = openTraceDrawer('tr_owned',900,sessionId); + sessionId = 'chat-new'; + summary.resolve(apiData({id:'tr_owned',cli:'pi',model:'test',agentLabel:'agent',eventCount:1,byteCount:1,startedAt:1,rawRetentionStatus:'available',status:'done'})); + await pending; await nextTick(); + assert.equal(document.getElementById('traceEventRaw')?.textContent,'OWNED'); + assert.equal(calls.filter(url=>url.startsWith('/api/traces/')).length,3); + assert.ok(calls.filter(url=>url.startsWith('/api/traces/')).every(url=>url.includes('session=chat-old'))); + assert.equal(sessionId,'chat-new'); +}); + +test('closing the drawer cancels its read and ignores a late result', async () => { + setupWebUiDom(); installScrollIntoView(); + const response = deferredResponse(); + let signal: AbortSignal | null | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === '/api/auth/token') return jsonResponse({token:''}); + signal = init?.signal; + return response.promise; + }) as typeof fetch; + const drawer = await import('../../public/js/features/trace-drawer.ts'); + const pending = drawer.openTraceDrawer('tr_closed',undefined,'chat'); + await nextTick(); + document.querySelector('.trace-drawer-close')!.click(); + assert.equal(signal?.aborted,true); + response.resolve(apiData({id:'tr_closed',cli:'pi',eventCount:1,status:'done'})); + await pending; + assert.equal(document.querySelector('#traceDrawerOverlay.open'),null); + assert.equal(document.querySelector('.trace-event-row'),null); +}); + +test('the existing process Trace control resolves a server-owned default session before opening', async () => { + setupWebUiDom(); installScrollIntoView(); + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); calls.push(url); + if (url === '/api/auth/token') return jsonResponse({token:''}); + if (url === '/api/orchestrate/snapshot') return apiData({activityIdentity:{sessionId:'server-active',scope:'remote:scope'}}); + if (url === '/api/traces/tr_clicked?session=server-active') return apiData({id:'tr_clicked',cli:'pi',model:'test',agentLabel:'agent',eventCount:0,byteCount:0,startedAt:1,rawRetentionStatus:'available',status:'done'}); + if (url.includes('/events?offset=0&limit=80&session=server-active')) return apiData({total:0,events:[]}); + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + const {bindProcessBlockInteractions} = await import('../../public/js/features/process-block.ts'); + const root = document.createElement('div'); + root.innerHTML = ''; + document.body.append(root); bindProcessBlockInteractions(root); + root.querySelector('button')!.click(); + // Lazy import then JSON body consumption complete before this event-loop boundary. + await nextTick(); await nextTick(); + assert.ok(calls.includes('/api/traces/tr_clicked?session=server-active')); + assert.ok(calls.includes('/api/traces/tr_clicked/events?offset=0&limit=80&session=server-active')); +}); + +test('a late snapshot from earlier Trace click cannot reopen it over a newer click', async () => { + setupWebUiDom(); installScrollIntoView(); + const snapshots = [deferredResponse(),deferredResponse()]; + const calls: string[] = []; let index = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); calls.push(url); + if (url === '/api/auth/token') return jsonResponse({token:''}); + if (url === '/api/orchestrate/snapshot') return snapshots[index++]!.promise; + if (url === '/api/traces/tr_second?session=chat') return apiData({id:'tr_second',cli:'pi',model:'test',agentLabel:'agent',eventCount:0,byteCount:0,startedAt:1,rawRetentionStatus:'available',status:'done'}); + if (url.includes('/tr_second/events?')) return apiData({total:0,events:[]}); + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + const {bindProcessBlockInteractions} = await import('../../public/js/features/process-block.ts'); + const root = document.createElement('div'); + root.innerHTML = ''; + document.body.append(root); bindProcessBlockInteractions(root); + const buttons = root.querySelectorAll('button'); + buttons[0]!.click(); buttons[1]!.click(); await nextTick(); + snapshots[1]!.resolve(apiData({activityIdentity:{sessionId:'chat',scope:'default'}})); + await nextTick(); await nextTick(); + snapshots[0]!.resolve(apiData({activityIdentity:{sessionId:'chat',scope:'default'}})); + await nextTick(); await nextTick(); + assert.match(document.getElementById('traceDrawerMeta')!.textContent!,/tr_second/); + assert.equal(calls.some(url=>url.includes('/api/traces/tr_first')),false); +}); + test('stale trace open responses cannot overwrite the newer clicked trace', async () => { setupWebUiDom(); installScrollIntoView(); diff --git a/tests/unit/trace-identity.test.ts b/tests/unit/trace-identity.test.ts new file mode 100644 index 000000000..75fc51c57 --- /dev/null +++ b/tests/unit/trace-identity.test.ts @@ -0,0 +1,14 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { db } from '../../src/core/db.js'; +import { createTraceId, startTraceRun } from '../../src/trace/store.js'; + +test('exported trace identity allocator keeps the existing format and needs no available database', () => { + const admitted = startTraceRun({ cli: 'fixture' }); + db.close(); + const fallback = createTraceId(); + assert.match(admitted, /^tr_[a-f0-9]{32}$/); + assert.match(fallback, /^tr_[a-f0-9]{32}$/); + assert.notEqual(fallback, admitted); +}); diff --git a/tests/unit/trace-message-hydration.test.ts b/tests/unit/trace-message-hydration.test.ts index a1dab2138..4c55942a6 100644 --- a/tests/unit/trace-message-hydration.test.ts +++ b/tests/unit/trace-message-hydration.test.ts @@ -5,6 +5,7 @@ // (lifecycle-handler.ts:524); worker child runs fold in via parent_run_id once Phase 2's // cross-process linkage lands. Audience-filtered so internal worker noise stays hidden. +import '../setup/test-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -12,7 +13,12 @@ import { stampTraceTool, linkTraceRunToMessage, listToolEntriesForMessage, + appendTraceEvent, + getTraceEvent, + updateTraceToolRow, } from '../../src/trace/store.js'; +import { resolveToolLog } from '../../src/routes/messages.js'; +import type { ToolEntry } from '../../src/types/agent.js'; test('P3H-001: boss message tools hydrate from trace_events by message_id, in seq order', () => { const runId = startTraceRun({ cli: 'claude', audience: 'public' }); @@ -43,5 +49,63 @@ test('P3H-003: public hydration excludes internal-audience (worker) runs', () => linkTraceRunToMessage(runId, messageId); assert.deepEqual(listToolEntriesForMessage(messageId, { audience: 'public' }), [], 'internal run excluded from public hydration'); - assert.equal(listToolEntriesForMessage(messageId, { audience: 'internal' }).length, 1, 'internal audience sees it'); + const internal = listToolEntriesForMessage(messageId, { audience: 'internal' }); + assert.equal(internal.length, 1, 'internal audience sees it'); + assert.equal(internal[0]?.traceRunId, runId); + assert.equal(internal[0]?.traceSeq, 1); + assert.equal(internal[0]?.detailAvailable, false); + assert.equal(internal[0]?.rawRetentionStatus, 'internal'); +}); + +test('message hydration synthesizes noncontiguous pointers and current SQL detail metadata', () => { + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + appendTraceEvent({ runId, source: 'cli_raw', eventType: 'text', raw: 'between tools' }); + const tool: ToolEntry = { icon: '🔧', label: 'read', toolType: 'tool', detail: 'initial' }; + stampTraceTool(tool, { traceRunId: runId, traceAudience: 'public' }); + linkTraceRunToMessage(runId, 990401); + const initial = listToolEntriesForMessage(990401)[0]!; + assert.equal(initial.traceRunId, runId); + assert.equal(initial.traceSeq, 2); + assert.equal(initial.detailAvailable, true); + assert.equal(initial.detailBytes, getTraceEvent(runId, 2)?.bytes); + assert.equal(initial.rawRetentionStatus, 'available'); + updateTraceToolRow({ ...tool, status: 'done', detail: 'a much longer completed result' }); + const updated = listToolEntriesForMessage(990401)[0]!; + assert.equal(updated.status, 'done'); + assert.equal(updated.detail, 'a much longer completed result'); + assert.equal(updated.detailBytes, getTraceEvent(runId, 2)?.bytes, 'SQL bytes replace the stale pointer embedded in raw JSON'); + assert.notEqual(updated.detailBytes, initial.detailBytes); + updateTraceToolRow({ ...tool, status: 'done', detail: 'x'.repeat(100_000) }); + const spilled = listToolEntriesForMessage(990401)[0]!; + assert.equal(spilled.rawRetentionStatus, 'spilled'); + assert.equal(spilled.detailBytes, getTraceEvent(runId, 2)?.bytes); + assert.equal(spilled.detail?.length, 100_000); +}); + +test('resolveToolLog preserves boss-first ordering and cross-run/unscoped blob workers', () => { + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + const otherRunId = startTraceRun({ cli: 'claude', audience: 'public' }); + const workerRunId = startTraceRun({ cli: 'claude', audience: 'internal' }); + const tool: ToolEntry = { icon: '🔧', label: 'boss', toolType: 'tool', stepRef: 'shared', status: 'running', detail: 'old' }; + stampTraceTool(tool, { traceRunId: runId, traceAudience: 'public' }); + updateTraceToolRow({ ...tool, status: 'done', detail: '' }); + stampTraceTool({ ...tool, label: 'other-boss', traceRunId: undefined, traceSeq: undefined }, { traceRunId: otherRunId, traceAudience: 'public' }); + linkTraceRunToMessage(runId, 990501); + linkTraceRunToMessage(otherRunId, 990501); + const blob = JSON.stringify([ + { ...tool, label: 'blob-boss' }, + { ...tool, traceRunId: workerRunId, label: 'worker-start', isEmployee: true }, + { ...tool, traceRunId: workerRunId, label: 'worker-done', status: 'done', isEmployee: true }, + { ...tool, traceRunId: undefined, label: 'unscoped-worker', isEmployee: true }, + { icon: '🤖', label: 'anonymous-worker', isEmployee: true }, + ]); + const result = JSON.parse(resolveToolLog(990501, blob, true)!) as ToolEntry[]; + assert.deepEqual(result.slice(0, 2).map(t => t.traceRunId).sort(), [runId, otherRunId].sort()); + assert.deepEqual(result.slice(2).map(t => t.label), ['worker-done', 'unscoped-worker', 'anonymous-worker']); + const boss = result.find(t => t.traceRunId === runId)!; + assert.equal(boss.status, 'done'); + assert.equal(boss.detail ?? '', ''); + assert.equal(boss.traceSeq, 1); + assert.equal((JSON.parse(resolveToolLog(990501, blob, false)!) as ToolEntry[])[0]?.label, 'blob-boss'); + assert.equal(resolveToolLog(990599, blob, true), resolveToolLog(990599, blob, false), 'missing rows retain legacy blob fallback'); });