Merge PR #5977: fix(coding-agent): bound RPC output and reap failed workers (@wolfiesch)
This commit is contained in:
@@ -148,6 +148,90 @@
|
||||
- Fixed bash command timeouts rendering with an incorrect error border, and resolved Windows bash crashes when piped commands time out.
|
||||
- Migrated legacy nested/quoted-dotted config keys (e.g., `dev.autoqa.consent` -> `dev.autoqaConsent`) on settings load.
|
||||
- Added managed `ctx.setInterval` / `ctx.setTimeout` / `ctx.clearTimer` helpers on extension contexts to prevent uncaught exceptions from crashing sessions.
|
||||
- Fixed `--model <role>` resolving a bare configured `modelRoles` key.
|
||||
- Browser tool selectors now accept bare snapshot refs (`tab.click("e501")`, `@e501`) everywhere `aria-ref=e501` works — previously the tab-worker backend fell through to a CSS tag selector that could never match, burning the 2s zero-match watchdog with a misleading "matches no elements" hint. `tab.select`, `tab.uploadFile`, `tab.press({ selector })`, `tab.screenshot({ selector })`, and `tab.drag` now resolve refs too. Unknown/stale refs fail immediately with the "refresh refs" error.
|
||||
- `tab.select` no longer double-reports the previously selected option of a single `<select>`: the returned selection is read back after the full assignment pass instead of mid-loop.
|
||||
- Fixed transcript blocks being visibly duplicated during streaming (whole tool boxes and assistant paragraphs recommitted below their first copy on the terminal tape) by removing transcript committed-prefix compaction entirely. Dropping committed rows from the transcript's local frame shifted the frame under the engine's committed-prefix ledger, so the audit re-anchored and recommitted rows the tape already held. The transcript now always keeps its full local frame; committed finalized blocks still skip `render()` via the segment reuse bypass. Reverts the compaction half of [#5930](https://github.com/can1357/oh-my-pi/issues/5930)'s fix (compose keeps the render bypass; the local frame is no longer truncated).
|
||||
- Fixed tmux pane growth during a live response blanking finalized chat history re-exposed from native scrollback by rebasing the in-place repaint's commit seam to the resized viewport tail ([#6011](https://github.com/can1357/oh-my-pi/issues/6011)).
|
||||
- Fixed classifier refusals (e.g. Anthropic `stop_reason: "refusal"`) ending the turn with no visible error. Two independent regressions: (1) session events reached subscribers out of order when a turn's provider events landed in one tick — extension emits only await for event types with registered handlers, so the assistant `message_end` overtook its own `message_start` and the TUI skipped the error render entirely (no pinned banner, no inline `Error:` line); subscriber fan-out is now serialized in emission order. (2) Refusal turns are pruned from active context at settle (#3591), which also erased them from `state.messages` before `prompt()` resolved — print mode printed nothing and exited 0, and the task executor's `getLastAssistantMessage()` saw the previous turn. The pruned refusal is now retained until the next run starts, `getLastAssistantMessage()` reports it, and print mode reads the settled assistant via that accessor (exit 1 + refusal message on stderr). Additionally, `#lastAssistantMessage` is now set synchronously on `message_end` to prevent `agent_end` maintenance from reading a stale assistant turn when tool results and stops land in the same tick.
|
||||
- Fixed `before_provider_request` extension contexts exposing the primary session model for cross-provider Advisor requests instead of the request model ([#6006](https://github.com/can1357/oh-my-pi/issues/6006)).
|
||||
- Fixed isolated `task` subagents mutating the parent checkout and stacking parallel task branches. Copy isolation backends (reflink/apfs/btrfs/zfs/block-clone/rcopy) materialise the worktree by duplicating its `.git` verbatim; when the parent is a linked git worktree its `.git` is a pointer file, so the isolation shared the parent's HEAD/index/ref namespace and a task's `git checkout`/`commit` moved the parent's branch (and the rcopy `git worktree add` path leaked task branches into the shared namespace so a second task committed on top of the first). `ensureIsolation` now runs a new `git.detachGitDir` after `isoStart`: each isolation becomes a standalone repo with a frozen HEAD/refs/index snapshot that borrows the source object database through `objects/info/alternates`, so isolated git operations stay private, every task branch is parented on the requested base, and patch/branch capture (`git fetch <merged>`) still resolves objects. ([#6003](https://github.com/can1357/oh-my-pi/issues/6003))
|
||||
- Fixed `browser.run` leaving Puppeteer request handlers and interception state with divergent lifetimes by removing run-scoped handlers, disabling interception, and releasing held requests on every exit path ([#6004](https://github.com/can1357/oh-my-pi/issues/6004)).
|
||||
- Fixed Codex web search to honor configured `openai-codex` base URLs, API keys, and headers without leaking official OAuth credentials to custom endpoints; explicitly selected providers now fail closed instead of silently falling back ([#6001](https://github.com/can1357/oh-my-pi/issues/6001)).
|
||||
- Fixed `launch start` waiting for a finite PTY command to exit when the broker's PID-file handoff was unavailable; PTY startup now reports the spawned PID directly and returns an authoritative running or exited snapshot promptly ([#5996](https://github.com/can1357/oh-my-pi/issues/5996)).
|
||||
- Fixed queued user steering aborting side-effecting `hub start` calls after the broker request may already have been written; only passive hub waits and followed logs are now interruptible ([#5995](https://github.com/can1357/oh-my-pi/issues/5995)).
|
||||
- Fixed JavaScript/TypeScript debugging by launching vscode-js-debug over TCP, handling recursive `startDebugging` child sessions, synchronizing breakpoints across the session tree, and terminating every child connection ([#5984](https://github.com/can1357/oh-my-pi/issues/5984)).
|
||||
- Fixed rich ask options showing preview content only for the highlighted choice; every option now renders its preview inline, with pageable long content and accurate configured paging and cancel hints ([#5988](https://github.com/can1357/oh-my-pi/pull/5988) by [@metaphorics](https://github.com/metaphorics)).
|
||||
- Fixed legacy pi extensions failing extension validation when importing `getPackageDir` or `getProjectDir` from `@earendil-works/pi-coding-agent` (aliased to the legacy shim). The shim only re-exported `getAgentDir`; the two missing path helpers now resolve — `getProjectDir` from `@oh-my-pi/pi-utils`, and `getPackageDir` as a string-valued wrapper over omp's canonical package-root helper that falls back to the executable's directory inside `bun --compile` binaries (where the canonical helper returns `undefined`), matching pi's string contract. Extensions like `@gotgenes/pi-permission-system` install and load, and `path.join(getPackageDir(), …)` no longer crashes in the shipped binary ([#5968](https://github.com/can1357/oh-my-pi/issues/5968)).
|
||||
- Fixed headless print mode disposing the session before a final advisor review completed, which could drop the advisor transcript and usage ([#5942](https://github.com/can1357/oh-my-pi/pull/5942)).
|
||||
- Fixed capped zero-block assistant stops remaining in active/session history with the full failed-request usage, causing the next post-snapcompact `continue` to re-enter context maintenance at the same boundary; capped empty turns are now discarded and the failure names model switching or `/shake images` as recovery options ([#5959](https://github.com/can1357/oh-my-pi/issues/5959)).
|
||||
- Long sessions no longer re-run `convertToLlm` over settled history every turn. Conversion is memoized per message identity (plus the assistant `interruptedNext` neighbor flag): an exact re-convert of the same array reuses the outer `Message[]`, append-only growth reuses the converted prefix via slice-on-growth, and the prune/shake/strip-images/prewalk-scrub rewrite seams invalidate the affected message before the next pass. On the `llm-assembly` bench (N=5000) steady/append convert and repeat estimate are all >10x faster with robust MAD-noise well under 20% ([#5934](https://github.com/can1357/oh-my-pi/issues/5934)).
|
||||
- Fixed `/exit` hanging on post-prompt work and stacking independent subsystem teardown delays by bounding the aborted-work drain, disposing independent session resources concurrently, and keeping long shutdown waits visible ([#5932](https://github.com/can1357/oh-my-pi/issues/5932)).
|
||||
- Fixed queued-message display updates being skipped by focused-editor keystroke frames by explicitly repainting the pending-message container ([#5928](https://github.com/can1357/oh-my-pi/issues/5928)).
|
||||
- Fixed RPC and RPC-UI startup crashes when an in-process extension claimed Bun's singleton stdin stream before the protocol reader ([#5898](https://github.com/can1357/oh-my-pi/issues/5898)).
|
||||
- Bash command timeouts now render with a warning (yellow) border instead of an error (red) border, reflecting that the timeout ran its course rather than the command failed. `isError` remains `true` on the result so the model still knows the command did not complete normally. The `timedOut` flag is now propagated from the bash executor to distinguish timeouts from user aborts.
|
||||
- Fixed Cursor responses streams stalling after an exec-channel tool completed without automatically recovering. The session now continues from the already-buffered tool result instead of replaying the side-effecting request. ([#5790](https://github.com/can1357/oh-my-pi/issues/5790))
|
||||
- Fixed linked legacy pi extensions failing to load when they import `DefaultPackageManager` or linkedom: the coding-agent compatibility shim now enumerates OMP extension paths with plugin metadata, and extension-graph CommonJS modules load through synchronous default-export bridges with linkedom's bundled canvas fallback. ([#5658](https://github.com/can1357/oh-my-pi/issues/5658))
|
||||
- Fixed the advisor retrying terminal, non-retriable provider failures (e.g. blocked prompts) three times before giving up; such failures now drop the bounded batch after a single attempt while transient failures keep the 3-attempt retry path ([#5468](https://github.com/can1357/oh-my-pi/pull/5468)).
|
||||
- Fixed reassigning the `plan` role model mid-planning not taking effect on the active planning turn; the change now applies at the next turn boundary instead of only the next plan-mode entry ([#5657](https://github.com/can1357/oh-my-pi/issues/5657)).
|
||||
- Added managed `ctx.setInterval` / `ctx.setTimeout` / `ctx.clearTimer` helpers on the extension context. Callbacks scheduled through them run with the same isolation as handler dispatch — a throw or rejected promise is logged and reported through the extension error channel instead of escaping as a process-fatal `uncaughtException` — and every outstanding timer is `unref`'d and cleared automatically on `session_shutdown` ([#5664](https://github.com/can1357/oh-my-pi/issues/5664)).
|
||||
- Fixed an extension's self-scheduled `setInterval`/`setTimeout` callback throwing being able to tear down the whole session. Such callbacks ran outside the handler-dispatch try/catch, surfaced as a process-level `uncaughtException`, and the global postmortem handler treated them as fatal; extension authors now have sanctioned managed timers (see Added), and the constraint is documented in `docs/extensions.md` / `docs/skills/authoring-extensions.md` ([#5664](https://github.com/can1357/oh-my-pi/issues/5664)).
|
||||
- Fixed `/quit` and `/exit` leaving failed or stalled automatic title-generation requests alive during session teardown; disposal now aborts both online provider and local tiny-model title requests ([#5666](https://github.com/can1357/oh-my-pi/issues/5666)).
|
||||
- Fixed `startup.quiet` still rendering the `xdev: xd://: mounted …` status line when MCP tools connect; quiet startup now suppresses only the user-visible mount notice while retaining the hidden model-facing device update ([#5670](https://github.com/can1357/oh-my-pi/issues/5670)).
|
||||
- Fixed command error in `hub` tool with a non-POSIX shell ([#5682](https://github.com/can1357/oh-my-pi/pull/5682))
|
||||
- Fixed xdev-routed checkpoint and rewind writes not tracking checkpoint state and leaving rewinding results in rebuilt provider and session context.
|
||||
- Fixed the built-in advisor silently doing nothing when its model routes through the `cursor` provider: the advisor runs in its own `Agent` that was constructed without `cursorExecHandlers`, so on Cursor — where every tool executes server-side and is dispatched back through the client's exec handlers — each advisor tool call (including the MCP `advise` tool) came back `toolNotFound`/"tool not available" and no advice was ever routed. The advisor `Agent` now gets a Cursor exec bridge scoped to its own granted tool set, mirroring the primary agent. The bridge's native `delete` frame is gated so a read-only advisor cannot delete workspace files it was never granted a mutating tool for ([#5680](https://github.com/can1357/oh-my-pi/issues/5680)).
|
||||
- Fixed the fullscreen plan-review overlay staying visible until the approved execution turn finished, so after picking "Approve and keep context" (or any approve option) work proceeded underneath while the operator was stuck on the plan-review screen. The overlay is now hidden once execution begins — after the async transcript rebuild, before the blocking synthetic prompt is dispatched — instead of only after the whole turn returns ([#5688](https://github.com/can1357/oh-my-pi/issues/5688)).
|
||||
- Fixed MCP tools repeatedly unmounting and remounting mid-session when server names have overlapping sanitized prefixes (e.g. `atlassian` alongside an imported `atlassian:atlassian`), and stale tools remaining registered after disconnecting a server with special characters in its name.
|
||||
- Fixed the `/usage show` `in use by this session:` marker showing only the login email, so two same-email Anthropic credentials in different orgs (a Team seat and a personal Max plan) were indistinguishable. The marker now suffixes the active organization (`email (OrgName)`) via a shared `formatActiveAccountLabel`, matching the account list and login-success surfaces ([#5691](https://github.com/can1357/oh-my-pi/issues/5691)).
|
||||
- Fixed Windows stdio MCP servers launched through `.cmd`/`.bat` shims failing with `Transport closed`; the launch now builds a `cmd.exe /d /e:ON /v:OFF /c` command line escaped for `cmd.exe`'s parser and spawned with `windowsVerbatimArguments`, so the resolved command path and arguments (including `%VAR%`, quotes, and shell metacharacters) reach the server intact and cannot inject commands (BatBadBut / CVE-2024-24576) ([#5696](https://github.com/can1357/oh-my-pi/issues/5696)).
|
||||
- Fixed the TUI usage panel truncating organization suffixes from same-email account labels even when the terminal has enough width ([#5701](https://github.com/can1357/oh-my-pi/issues/5701)).
|
||||
- Fixed a startup crash on Windows when running from a drive root (e.g. `R:\`): `fs.realpath` throws `EISDIR` there, but `canonicalProjectDir` in `launch/presence.ts` and `launch/client.ts` only recovered `ENOENT`. It now also falls back to `path.resolve()` on `EISDIR` ([#5708](https://github.com/can1357/oh-my-pi/issues/5708) by [@ve3xone](https://github.com/ve3xone)).
|
||||
- Fixed unknown `__omp_worker_*` CLI selectors exiting 0 with empty output instead of erroring; an unrecognized worker-host selector now writes `Error: unknown worker selector: …` to stderr and exits nonzero, so a stale or mistyped selector can no longer look healthy to a parent process or install smoke path ([#5712](https://github.com/can1357/oh-my-pi/issues/5712)).
|
||||
- Fixed Plan Review capturing mouse drags as pointer events, preventing native terminal text selection ([#5711](https://github.com/can1357/oh-my-pi/issues/5711)).
|
||||
- Fixed orphaned TUI processes with revoked terminal descriptors remaining alive after a fatal error and amplifying shared log-rotation races into runaway memory, file-descriptor, swap, and disk consumption ([#5716](https://github.com/can1357/oh-my-pi/issues/5716)).
|
||||
- Fixed approved-plan execution looping through filesystem searches when a model rewrites the required `local://<slug>-plan.md` read as a same-basename working-directory path; a missing cwd-root alias now recovers the active session-local plan while preserving any real working-tree file ([#5704](https://github.com/can1357/oh-my-pi/issues/5704)).
|
||||
- Fixed Ask dialogs immediately accepting their highlighted single-select answer when they appear while the user is typing a space in the prompt editor ([#5717](https://github.com/can1357/oh-my-pi/issues/5717)).
|
||||
- Stopped post-compaction auto-continue from opening another primary turn after a terminal text answer with no queued work, and moved automatic auto-learn capture into an abortable private agent with only `manage_skill` and `learn` tools ([#5715](https://github.com/can1357/oh-my-pi/issues/5715)).
|
||||
- Fixed the `write` approval gate misclassifying `xd://` device writes as `exec` when the mounted tool declared a function-valued (argument-dependent) `approval`: the gate discarded the function and never decoded the device JSON payload, so read/write device operations prompted in non-yolo modes their approval mode permits. It now parses valid object payloads and evaluates the mounted tool's normal approval decision, while malformed JSON, non-object payloads, and unknown devices still fall back to `exec` and prompt ([#5727](https://github.com/can1357/oh-my-pi/issues/5727)).
|
||||
- Fixed custom LSP servers such as `roslyn-language-server` crashing after initialization when they request unconfigured `workspace/configuration` sections; missing settings now receive the spec-required `null` instead of `{}` ([#5745](https://github.com/can1357/oh-my-pi/issues/5745)).
|
||||
- Fixed late user-initiated bash results and minimized-output artifacts being recorded in whichever session or branch was active when execution finished; bash now retains its originating transcript across `new_session`/`switch_session`/`branch`/tree navigation, and an intentionally dropped session stays deleted instead of being recreated by a straggling result ([#5743](https://github.com/can1357/oh-my-pi/issues/5743)).
|
||||
- Fixed Claude Code marketplace plugins with `scope: "local"` leaking skills, hooks, tools, commands, and MCP servers into unrelated projects ([#5750](https://github.com/can1357/oh-my-pi/issues/5750)).
|
||||
- Fixed headless `omp -p` waiting indefinitely after a completed turn when final mnemopi consolidation stalls; print mode now applies the same bounded consolidation shutdown budget as interactive exit and reaps the embed worker ([#5753](https://github.com/can1357/oh-my-pi/issues/5753)).
|
||||
- Fixed explicit-tool sessions bypassing `xd://` presentation for ambient discoverable custom and MCP tools, which sent their schemas top-level and could exceed provider tool limits or trigger schema-compatibility errors.
|
||||
- Fixed `providers.webSearch: kimi` sending a Moonshot Open Platform credential (`MOONSHOT_API_KEY` / stored `moonshot` auth) to the Kimi Code search endpoint (`api.kimi.com/coding/v1/search`), which rejects it with `401` and silently falls back to another provider. Kimi web search now resolves and advertises Kimi Code credentials only — a Kimi Code Console key via `KIMI_SEARCH_API_KEY` / `MOONSHOT_SEARCH_API_KEY` or `omp /login kimi-code` ([#5762](https://github.com/can1357/oh-my-pi/issues/5762)).
|
||||
- Fixed extension/SDK/RPC `registerTool` demoting essential built-ins (`read`/`write`/`bash`/`edit`/`glob`/…) to `discoverable` when a re-registration omitted `loadMode`, which — with `tools.xdev` on — unmounted them from the top-level schema and broke the `xd://` transport (`read xd://`/`write xd://`), leaving the model with no callable coding essentials. Omitted `loadMode` now defaults to `"essential"` for known essential built-in names at every adapter boundary, and `read`/`write` (the transport itself) are never mounted under xdev regardless of `loadMode` ([#5764](https://github.com/can1357/oh-my-pi/issues/5764)).
|
||||
- Fixed the advisor skipping the next real user instruction after auto-learn accepted and pruned a terminal empty assistant stop; advisor transcript cursors now detect rewritten prefixes and re-prime before slicing the next update ([#5731](https://github.com/can1357/oh-my-pi/issues/5731)).
|
||||
- Fixed built-in advisors retrying a quota- or rate-limited provider until becoming unavailable instead of applying the matching `retry.fallbackChains` model chain; advisor fallbacks now emit the same applied and succeeded lifecycle events as primary-agent fallbacks ([#5740](https://github.com/can1357/oh-my-pi/issues/5740)).
|
||||
- Made the model selector status messages use the role tag (`SMOL`, `SLOW`) instead of the display name (`Fast`, `Thinking`), matching the rest of the TUI and CLI/env role terminology ([#5585](https://github.com/can1357/oh-my-pi/issues/5585)).
|
||||
- Fixed Cursor models receiving only top-level tools by forwarding mounted `xd://` devices, including user-configured MCP servers, through Cursor's request-context MCP catalog and execution bridge ([#5650](https://github.com/can1357/oh-my-pi/issues/5650)).
|
||||
- Fixed Windows bash crashes when a piped command times out while flushing output; explicit-timeout watchdogs now wait for bounded native teardown instead of returning mid-drain. ([#5316](https://github.com/can1357/oh-my-pi/issues/5316))
|
||||
- Fixed a race where hub/IRC `send` and `ensureLive` could hand out or inject into a subagent session mid-`park` dispose: park now detaches and flips status to `parked` before `session.dispose()`, concurrent `ensureLive` cancels a pre-detach park or waits then revives, and IRC delivery always gates through `ensureLive` so receipts/unread counts stay truthful ([#5633](https://github.com/can1357/oh-my-pi/issues/5633)).
|
||||
- Migrated legacy `dev.autoqa.consent` → `dev.autoqaConsent` and `todo.reminders.max` → `todo.remindersMax` on settings load so pre-v17 nested or quoted-dotted config no longer leaves the parent path as an object (which made `dev.autoqa` truthy and enabled Auto QA, and discarded the reminder limit). Explicit new keys win, a separately configured parent boolean is preserved, an irrecoverable object parent falls back to the schema default, and only the new keys persist on save ([#5632](https://github.com/can1357/oh-my-pi/issues/5632)).
|
||||
- Fixed all keyboard input dying after the first keypress when a `~/.claude/tools` (or `.omp/tools`) module attaches a stdin consumer at import time — e.g. an MCP `StdioServerTransport` constructed at module top level, or a bare `process.stdin.resume()`. The custom-tool/extension/hook/plugin loader guard now snapshots and restores `process.stdin` (listeners, paused state, raw mode) around third-party module evaluation, so a hijacked stdin reader can no longer starve the TUI's own listener ([#5618](https://github.com/can1357/oh-my-pi/issues/5618)).
|
||||
- Fixed the ask tool's "Other" custom-input dialog rendering the title, options, and hint one column to the right of the `> ` input gutter; the prompt-style editor chrome now aligns to column 0 ([#5313](https://github.com/can1357/oh-my-pi/issues/5313))
|
||||
- Fixed advisor context maintenance undercounting the provider context: the compaction decision now anchors on the advisor's provider-reported context usage (cached input + generated output) floored by a full local estimate that includes the advisor system prompt and tool schemas, rejects stale provider usage retained across advisor compaction, and recovers a provider overflow by clearing only the advisor's own context at the current primary cursor — retrying the bounded failing batch once against a fresh context without replaying old primary history and keeping later updates eligible ([#5282](https://github.com/can1357/oh-my-pi/issues/5282))
|
||||
- Fixed RPC mode (`--mode rpc`) crashing the whole process with an uncaught `SyntaxError: Failed to parse JSONL` on any non-JSON stdin line. Malformed lines are now reported via a `Failed to parse command` error frame and the frame loop keeps running. ([#5194](https://github.com/can1357/oh-my-pi/issues/5194))
|
||||
- Fixed the status line loop indicator to distinguish waiting, running, and paused states and show the remaining loop budget ([#5832](https://github.com/can1357/oh-my-pi/pull/5832) by [@wolfiesch](https://github.com/wolfiesch)).
|
||||
- Fixed single-model task agents ignoring an explicitly configured default retry fallback chain, which left subagents failed after their selected provider became unreachable instead of advancing to the configured fallback model.
|
||||
- Fixed the `/extensions` dashboard tab labeled "Agents (standard)" being confused with the `/agents` subagents feature — the `.agent`/`.agents` config-standard provider now presents as "Agent Dirs (.agent/.agents)" since it lists skills, rules, prompts, commands, and context/system files, never subagents ([#5821](https://github.com/can1357/oh-my-pi/issues/5821)).
|
||||
- Fixed non-raw `read` line selectors returning context outside the requested inclusive range ([#5802](https://github.com/can1357/oh-my-pi/issues/5802)).
|
||||
- Fixed LSP requests silently clamping explicit timeouts above 60 seconds by supporting documented budgets up to 300 seconds ([#5804](https://github.com/can1357/oh-my-pi/issues/5804)).
|
||||
- Clarified async task and hub guidance: inspecting a settled job consumes its automatic delivery, job IDs expire from process memory after roughly five minutes, and completion does not verify claimed artifacts ([#5869](https://github.com/can1357/oh-my-pi/issues/5869)).
|
||||
- Fixed `vibe_wait` TV-wall panels stacking duplicate frozen frames in native scrollback while two or more workers were live ([#5777](https://github.com/can1357/oh-my-pi/issues/5777)).
|
||||
- Fixed async task job rows omitting resolved subagent model and reasoning badges when `task.showResolvedModelBadge` is enabled. ([#5060](https://github.com/can1357/oh-my-pi/issues/5060))
|
||||
- Fixed raw Puppeteer `page`/`browser` promises from crashing inline browser workers or killing dedicated workers when a target closed before the caller awaited the promise.
|
||||
- Fixed auto-compaction dead-ending in a warning loop ("Compaction freed too little context to make progress") when the single most-recent turn is itself over budget so `prepareCompaction` has nothing to summarize (`findCutPoint` never cuts inside a tool result). This `!preparation` short-circuit never ran the artifact-backed `shake` elide rescue that #3786 added to the post-maintenance guard, so snapcompact/context-full maintenance paused with no attempt to shrink the oversized tail. The dead-end now runs the same elide pass, re-prepares on the shrunken branch, and falls through to a normal compaction when the tail became summarizable — only pausing (single warning) when nothing is elide-eligible. ([#4786](https://github.com/can1357/oh-my-pi/issues/4786))
|
||||
- Fixed GitHub-hosted repository file reads falling back to `curl` by adding a dedicated `github` file-read operation and explicit tool-routing guidance ([#4805](https://github.com/can1357/oh-my-pi/issues/4805)).
|
||||
|
||||
### Removed
|
||||
|
||||
- Fixed the Cursor-backed advisor losing entire turns when it selected server-native tools (`bash`, `grep`, etc.) outside its grant: exec-resolved native blocks are already rejected in-band by the advisor-scoped bridge, so they no longer trip the unavailable-tool quarantine and discard the `advise` emitted in the same turn ([#5900](https://github.com/can1357/oh-my-pi/issues/5900)).
|
||||
- Fixed custom `anthropic-messages` OAuth providers being unable to opt into configured Claude Code fingerprint header overrides. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888))
|
||||
- Fixed authoritative providers (e.g. `openai-codex`) keeping unsupported bundled models selectable when a fresh model cache and an expired OAuth token coincided: built-in discovery now forces the OAuth refresh so the provider's model manager is constructed and prunes stale bundled entries (e.g. `gpt-5.4-nano`) instead of waiting out the cache TTL. ([#5364](https://github.com/can1357/oh-my-pi/issues/5364))
|
||||
### Fixed
|
||||
|
||||
- Bounded RPC JSONL frames to 1 MiB, compacted oversized `agent_end` frames without losing complete Python prompt results, and guaranteed worker reaping plus pending-request rejection after output-reader failures or explicit stops ([#5405](https://github.com/can1357/oh-my-pi/issues/5405)).
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ function normalizeToolResult<TDetails>(result: RpcClientToolResult<TDetails>): A
|
||||
|
||||
export class RpcClient {
|
||||
#process: ptree.ChildProcess | null = null;
|
||||
#reaping: Promise<void> | null = null;
|
||||
#eventListeners: RpcEventListener[] = [];
|
||||
#sessionEventListeners: RpcSessionEventListener[] = [];
|
||||
#subagentLifecycleListeners = new Set<RpcSubagentLifecycleListener>();
|
||||
@@ -233,6 +234,7 @@ export class RpcClient {
|
||||
* retry without leaking processes.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
await this.#reaping;
|
||||
if (this.#process) {
|
||||
throw new Error("Client already started");
|
||||
}
|
||||
@@ -268,7 +270,26 @@ export class RpcClient {
|
||||
const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
|
||||
let readySettled = false;
|
||||
|
||||
// Process lines in background, intercepting the ready signal
|
||||
const reapAfterOutputFailure = async (error: Error) => {
|
||||
if (this.#process !== child) return;
|
||||
|
||||
this.#process = null;
|
||||
this.#abortController.abort(error);
|
||||
const pendingRequests = Array.from(this.#pendingRequests.values());
|
||||
this.#pendingRequests.clear();
|
||||
for (const pendingCall of this.#pendingHostToolCalls.values()) pendingCall.controller.abort(error);
|
||||
this.#pendingHostToolCalls.clear();
|
||||
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// The process may already have exited.
|
||||
}
|
||||
await this.#waitForExit(child);
|
||||
for (const request of pendingRequests) request.reject(error);
|
||||
};
|
||||
|
||||
// Process lines in background, intercepting the ready signal.
|
||||
const lines = readJsonl(child.stdout, this.#abortController.signal);
|
||||
void (async () => {
|
||||
for await (const line of lines) {
|
||||
@@ -279,22 +300,38 @@ export class RpcClient {
|
||||
}
|
||||
this.#handleLine(line);
|
||||
}
|
||||
// Stream ended without the ready signal — the child exited or is
|
||||
// exiting. Defer to the exit handler below: ptree resolves
|
||||
// `exited` only after stderr is fully drained (nonzero exits), so
|
||||
// rejecting here would snapshot a partial stderr tail and lose
|
||||
// the actual startup error.
|
||||
if (readySettled) return;
|
||||
await child.exited.catch(() => {});
|
||||
// A closed stdout is terminal even if the child remains alive. Startup
|
||||
// failures are reaped by the readyPromise catch below; established
|
||||
// workers are reaped here so pending requests cannot hang indefinitely.
|
||||
if (!readySettled) {
|
||||
readySettled = true;
|
||||
readyReject(new Error(`Agent process exited before ready. Stderr: ${child.peekStderr()}`));
|
||||
readyReject(new Error(`Agent output stream ended before ready. Stderr: ${child.peekStderr()}`));
|
||||
return;
|
||||
}
|
||||
})().catch((err: Error) => {
|
||||
const exitResult = await Promise.race([
|
||||
child.exited.then(
|
||||
exitCode => ({ exitCode }),
|
||||
cause => ({ cause }),
|
||||
),
|
||||
Bun.sleep(100).then(() => null),
|
||||
]);
|
||||
const error =
|
||||
exitResult === null
|
||||
? new Error(`Agent output stream ended unexpectedly. Stderr: ${child.peekStderr()}`)
|
||||
: "exitCode" in exitResult
|
||||
? new Error(`Agent process exited with code ${exitResult.exitCode}. Stderr: ${child.peekStderr()}`)
|
||||
: new Error(`Agent output stream ended. Stderr: ${child.peekStderr()}`, {
|
||||
cause: exitResult.cause,
|
||||
});
|
||||
await reapAfterOutputFailure(error);
|
||||
})().catch(async (cause: unknown) => {
|
||||
const error = cause instanceof Error ? cause : new Error(String(cause));
|
||||
if (!readySettled) {
|
||||
readySettled = true;
|
||||
readyReject(err);
|
||||
readyReject(error);
|
||||
return;
|
||||
}
|
||||
await reapAfterOutputFailure(new Error(`Agent output reader failed: ${error.message}`, { cause: error }));
|
||||
});
|
||||
|
||||
// Also race against process exit (in case stdout closes before we read it)
|
||||
@@ -325,20 +362,12 @@ export class RpcClient {
|
||||
if (this.#customTools.length > 0) {
|
||||
await this.setCustomTools(this.#customTools);
|
||||
}
|
||||
} catch (err) {
|
||||
// Startup failed after we spawned the child. Kill it and clear
|
||||
// state so the caller (or a retry via start() again) does not
|
||||
// leak the abandoned process (issue #4079).
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
this.#abortController.abort();
|
||||
if (this.#process === child) {
|
||||
this.#process = null;
|
||||
}
|
||||
throw err;
|
||||
} catch (cause) {
|
||||
// Startup failed after spawning the child. Reap it before returning
|
||||
// so a retry cannot inherit a live worker or its session lock.
|
||||
const error = cause instanceof Error ? cause : new Error(String(cause));
|
||||
await reapAfterOutputFailure(error);
|
||||
throw cause;
|
||||
} finally {
|
||||
clearTimeout(readyTimeout);
|
||||
}
|
||||
@@ -347,28 +376,40 @@ export class RpcClient {
|
||||
/**
|
||||
* Stop the RPC agent process.
|
||||
*/
|
||||
stop() {
|
||||
if (!this.#process) return;
|
||||
stop(): Promise<void> {
|
||||
if (!this.#process) return this.#reaping ?? Promise.resolve();
|
||||
|
||||
this.#process.kill();
|
||||
this.#abortController.abort();
|
||||
const error = new Error("Client stopped");
|
||||
const child = this.#process;
|
||||
child.kill();
|
||||
this.#abortController.abort(error);
|
||||
this.#process = null;
|
||||
for (const request of this.#pendingRequests.values()) request.reject(error);
|
||||
this.#pendingRequests.clear();
|
||||
for (const pendingCall of this.#pendingHostToolCalls.values()) {
|
||||
pendingCall.controller.abort();
|
||||
pendingCall.controller.abort(error);
|
||||
}
|
||||
this.#pendingHostToolCalls.clear();
|
||||
return this.#waitForExit(child);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the RPC agent process and clean up resources.
|
||||
*/
|
||||
[Symbol.dispose](): void {
|
||||
try {
|
||||
this.stop();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
void this.stop();
|
||||
}
|
||||
|
||||
#waitForExit(child: ptree.ChildProcess): Promise<void> {
|
||||
const reaping = child.exited.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
this.#reaping = reaping;
|
||||
void reaping.then(() => {
|
||||
if (this.#reaping === reaping) this.#reaping = null;
|
||||
});
|
||||
return reaping;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { isRecord } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/** Maximum UTF-8 size of one newline-delimited RPC frame, including the newline. */
|
||||
export const MAX_RPC_FRAME_BYTES = 1024 * 1024;
|
||||
|
||||
interface ShrinkPass {
|
||||
stringCap: number;
|
||||
arrayLimit: number;
|
||||
objectLimit: number;
|
||||
}
|
||||
|
||||
const SHRINK_PASSES: readonly ShrinkPass[] = [
|
||||
{ stringCap: 256 * 1024, arrayLimit: 512, objectLimit: 512 },
|
||||
{ stringCap: 64 * 1024, arrayLimit: 256, objectLimit: 256 },
|
||||
{ stringCap: 16 * 1024, arrayLimit: 128, objectLimit: 128 },
|
||||
{ stringCap: 4 * 1024, arrayLimit: 64, objectLimit: 64 },
|
||||
{ stringCap: 1024, arrayLimit: 32, objectLimit: 32 },
|
||||
{ stringCap: 256, arrayLimit: 8, objectLimit: 16 },
|
||||
{ stringCap: 64, arrayLimit: 1, objectLimit: 8 },
|
||||
];
|
||||
|
||||
const STRING_ELISION_RESERVE = 80;
|
||||
const METADATA_STRING_CAP = 1024;
|
||||
|
||||
function serializedFrameBytes(json: string): number {
|
||||
return Buffer.byteLength(json, "utf8") + 1;
|
||||
}
|
||||
|
||||
function shrinkString(value: string, cap: number): string {
|
||||
if (value.length <= cap) return value;
|
||||
const headLength = Math.max(0, cap - STRING_ELISION_RESERVE);
|
||||
return `${value.slice(0, headLength)}\n…[${value.length - headLength} chars elided for RPC frame]`;
|
||||
}
|
||||
|
||||
function shrinkValue(value: unknown, pass: ShrinkPass): unknown {
|
||||
if (typeof value === "string") return shrinkString(value, pass.stringCap);
|
||||
if (Array.isArray(value)) {
|
||||
const keep = Math.min(value.length, pass.arrayLimit);
|
||||
const output: unknown[] = new Array(keep + (keep < value.length ? 1 : 0));
|
||||
for (let index = 0; index < keep; index++) output[index] = shrinkValue(value[index], pass);
|
||||
if (keep < value.length) output[keep] = `…[${value.length - keep} items elided for RPC frame]`;
|
||||
return output;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const entries = Object.entries(value);
|
||||
const keep = Math.min(entries.length, pass.objectLimit);
|
||||
const output: Record<string, unknown> = {};
|
||||
for (let index = 0; index < keep; index++) {
|
||||
const [key, item] = entries[index];
|
||||
output[key] = shrinkValue(item, pass);
|
||||
}
|
||||
if (keep < entries.length) output.rpcFrameElidedKeys = entries.length - keep;
|
||||
return output;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonSnapshot(value: unknown): unknown {
|
||||
const json = JSON.stringify(value);
|
||||
return json === undefined ? undefined : JSON.parse(json);
|
||||
}
|
||||
|
||||
function encodedMessageSnapshot(encoded: string): { message: unknown } | undefined {
|
||||
const frame = JSON.parse(encoded);
|
||||
return isRecord(frame) && frame.type === "message_end" && Object.hasOwn(frame, "message")
|
||||
? { message: frame.message }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function compactTerminalFrame(
|
||||
frame: object,
|
||||
streamedMessageCount: number,
|
||||
streamedMessages?: readonly unknown[],
|
||||
): object {
|
||||
if (!isRecord(frame) || frame.type !== "agent_end" || !Array.isArray(frame.messages)) return frame;
|
||||
let streamed = Number.isSafeInteger(streamedMessageCount)
|
||||
? Math.min(Math.max(0, streamedMessageCount), frame.messages.length)
|
||||
: 0;
|
||||
if (streamedMessages) {
|
||||
streamed = 0;
|
||||
const limit = Math.min(streamedMessages.length, frame.messages.length);
|
||||
while (
|
||||
streamed < limit &&
|
||||
isDeepStrictEqual(streamedMessages[streamed], jsonSnapshot(frame.messages[streamed]))
|
||||
) {
|
||||
streamed++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...frame,
|
||||
messages: frame.messages.slice(streamed),
|
||||
messageCount: frame.messages.length,
|
||||
};
|
||||
}
|
||||
|
||||
function overflowFrame(frame: object): object {
|
||||
if (!isRecord(frame)) return { type: "rpc_frame_error", error: "RPC frame exceeded the transport limit" };
|
||||
if (frame.type === "response") {
|
||||
return {
|
||||
id: typeof frame.id === "string" ? shrinkString(frame.id, METADATA_STRING_CAP) : undefined,
|
||||
type: "response",
|
||||
command: typeof frame.command === "string" ? shrinkString(frame.command, METADATA_STRING_CAP) : "unknown",
|
||||
success: false,
|
||||
error: "RPC response exceeded the transport limit",
|
||||
};
|
||||
}
|
||||
if (frame.type === "agent_end") {
|
||||
return {
|
||||
type: "agent_end",
|
||||
messages: [],
|
||||
messageCount: typeof frame.messageCount === "number" ? frame.messageCount : 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "rpc_frame_error",
|
||||
originalType: typeof frame.type === "string" ? shrinkString(frame.type, METADATA_STRING_CAP) : undefined,
|
||||
error: "RPC frame exceeded the transport limit",
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize a complete JSONL frame while enforcing the transport byte ceiling. */
|
||||
export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamedMessages?: readonly unknown[]): string {
|
||||
let json = JSON.stringify(frame);
|
||||
if (serializedFrameBytes(json) <= MAX_RPC_FRAME_BYTES) return `${json}\n`;
|
||||
if (isRecord(frame) && frame.type === "response") {
|
||||
return `${JSON.stringify(overflowFrame(frame))}\n`;
|
||||
}
|
||||
|
||||
const compacted = compactTerminalFrame(frame, streamedMessageCount, streamedMessages);
|
||||
json = JSON.stringify(compacted);
|
||||
if (serializedFrameBytes(json) <= MAX_RPC_FRAME_BYTES) return `${json}\n`;
|
||||
|
||||
for (const pass of SHRINK_PASSES) {
|
||||
json = JSON.stringify(shrinkValue(compacted, pass));
|
||||
if (serializedFrameBytes(json) <= MAX_RPC_FRAME_BYTES) return `${json}\n`;
|
||||
}
|
||||
|
||||
return `${JSON.stringify(overflowFrame(compacted))}\n`;
|
||||
}
|
||||
|
||||
/** Stateful encoder that tracks which messages a client has already received. */
|
||||
export class RpcFrameEncoder {
|
||||
#streamedMessages: unknown[] = [];
|
||||
|
||||
encode(frame: object): string {
|
||||
if (isRecord(frame) && frame.type === "agent_start") this.#streamedMessages = [];
|
||||
const encoded = encodeRpcFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
|
||||
if (!isRecord(frame)) return encoded;
|
||||
if (frame.type === "message_end") {
|
||||
const snapshot = encodedMessageSnapshot(encoded);
|
||||
if (snapshot) this.#streamedMessages.push(snapshot.message);
|
||||
} else if (frame.type === "agent_end" && frame.willContinue !== true) this.#streamedMessages = [];
|
||||
return encoded;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import type { EventBus } from "../../utils/event-bus";
|
||||
import { initializeExtensions } from "../runtime-init";
|
||||
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
|
||||
import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
|
||||
import { RpcFrameEncoder } from "./rpc-frame";
|
||||
import { claimRpcInput } from "./rpc-input";
|
||||
import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents";
|
||||
import type {
|
||||
@@ -617,9 +618,10 @@ export async function runRpcMode(
|
||||
// may write there.
|
||||
process.env.PI_NOTIFICATIONS = "off";
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`);
|
||||
const frameEncoder = new RpcFrameEncoder();
|
||||
process.stdout.write(frameEncoder.encode({ type: "ready" }));
|
||||
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
|
||||
process.stdout.write(`${JSON.stringify(obj)}\n`);
|
||||
process.stdout.write(frameEncoder.encode(obj));
|
||||
};
|
||||
const emitRpcTitles = shouldEmitRpcTitles();
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
* Used by rpc-client lifecycle tests that need to exercise start/stop/start
|
||||
* without booting the full agent runtime (which requires provider credentials).
|
||||
*/
|
||||
if (Bun.env.MOCK_RPC_PID_FILE) {
|
||||
await Bun.write(Bun.env.MOCK_RPC_PID_FILE, String(process.pid));
|
||||
}
|
||||
if (Bun.env.MOCK_RPC_IGNORE_SIGTERM === "1") {
|
||||
process.on("SIGTERM", () => {});
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ type: "ready" })}\n`);
|
||||
|
||||
// Bun's `console` is an AsyncIterable over stdin lines.
|
||||
@@ -15,6 +22,15 @@ for await (const raw of console) {
|
||||
try {
|
||||
const frame = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (frame && typeof frame === "object" && typeof frame.type === "string") {
|
||||
if (Bun.env.MOCK_RPC_EXIT_ON_COMMAND) {
|
||||
process.stderr.write(Bun.env.MOCK_RPC_EXIT_STDERR ?? "");
|
||||
process.exit(Number(Bun.env.MOCK_RPC_EXIT_ON_COMMAND));
|
||||
}
|
||||
if (Bun.env.MOCK_RPC_INVALID_OUTPUT === "1") {
|
||||
process.stdout.write("{invalid-json\n");
|
||||
continue;
|
||||
}
|
||||
if (Bun.env.MOCK_RPC_IGNORE_COMMANDS === "1") continue;
|
||||
const id = typeof frame.id === "string" ? frame.id : undefined;
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, spyOn, test } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client";
|
||||
import { ptree, TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const MOCK_AGENT = path.join(import.meta.dir, "fixtures", "mock-rpc-agent.ts");
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("RpcClient lifecycle (issue #4079 B)", () => {
|
||||
test("start() succeeds a second time after stop() on the same instance", async () => {
|
||||
using client = new RpcClient({
|
||||
@@ -12,16 +22,40 @@ describe("RpcClient lifecycle (issue #4079 B)", () => {
|
||||
|
||||
// First lifecycle: start + stop.
|
||||
await client.start();
|
||||
client.stop();
|
||||
await client.stop();
|
||||
|
||||
// Second start on the same instance must NOT reuse the aborted
|
||||
// controller from the previous stop(). Before the fix, this rejected
|
||||
// with "Agent process exited before ready" because the JSONL reader
|
||||
// short-circuited on the pre-aborted signal.
|
||||
await client.start();
|
||||
client.stop();
|
||||
await client.stop();
|
||||
}, 20000);
|
||||
|
||||
test("start() waits for a signal-ignoring worker to be reaped after stop()", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-rpc-stop-restart-");
|
||||
const pidFile = tempDir.join("pid");
|
||||
using client = new RpcClient({
|
||||
cliPath: MOCK_AGENT,
|
||||
env: {
|
||||
MOCK_RPC_PID_FILE: pidFile,
|
||||
MOCK_RPC_IGNORE_SIGTERM: process.platform === "win32" ? "0" : "1",
|
||||
},
|
||||
});
|
||||
|
||||
await client.start();
|
||||
const firstPid = Number(await Bun.file(pidFile).text());
|
||||
|
||||
const stopped = client.stop();
|
||||
const restarted = client.start();
|
||||
await Promise.all([stopped, restarted]);
|
||||
|
||||
const secondPid = Number(await Bun.file(pidFile).text());
|
||||
expect(secondPid).not.toBe(firstPid);
|
||||
expect(isProcessAlive(firstPid)).toBe(false);
|
||||
await client.stop();
|
||||
}, 20_000);
|
||||
|
||||
test("start() may be retried after a failed start (child is cleaned up on failure)", async () => {
|
||||
using client = new RpcClient({
|
||||
cliPath: path.join(import.meta.dir, "..", "src", "cli.ts"),
|
||||
@@ -39,4 +73,109 @@ describe("RpcClient lifecycle (issue #4079 B)", () => {
|
||||
// legitimate startup error.
|
||||
await expect(client.start()).rejects.toThrow(/Unknown provider.*__missing_provider__/);
|
||||
}, 30000);
|
||||
|
||||
test("stop() rejects active requests instead of leaving them to time out", async () => {
|
||||
using client = new RpcClient({
|
||||
cliPath: MOCK_AGENT,
|
||||
env: { MOCK_RPC_IGNORE_COMMANDS: "1" },
|
||||
});
|
||||
await client.start();
|
||||
|
||||
const pending = client.getState();
|
||||
client.stop();
|
||||
|
||||
await expect(pending).rejects.toThrow("Client stopped");
|
||||
});
|
||||
|
||||
test("rejects pending requests and reaps the worker when stdout parsing fails", async () => {
|
||||
// This awaits the real child-process grace-to-hard-kill path; fake timers
|
||||
// cannot drive OS signal delivery or process reaping.
|
||||
using tempDir = TempDir.createSync("@omp-rpc-reader-failure-");
|
||||
const pidFile = tempDir.join("pid");
|
||||
using client = new RpcClient({
|
||||
cliPath: MOCK_AGENT,
|
||||
env: {
|
||||
MOCK_RPC_PID_FILE: pidFile,
|
||||
MOCK_RPC_INVALID_OUTPUT: "1",
|
||||
MOCK_RPC_IGNORE_SIGTERM: process.platform === "win32" ? "0" : "1",
|
||||
},
|
||||
});
|
||||
|
||||
let pid = 0;
|
||||
try {
|
||||
await client.start();
|
||||
pid = Number(await Bun.file(pidFile).text());
|
||||
|
||||
await expect(client.getState()).rejects.toThrow(/Agent output reader failed/);
|
||||
await expect(client.getState()).rejects.toThrow("Client not started");
|
||||
expect(isProcessAlive(pid)).toBe(false);
|
||||
} finally {
|
||||
if (pid > 0 && isProcessAlive(pid)) process.kill(pid, "SIGKILL");
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
test("rejects pending requests and reaps a worker that closes stdout without exiting", async () => {
|
||||
let stdoutController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
let resolveExit: ((exitCode: number) => void) | undefined;
|
||||
let killCalls = 0;
|
||||
const exited = new Promise<number>(resolve => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const stdout = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
stdoutController = controller;
|
||||
controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ type: "ready" })}\n`));
|
||||
},
|
||||
});
|
||||
const fakeChild = {
|
||||
stdout,
|
||||
stdin: {
|
||||
write() {
|
||||
stdoutController?.close();
|
||||
stdoutController = undefined;
|
||||
return 0;
|
||||
},
|
||||
flush() {
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
exited,
|
||||
peekStderr() {
|
||||
return "";
|
||||
},
|
||||
kill() {
|
||||
killCalls += 1;
|
||||
resolveExit?.(0);
|
||||
},
|
||||
};
|
||||
const spawn = spyOn(ptree, "spawn").mockImplementation(
|
||||
() => fakeChild as unknown as ReturnType<typeof ptree.spawn>,
|
||||
);
|
||||
|
||||
try {
|
||||
using client = new RpcClient({ cliPath: MOCK_AGENT });
|
||||
await client.start();
|
||||
|
||||
await expect(client.getState()).rejects.toThrow("Agent output stream ended unexpectedly");
|
||||
await expect(client.getState()).rejects.toThrow("Client not started");
|
||||
expect(killCalls).toBe(1);
|
||||
} finally {
|
||||
spawn.mockRestore();
|
||||
}
|
||||
}, 5_000);
|
||||
|
||||
test("reports exit code and stderr when a ready worker exits", async () => {
|
||||
using client = new RpcClient({
|
||||
cliPath: MOCK_AGENT,
|
||||
env: {
|
||||
MOCK_RPC_EXIT_ON_COMMAND: "23",
|
||||
MOCK_RPC_EXIT_STDERR: "fixture worker failed",
|
||||
},
|
||||
});
|
||||
await client.start();
|
||||
|
||||
await expect(client.getState()).rejects.toThrow(
|
||||
"Agent process exited with code 23. Stderr: fixture worker failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { encodeRpcFrame, MAX_RPC_FRAME_BYTES, RpcFrameEncoder } from "../src/modes/rpc/rpc-frame";
|
||||
|
||||
function decode(frame: string): Record<string, unknown> {
|
||||
return JSON.parse(frame) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function oversizedMessageHistory(prefix: string) {
|
||||
const payload = "x".repeat(1024);
|
||||
return Array.from({ length: 1024 }, (_, index) => ({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `${prefix}-${index}-${payload}` }],
|
||||
}));
|
||||
}
|
||||
|
||||
describe("RPC frame encoding", () => {
|
||||
it("preserves frames that already fit", () => {
|
||||
const frame = { id: "request-1", type: "response", command: "get_state", success: true, data: { ok: true } };
|
||||
expect(encodeRpcFrame(frame)).toBe(`${JSON.stringify(frame)}\n`);
|
||||
});
|
||||
|
||||
it("compacts agent_end after message events have streamed", () => {
|
||||
const messages = Array.from({ length: 10_000 }, (_, index) => ({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `message-${index}-${"x".repeat(128)}` }],
|
||||
}));
|
||||
const encoded = encodeRpcFrame({ type: "agent_end", messages, telemetry: { stepCount: 42 } }, messages.length);
|
||||
const decoded = decode(encoded);
|
||||
|
||||
expect(Buffer.byteLength(encoded, "utf8")).toBeLessThanOrEqual(MAX_RPC_FRAME_BYTES);
|
||||
expect(decoded).toEqual({ type: "agent_end", messages: [], messageCount: 10_000, telemetry: { stepCount: 42 } });
|
||||
});
|
||||
|
||||
it("retains a terminal error emitted only by agent_end after earlier message events", () => {
|
||||
const streamed = { role: "assistant", content: [{ type: "text", text: "done" }] };
|
||||
const aborted = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
stopReason: "aborted",
|
||||
errorMessage: "Request was aborted",
|
||||
};
|
||||
const encoder = new RpcFrameEncoder();
|
||||
encoder.encode({ type: "agent_start" });
|
||||
encoder.encode({ type: "message_end", message: streamed });
|
||||
const decoded = decode(encoder.encode({ type: "agent_end", messages: [aborted] }));
|
||||
|
||||
expect(decoded).toEqual({
|
||||
type: "agent_end",
|
||||
messages: [aborted],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves terminal histories that fit for clients reading agent_end messages", () => {
|
||||
const streamed = { role: "assistant", content: [{ type: "text", text: "done" }] };
|
||||
const encoder = new RpcFrameEncoder();
|
||||
encoder.encode({ type: "agent_start" });
|
||||
encoder.encode({ type: "message_end", message: streamed });
|
||||
const frame = { type: "agent_end", messages: [streamed] };
|
||||
|
||||
expect(encoder.encode(frame)).toBe(`${JSON.stringify(frame)}\n`);
|
||||
});
|
||||
|
||||
it("matches oversized terminal messages in the JSON shape sent by message_end", () => {
|
||||
const messages = oversizedMessageHistory("wire-shape");
|
||||
const encoder = new RpcFrameEncoder();
|
||||
encoder.encode({ type: "agent_start" });
|
||||
for (const message of messages) {
|
||||
encoder.encode({
|
||||
type: "message_end",
|
||||
message: {
|
||||
...message,
|
||||
disabledFeatures: undefined,
|
||||
toolCallAbortMessages: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
const encoded = encoder.encode({ type: "agent_end", messages });
|
||||
|
||||
expect(Buffer.byteLength(encoded, "utf8")).toBeLessThanOrEqual(MAX_RPC_FRAME_BYTES);
|
||||
expect(decode(encoded)).toEqual({
|
||||
type: "agent_end",
|
||||
messages: [],
|
||||
messageCount: messages.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let later mutation rewrite the message_end snapshot", () => {
|
||||
const messages = oversizedMessageHistory("before");
|
||||
const encoder = new RpcFrameEncoder();
|
||||
encoder.encode({ type: "agent_start" });
|
||||
for (const message of messages) encoder.encode({ type: "message_end", message });
|
||||
messages[0].content[0].text = "after";
|
||||
const decoded = decode(encoder.encode({ type: "agent_end", messages }));
|
||||
|
||||
expect(decoded.messageCount).toBe(messages.length);
|
||||
expect(Array.isArray(decoded.messages)).toBe(true);
|
||||
expect((decoded.messages as unknown[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the active run snapshot when a continuing agent_end arrives late", () => {
|
||||
const active = oversizedMessageHistory("active");
|
||||
const stale = { role: "assistant", content: [{ type: "text", text: "stale" }] };
|
||||
const encoder = new RpcFrameEncoder();
|
||||
encoder.encode({ type: "agent_start" });
|
||||
for (const message of active) encoder.encode({ type: "message_end", message });
|
||||
|
||||
expect(decode(encoder.encode({ type: "agent_end", messages: [stale], willContinue: true }))).toEqual({
|
||||
type: "agent_end",
|
||||
messages: [stale],
|
||||
willContinue: true,
|
||||
});
|
||||
expect(decode(encoder.encode({ type: "agent_end", messages: active }))).toEqual({
|
||||
type: "agent_end",
|
||||
messages: [],
|
||||
messageCount: active.length,
|
||||
});
|
||||
const replayed = decode(encoder.encode({ type: "agent_end", messages: active }));
|
||||
expect(replayed.messageCount).toBe(active.length);
|
||||
expect(Array.isArray(replayed.messages)).toBe(true);
|
||||
expect((replayed.messages as unknown[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bounds a single multi-byte message without losing its event discriminator", () => {
|
||||
const encoded = encodeRpcFrame({
|
||||
type: "message_end",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "😀".repeat(600_000) }] },
|
||||
});
|
||||
const decoded = decode(encoded);
|
||||
|
||||
expect(Buffer.byteLength(encoded, "utf8")).toBeLessThanOrEqual(MAX_RPC_FRAME_BYTES);
|
||||
expect(decoded.type).toBe("message_end");
|
||||
expect(encoded).toContain("chars elided for RPC frame");
|
||||
});
|
||||
|
||||
it("bounds objects with many small fields", () => {
|
||||
const details = Object.fromEntries(
|
||||
Array.from({ length: 20_000 }, (_, index) => [`field-${index}`, `value-${index}-${"x".repeat(64)}`]),
|
||||
);
|
||||
const encoded = encodeRpcFrame({ type: "tool_execution_end", toolCallId: "tool-1", details });
|
||||
const decoded = decode(encoded);
|
||||
|
||||
expect(Buffer.byteLength(encoded, "utf8")).toBeLessThanOrEqual(MAX_RPC_FRAME_BYTES);
|
||||
expect(decoded.type).toBe("tool_execution_end");
|
||||
expect(encoded).toContain("rpcFrameElidedKeys");
|
||||
});
|
||||
|
||||
it("fails oversized responses instead of returning partial success data", () => {
|
||||
const encoded = encodeRpcFrame({
|
||||
id: "request-2",
|
||||
type: "response",
|
||||
command: "get_state",
|
||||
success: true,
|
||||
data: { transcript: "x".repeat(MAX_RPC_FRAME_BYTES) },
|
||||
});
|
||||
const decoded = decode(encoded);
|
||||
|
||||
expect(Buffer.byteLength(encoded, "utf8")).toBeLessThanOrEqual(MAX_RPC_FRAME_BYTES);
|
||||
expect(decoded).toEqual({
|
||||
id: "request-2",
|
||||
type: "response",
|
||||
command: "get_state",
|
||||
success: false,
|
||||
error: "RPC response exceeded the transport limit",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps overflow response metadata within the hard byte ceiling", () => {
|
||||
const encoded = encodeRpcFrame({
|
||||
id: "😀".repeat(MAX_RPC_FRAME_BYTES),
|
||||
type: "response",
|
||||
command: "get_state",
|
||||
success: true,
|
||||
data: {},
|
||||
});
|
||||
const decoded = decode(encoded);
|
||||
|
||||
expect(Buffer.byteLength(encoded, "utf8")).toBeLessThanOrEqual(MAX_RPC_FRAME_BYTES);
|
||||
expect(decoded.success).toBe(false);
|
||||
expect(decoded.id).toContain("chars elided for RPC frame");
|
||||
});
|
||||
});
|
||||
@@ -1065,9 +1065,12 @@ class RpcClient:
|
||||
|
||||
def _build_prompt_turn(self, events: tuple[RpcAgentEvent, ...]) -> PromptTurn:
|
||||
final_messages: tuple[AgentMessage, ...] = ()
|
||||
for event in reversed(events):
|
||||
for event_index in range(len(events) - 1, -1, -1):
|
||||
event = events[event_index]
|
||||
if isinstance(event, AgentEndEvent):
|
||||
final_messages = event.messages
|
||||
final_messages = self._complete_agent_end_messages(
|
||||
events[:event_index], event
|
||||
)
|
||||
break
|
||||
|
||||
assistant_message: AssistantMessage | None = None
|
||||
@@ -1093,6 +1096,36 @@ class RpcClient:
|
||||
else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _complete_agent_end_messages(
|
||||
events: tuple[RpcAgentEvent, ...], terminal: AgentEndEvent
|
||||
) -> tuple[AgentMessage, ...]:
|
||||
if (
|
||||
terminal.message_count is None
|
||||
or terminal.message_count <= len(terminal.messages)
|
||||
):
|
||||
return terminal.messages
|
||||
|
||||
run_start = 0
|
||||
for event_index in range(len(events) - 1, -1, -1):
|
||||
if isinstance(events[event_index], AgentStartEvent):
|
||||
run_start = event_index + 1
|
||||
break
|
||||
|
||||
streamed_messages = tuple(
|
||||
event.message
|
||||
for event in events[run_start:]
|
||||
if isinstance(event, MessageEndEvent)
|
||||
)
|
||||
streamed_prefix_count = terminal.message_count - len(terminal.messages)
|
||||
if streamed_prefix_count > len(streamed_messages):
|
||||
raise RpcError(
|
||||
"Compacted agent_end references "
|
||||
f"{streamed_prefix_count} streamed messages, but only "
|
||||
f"{len(streamed_messages)} were retained"
|
||||
)
|
||||
return streamed_messages[:streamed_prefix_count] + terminal.messages
|
||||
|
||||
def _wait_for_agent_end(
|
||||
self,
|
||||
start_index: int,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Literal, NotRequired, TypedDict, TypeAlias, cast
|
||||
|
||||
@@ -905,6 +905,7 @@ class AgentStartEvent:
|
||||
class AgentEndEvent:
|
||||
messages: tuple[AgentMessage, ...]
|
||||
type: Literal["agent_end"] = "agent_end"
|
||||
message_count: int | None = field(default=None, kw_only=True)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
@@ -1500,7 +1501,8 @@ def parse_notification(payload: JsonObject) -> RpcNotification:
|
||||
return AgentEndEvent(
|
||||
messages=parse_agent_messages(
|
||||
cast(JsonValue | None, payload.get("messages"))
|
||||
)
|
||||
),
|
||||
message_count=_optional_int(payload, "messageCount"),
|
||||
)
|
||||
if event_type == "turn_start":
|
||||
return TurnStartEvent()
|
||||
|
||||
@@ -86,7 +86,12 @@ FAKE_SERVER = textwrap.dedent(
|
||||
"dumpTools": [{"name": "read", "description": "Read files", "parameters": {"type": "object"}}] + registered_host_tools,
|
||||
}
|
||||
|
||||
def emit_prompt_turn(text: str, delay: float = 0.0, include_extra_events: bool = False):
|
||||
def emit_prompt_turn(
|
||||
text: str,
|
||||
delay: float = 0.0,
|
||||
include_extra_events: bool = False,
|
||||
compact_terminal: bool = False,
|
||||
):
|
||||
global last_assistant_text, messages
|
||||
print(json.dumps({"type": "agent_start"}), flush=True)
|
||||
print(json.dumps({"type": "turn_start"}), flush=True)
|
||||
@@ -198,9 +203,24 @@ FAKE_SERVER = textwrap.dedent(
|
||||
assistant = assistant_message(text)
|
||||
print(json.dumps({"type": "message_end", "message": assistant}), flush=True)
|
||||
print(json.dumps({"type": "turn_end", "message": assistant, "toolResults": []}), flush=True)
|
||||
print(json.dumps({"type": "agent_end", "messages": [assistant]}), flush=True)
|
||||
last_assistant_text = text
|
||||
messages = [assistant]
|
||||
if compact_terminal:
|
||||
terminal = assistant_message("terminal")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "agent_end",
|
||||
"messages": [terminal],
|
||||
"messageCount": 2,
|
||||
}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
last_assistant_text = "terminal"
|
||||
messages = [assistant, terminal]
|
||||
else:
|
||||
print(json.dumps({"type": "agent_end", "messages": [assistant]}), flush=True)
|
||||
last_assistant_text = text
|
||||
messages = [assistant]
|
||||
|
||||
def respond(request_id, command, data=None, success=True, error=None):
|
||||
payload = {"id": request_id, "type": "response", "command": command, "success": success}
|
||||
@@ -384,7 +404,12 @@ FAKE_SERVER = textwrap.dedent(
|
||||
if message == "notifications":
|
||||
print(json.dumps({"type": "extension_error", "extensionPath": "/tmp/ext.py", "event": "run", "error": "boom"}), flush=True)
|
||||
print(json.dumps({"type": "unknown_future_event", "value": 1}), flush=True)
|
||||
emit_prompt_turn("pong", delay=0.3 if message == "slow" else 0.0, include_extra_events=message == "all events")
|
||||
emit_prompt_turn(
|
||||
"pong",
|
||||
delay=0.3 if message == "slow" else 0.0,
|
||||
include_extra_events=message == "all events",
|
||||
compact_terminal=message == "compacted turn",
|
||||
)
|
||||
elif command_type == "host_tool_update":
|
||||
print(
|
||||
json.dumps(
|
||||
@@ -645,6 +670,16 @@ class RpcClientTests(unittest.TestCase):
|
||||
self.assertEqual(turn.require_assistant_text(), "pong")
|
||||
self.assertGreaterEqual(len(turn.events), 3)
|
||||
|
||||
def test_prompt_and_wait_reconstructs_compacted_terminal_messages(self) -> None:
|
||||
with self.make_client() as client:
|
||||
turn = client.prompt_and_wait("compacted turn", timeout=2.0)
|
||||
|
||||
self.assertEqual(
|
||||
[message["content"][0]["text"] for message in turn.messages],
|
||||
["pong", "terminal"],
|
||||
)
|
||||
self.assertEqual(turn.require_assistant_text(), "terminal")
|
||||
|
||||
def test_custom_tools_are_registered_and_executed_via_rpc(self) -> None:
|
||||
def echo_host(args: dict[str, str], context) -> str:
|
||||
context.send_update(f"working:{args['message']}")
|
||||
|
||||
@@ -125,11 +125,17 @@ class ProtocolParsingTests(unittest.TestCase):
|
||||
"timestamp": 1,
|
||||
}
|
||||
],
|
||||
"messageCount": 1,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertIsInstance(notification, AgentEndEvent)
|
||||
self.assertEqual(assistant_text(notification.messages[0]), "hello")
|
||||
self.assertEqual(notification.message_count, 1)
|
||||
|
||||
legacy = AgentEndEvent(notification.messages, "agent_end")
|
||||
self.assertEqual(legacy.type, "agent_end")
|
||||
self.assertIsNone(legacy.message_count)
|
||||
|
||||
def test_parse_extension_ui_request(self) -> None:
|
||||
notification = parse_notification(
|
||||
|
||||
Reference in New Issue
Block a user