mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
main
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e2bbb0295a |
feat: smart auto-routing (per-turn simple-vs-strong model selection) (#1734)
* feat(smart-routing): add smartRouting settings schema and reader * feat(smart-routing): resolve role keys to a SmartRoutingConfig * feat(smart-routing): wire per-user-turn routing into the query loop Classify once per user turn (transition===undefined), pin the decision in a loop-local, and apply the model-only route before the blocking-limit math. Enforce the org allowlist by calling isModelAllowed directly (coerce disallowed to strong; disable for the session if strong is also disallowed). Strip thinking history on a model change only under the provider gate (preserve-reasoning providers are left untouched). Export stripThinkingBlocksIfProviderAllows. * feat(smart-routing): add routed-error fallback to the strong model A simple-routed turn whose model call hits a retryable error retries once on the strong model, reusing the existing attemptWithFallback retry loop. Aborts and 4xx client errors propagate. Adds a session routing tally (simple/strong counts and simple->strong escalations) for the observability surface. * feat(smart-routing): add /smartroute command and env defaults /smartroute shows status and sets/toggles the simple and strong roles from agentModels keys, warning when the simple model is not first-party-cheaper than the strong one. OPENCLAUDE_SMART_ROUTING(_SIMPLE/_STRONG) provide startup defaults; an explicit settings block overrides env. * feat(smart-routing): show routing summary in /cost Appends a session routing summary (turns simple/strong, simple->strong escalations) to /cost, with an estimated-savings line gated on first-party pricing and annotated unavailable for unknown third-party pricing. Per-turn cost is already attributed to the routed model via the existing per-model breakdown. * fix(smart-routing): re-pin to strong after a routed-error fallback Without this, a turn's later continuation passes re-applied the pinned simple model after a fallback, re-triggering the same failure each pass. Re-pinning to strong keeps the rest of the turn on the recovered model. * fix(review): provider-swap guard, tally reset, notice-storm, env docs - Add the KTD6 provider-swap guard: drop the per-turn routing pin when a mid-turn provider-fallback swap changes the active provider, so the old provider's model id is not replayed at the new endpoint (adversarial P1). - Reset the routing tally in resetCostState() so /cost does not show stale cross-session counts. - Don't emit the disabled-for-session notice on every turn when no sessionId is available (suppress instead of storm). - Document OPENCLAUDE_SMART_ROUTING* in the openaiShim env-var header. - Add tests: provider-swap-safe pin, undefined-session silence, /smartroute strong arm and no-value guard. * docs(smart-routing): document /smartroute, settings, and env vars Register /smartroute in the web command catalog, add the smartRouting setting and OPENCLAUDE_SMART_ROUTING* env vars to the configuration reference, add a docs/smart-routing.md usage guide, and link it from the README. * fix(review): clear tally on /login, extract+test swap predicate, cap disabled set - /login used the raw bootstrap resetCostState, leaking the routing tally across an account switch; switch it to the cost-tracker wrapper. - Extract the provider-swap drop check as a pure, tested shouldDropPinForProviderSwap() and use it in the query loop. - Cap the disabledSessions set so a long-lived host can't grow it unbounded. - Document the 404/429 retry-by-design rationale; add tests for it. - Clarify the routedFallbackUsed per-turn scope and the apply-after-guard comment; document cross-provider role rejection and the re-enable path. * test(smart-routing): make allowlist tests robust to cross-file module mocks The decideTurnModel allowlist tests spied the global settings singleton, which let another file's leaked mock.module of modelAllowlist (agent.test.ts) flip isModelAllowed out from under them in the full suite. Spy isModelAllowed directly and restore it in afterEach so the tests are deterministic regardless of suite ordering. * fix(smart-routing): address CodeRabbit review and green CI - index.test.ts: pin the allowlist in the three happy-path decideTurnModel tests so they no longer inherit a leaked cross-file isModelAllowed mock (the CI test failure) - smartroute/index.test.ts: narrow the LocalCommandResult union via an expectText helper instead of reading .value off the union (the CI typecheck failure) - conversationRecovery.ts: route deserialize's thinking-strip gate through stripThinkingBlocksIfProviderAllows, removing the duplicated provider detection - conversationRecovery.test.ts: replace the two as-any fixtures with a shared typed factory * fix(smart-routing): scope cost claims to first-party reference pricing Smart routing's savings estimate and "simple isn't cheaper" warning were derived from the static first-party MODEL_COSTS table via getKnownInputCost, with no knowledge of the active provider, gateway, or account pricing. For a multi-provider user whose model ids happen to exist in that table but bill differently, the /cost summary and /smartroute warning stated a savings figure as if it reflected what they are actually charged. Narrow the copy instead of inventing provider-aware pricing the code cannot verify: the /cost line, the /smartroute warning, and docs/smart-routing.md now label the numbers as first-party reference pricing and note the active provider may bill differently. Tests assert the qualifier on every reworded branch so it cannot silently regress. No routing logic changed. * fix(smart-routing): clarify simple role wording * Fix smart routing review findings * fix(smart-routing): honor env roles and non-text turns * test(smart-routing): cover non-text skip path --------- Co-authored-by: jatmn <the@jat.mn> |
||
|
|
5471e4c453 |
feat(agent-routing): assign a per-agent model from the /agents menu (#1632)
* feat(agent-routing): add user-settings route read/write helpers for the /agents UI * feat(agent-routing): add AgentRouteSelector UI for picking a per-agent model route * feat(agent-routing): open the model-route selector from the /agents detail view Co-authored-by: CommandCodeBot <noreply@commandcode.ai> * fix(agent-routing): scope route options to user settings, align cross-provider labels Build route options from getSettingsForSource('userSettings') instead of getInitialSettings(), matching the scope we persist to. Reading from the merged view could surface an agentModels key that only exists in a non-user scope; selecting it would write a shadow user-settings entry that diverges from the original (losing its base_url/api_key). Label a key as cross-provider when either base_url or api_key is present, matching readAgentRoute, so the same route is described consistently. Also assert the dangling case in the currentRouteValue test, which the old test name referenced but did not cover. * fix(agent-routing): commit custom model id on submit, match runtime cred rule The custom-model input persisted from the per-keystroke onChange, so typing the first character of a model id saved that single character and closed the selector. Track the typed value and persist only on submit (the sentinel fires the Select onChange with the full value). Cross-provider detection now mirrors the runtime resolver (toAgentRoute): a route is cross-provider only when both base_url and api_key are present. A partial entry is skipped at runtime and inherits, so readAgentRoute reports it as unconfigured (dangling) and buildRouteOptions labels it 'unconfigured, inherits' rather than claiming a cross-provider route that will not execute. * fix(agent-routing): resolve the picker against the runtime's effective route key The runtime resolver normalizes routing keys (case-insensitive, hyphen/underscore-equivalent) and falls back to default, but the picker read and wrote agentRouting with the exact agentType key. So an existing general_purpose route showed general-purpose agents as inheriting, and selecting a model wrote a general-purpose sibling that first-wins lookup ignored while the menu claimed the change took effect. readAgentRoute now matches the normalized per-agent key the resolver would use, and surfaces a default-fallback route with viaDefault. computeSetRouteUpdate/computeClearRouteUpdate overwrite or clear that existing key spelling instead of writing a sibling. The clear option is hidden for default-inherited routes since there is no own key to remove. * fix(agent-routing): do not save routes a higher-priority source overrides The runtime resolver reads merged settings (userSettings -> projectSettings -> localSettings -> flagSettings -> policySettings), but the picker read and wrote only userSettings. A project/local/policy agentRouting entry made /agents report the agent as inheriting, and selecting a model wrote a userSettings route the merged chain ignored while the menu reported success. getAgentRoute now reads the effective merged settings, so the route line reflects what runtime resolves. setAgentRoute/clearAgentRoute refuse with an explanation when a higher-priority source owns the normalized route key, and the picker surfaces that as a read-only notice instead of offering an edit that cannot take effect. * fix(agent-routing): source-aware shadow guidance + selector tests flagSettings has no file to edit (it comes from the --settings flag or SDK inline settings), so the shadow message no longer tells users to edit a nonexistent file. Extracted shadowRemediation so the error and the read-only selector notice share one source-aware string. Added focused AgentRouteSelector tests covering the shadow read-only mode (file-backed and flag sources), persisting a selected model, and surfacing a failed save without closing the dialog. * test(agent-routing): make selector set-route test deterministic The persist test selected an option by ordinal without controlling the option list and only asserted the agentType. Mock buildRouteOptions to a known single option and assert the full [agentType, modelKey] tuple, so the test proves the selected model key is what gets persisted. * test(agent-routing): stop selector mock leaking into sibling test bun's mock.module persists across files in the same process and mock.restore() does not undo it, so mocking buildRouteOptions in the selector test leaked into agentRouteSettings.test.ts and failed its buildRouteOptions assertions in the full suite (passed in isolation). Re-point the module at the real implementation in afterEach, matching the AgentsMenu.test.tsx pattern. * test(agent-routing): use real buildRouteOptions in selector test Faking buildRouteOptions via mock.module leaked into agentRouteSettings.test.ts in the full suite: bun live-updates the imported namespace, so the afterEach re-mock to that namespace re-installed the fake. The real buildRouteOptions is deterministic (built-in aliases list sonnet/opus/haiku first), so option 1 is 'sonnet' without any fake. Only the I/O wrappers stay mocked. * test(agent-routing): pin mock restore to a pre-mock exports snapshot Snapshot the real agentRouteSettings exports into a frozen const before the first mock.module call and spread/restore from that, instead of the live realRouteSettings namespace bun mutates when the module is mocked. * fix(agent-routing): resolve alias routes provider-aware, guard disabled user settings Two review findings: - A model-only route whose model is a built-in alias (sonnet/haiku/opus/inherit) was sent literally as mainLoopModel, bypassing getAgentModel()'s provider-aware fallback. On non-Claude-native providers that 404s. resolveAgentRunModelRouting now runs a bare-alias model-only route through getAgentModel (parentModel + permissionMode threaded from the callers), so it inherits the parent model the same way the agent model selector does. Real model ids pass through unchanged. - setAgentRoute/clearAgentRoute now refuse with an explanatory error when userSettings is not an enabled setting source (e.g. --setting-sources project), instead of writing a route the runtime will never load and reporting success. * fix(agent-routing): apply model-only routes to teammate spawns Pane/window teammates resolve their model through resolveOutOfProcessTeammateProvider, which returns only cross-provider overrides. A model-only agentRouting route (the common case the /agents menu writes) was dropped, so a routed teammate type with no explicit model inherited the parent instead of the saved route. Add resolveOutOfProcessTeammateModelOnly, the model-only twin of the provider resolver, mirroring runAgent's lookup order and provider-aware alias resolution, and consult it in the teammate spawn path when there is no cross-provider override. Enforce the model allowlist on the resolved model when it changes the effective model. Also add the permission-mode regression CodeRabbit requested: spy on getAgentModel to assert the mode is forwarded into alias resolution. * fix(agent-routing): guard agentModels key shadowing and honest clear label The picker builds options from userSettings, but agentModels merges by source priority, so a user-level route to a key that a higher source (project/local/flag/policy) also defines resolves to that higher entry, not the current-provider model the option promised. getRouteShadowSource only checked agentRouting keys, so this collision was not surfaced. Add findModelKeyShadowingSource / getModelKeyShadowSource and refuse to save a model-only route whose key is shadowed by a higher source (unless the user already owns that key in userSettings). buildRouteOptions now flags shadowed keys so the conflict is visible before selecting. Also fixes the clear-route label: clearing only removes the agent's own routing key, so when a default route is configured the agent falls back to the default, not the parent model. The label and PR description now say so instead of promising parent inheritance. * fix(agent-routing): reject shadowed model keys even when userSettings owns them The previous shadow guard skipped the check when userSettings already defined agentModels[modelKey], but that does not make the route take effect: agentModels merges by source priority, so a higher-priority project/policy entry for the same key still wins and the agent resolves to that provider/model. The picker even flagged the key as shadowed while the save path let it through, so offer and save disagreed. Drop the ownsKey carve-out so setAgentRoute rejects whenever a higher source defines the key. Extract collectShadowedModelKeys as the single definition of 'defined above userSettings' that both the offer flag (getShadowedModelKeys) and the save guard (findModelKeyShadowingSource) derive from, and add a test locking the two paths in agreement. * test(agent-routing): cover setAgentRoute shadow guard and model-only teammate spawn Adds direct setAgentRoute() coverage for the higher-priority-shadow rejection and the allowed user-only-key save, and an AgentTool teammate-spawn regression that a model-only route reaches spawnTeammate as the resolved model with no cross-provider override. * test(agent-routing): cover custom model id submission persists full id * test(agent-routing): match focus glyph cross-platform in custom-id test The custom model regression test waited for the ❯ focus pointer, but ink renders figures.pointer as ">" on Windows, so the wait timed out in a Windows checkout before typing the id. Match figures.pointer directly so the wait tracks whatever glyph the renderer emits on each platform. * fix(agent-routing): let the route picker own Esc while open AgentDetail kept its parent confirm:no (Esc -> onBack) handler active after switching into routing mode. Since the Confirmation context was registered first, a bare Esc resolved to confirm:no and exited the detail view instead of resolving to the nested select's select:cancel, despite the picker copy telling users Esc goes back. Gate confirm:no with isActive=!routing so the route selector owns Esc and a single Esc only closes the picker. Add an AgentDetail regression covering m then Esc. * fix(agent-routing): scope Esc to the route picker and keep in-process teammate routes Two follow-ups from review of the per-agent model routing UI. The /agents detail view wraps AgentDetail in a Dialog whose own confirm:no (onCancel) stayed active while the route picker was open. As the first-registered Confirmation context it swallowed Esc and dropped back to the agent list instead of just closing the picker. AgentDetail now reports its picker state up through onRoutingChange, and a small AgentDetailDialog wrapper feeds that into the Dialog's isCancelActive so the picker's Select owns Esc while it is open. In-process teammates run the same runAgent() as normal subagents, but the synthetic agent definition overwrites agentType with the teammate's display name, so the original subagent_type that agentRouting is keyed on was lost and the configured cross-provider route never resolved (the teammate ran the routed model on the parent provider). The original subagent_type is now carried through the in-process handoff and used as the routing key, matching what pane and window teammates already get by re-resolving from their CLI identity. Adds a regression test for the picker Esc interaction through the Dialog wrapper and resolver tests proving the route resolves from the original subagent_type rather than the teammate name. --------- Co-authored-by: CommandCodeBot <noreply@commandcode.ai> |
||
|
|
d5588ea80d |
feat(context-collapse): opt-in between-turns context collapse (span summarization) (#1619)
* feat(context-collapse): implement context collapse for proactive context management
* feat(context-collapse): add turn-boundary helpers for span selection
* feat(context-collapse): deterministic turn-anchored span selection
* feat(context-collapse): code-computed span risk score
* feat(context-collapse): ctx-agent summarization instruction
* feat(context-collapse): implement ctx-agent span summarization spawn
* fix(context-collapse): make runtime activation opt-in (CLAUDE_CONTEXT_COLLAPSE)
* fix(context-collapse): address review feedback on restore state and test rigor
- restoreContextCollapseState now resets armed/lastSpawnTokens up front so a
snapshot-less restore cannot carry stale spawn state across sessions.
- projectView reuses a stable timestamp from the replaced span instead of
new Date(), keeping the read-side projection deterministic.
- Strengthen the disabled-state and turn-boundary assertions, drop an internal
renderToolUseMessage assertion, and isolate the operations/persist/spawn tests
from shared module and CLAUDE_CONTEXT_COLLAPSE env state.
* test(context-collapse): re-init enablement in persist.test hooks
resetContextCollapse() does not re-read CLAUDE_CONTEXT_COLLAPSE, so the
afterEach env delete left enabled=true in module state, leaking to the
next test file. Call initContextCollapse() in both hooks so module
enablement stays synced to the env var.
* test(context-collapse): stop spawnCtxAgent module stubs leaking across files
spawnCtxAgent.test.ts stubs shared modules (tokens, forkedAgent, messages,
analytics, log, spanSelection) via mock.module in beforeEach. bun's
mock.restore() does not undo mock.module, so the tokens stub (() => 100000)
bled into autoCompact/microCompact/runAgent tests run later in the full serial
suite, making them see every conversation as over-threshold (4 spurious
failures in test:full, all green in isolation).
Restore each stub to its real implementation in afterEach. The reals are
snapshotted into plain objects up front because 'import * as' yields a live
namespace that mock.module mutates in place, so holding the namespace would
restore the stub. autoCompact.js is deliberately not restored here since
autoCompact.test.ts re-imports it fresh via a cache-busting nonce.
Also reset+reinit the collapse module in afterEach so enabled state stays
synced to the now-unset env var.
* test(context-collapse): also restore autoCompact stub from spawnCtxAgent
The getEffectiveContextWindowSize stub on ../compact/autoCompact.js was the one
module the previous commit left unrestored, on the assumption that restoring it
would clash with autoCompact.test.ts's nonce re-import. It doesn't: the nonce
import uses a different specifier, and the snapshot restore is keyed by the
plain specifier. compressToolHistory imports getEffectiveContextWindowSize and
sizes tool-history truncation from it, so the leaked 20000-token window made it
fully omit tool results ('chars omitted') instead of mid-truncating
('[…truncated') for large-context models, failing the openaiShim compression
tests in the full serial suite. Restore all seven mocked modules.
* fix(context-collapse): re-arm after reset and gate ctx_inspect on opt-in
resetContextCollapse() left armed=false while enabled stayed true, so the
first /compact, main-thread compaction cleanup, or rewind permanently
disabled collapse for the rest of an opted-in session. Reset now mirrors
restoreContextCollapseState and sets armed=enabled.
CtxInspectTool.isEnabled() returned true unconditionally, advertising
ctx_inspect to the model in every default session even when the runtime
opt-in was off. It now returns isContextCollapseEnabled(). The opt-in is
also exposed as the contextCollapseEnabled global config key, so it is
reachable through /config instead of only the CLAUDE_CONTEXT_COLLAPSE env
var.
* refactor(context-collapse): drop no-op ternary in drainStaged persist call
The (stagedQueue.length > 0 ? 0 : 0) subtrahend always evaluated to 0, so
this is just persistCommits(processed.length).
* fix(context-collapse): persist commits before advancing the snapshot
drainStaged removed processed spans from the staged queue and then fired
persistCommits and persistSnapshot in parallel. If the snapshot write (which
no longer lists those spans as staged) landed while the commit write failed
or the process died between them, restore would find the spans neither staged
nor committed and the collapse would disappear on resume. Chain the snapshot
write after the commit write so the commit log is durable first.
* fix(context-collapse): project committed collapses on the query path, fix opt-in reach
Three issues from review:
- Committed collapses were never re-applied to the model input. The query path
calls applyCollapsesIfNeeded but only drained staged spans; projectView (which
replays the commit log) ran only in /context. Since messagesForQuery is rebuilt
from full REPL history each turn and the commit log is repopulated on resume,
the archived spans returned to the model on the next turn, undoing the collapse.
applyCollapsesIfNeeded now runs projectView first (idempotent). Adds a
regression that a committed collapse changes the next query input.
- Cache-safe params were saved only for exact repl_main_thread/sdk sources, but
the REPL tags non-default output styles as repl_main_thread:outputStyle:*, so
those sessions left the ctx-agent without params (empty spawns). Matches
repl_main_thread:* now, via a small tested helper.
- contextCollapseEnabled had no settings control. Adds a /config toggle that
refreshes runtime state (re-runs initContextCollapse) so it applies without a
restart.
* fix(context-collapse): clear already-committed staged spans; harden config toggle
After projecting committed collapses before draining, a span present in both the
commit log and the staged snapshot (a restore whose snapshot predates the
matching commit write) could not be drained — projectView had already removed
its messages — so it lingered in stagedQueue and distorted spawn/overflow
checks. drainStaged now drops staged spans that are already committed and syncs
the snapshot. Adds a regression covering the committed+staged overlap restore.
Also wraps the /config context-collapse refresh in try/catch so a failed
require/init can't crash the settings UI, and lists the toggle in the
save-and-close change summary like the neighboring compaction settings.
* fix(context-collapse): re-sync runtime state on config cancel
The context-collapse toggle's onChange refreshes the module-level
enabled/armed cache via initContextCollapse(). The revert path restored
the config key on disk but left that cache untouched, so enabling the
toggle and then pressing Escape kept collapse active for the rest of the
session. Re-init context collapse after the global config snapshot is
restored so cancel fully reverts runtime state.
* fix(context-collapse): keep collapsed summaries visible to the model
projectView and drainStaged replaced an archived span with a system
informational placeholder, but normalizeMessagesForAPI filters out every
system message that is not a local command. So once a collapse committed,
the next model request lost both the archived messages and the
<collapsed> summary meant to stand in for them, defeating the feature.
Mark the placeholder with isCollapseSummary and let it take the same
model-input path as local-command system messages (converted to a user
message), so the summary survives normalization. Added a regression that
runs the projected view through normalizeMessagesForAPI and asserts the
summary is still present.
* fix(context-collapse): avoid competing snapshot write after drain
After an immediate post-spawn drain, drainStaged(messages, true) starts
its own persistCommits().then(persistSnapshot) chain to guarantee commit
durability before the snapshot stops listing the staged spans. The
unconditional await persistSnapshot() that followed could win that race
and persist a snapshot with no staged spans before the commits landed,
reopening the crash window that drops collapses on restore. Only persist
directly when nothing was drained.
* fix(context-collapse): fall back, keep summaries non-snippable, gate /context
Three review findings:
- Suppress autocompact and the blocking preempt only when collapse holds a
real committed/staged reduction, not on mere enablement. Adds
hasActiveReduction(); a first over-threshold turn where spawnCtxAgent cannot
produce a span (getLastCacheSafeParams() still null) now falls back to
autocompact/blocking instead of sending an oversized transcript.
- Preserve isMeta when converting a collapse-summary placeholder to a user
message in normalizeMessagesForAPI, so the HISTORY_SNIP sweep cannot tag the
only replacement for an archived span as snippable.
- Gate the two /context projectView calls on isContextCollapseEnabled(), so a
disabled session does not under-report token usage from a lingering commit
log while the API receives the full transcript.
Adds regressions for hasActiveReduction and for the summary surviving
normalization as a non-snippable meta message.
* fix(context-collapse): scope collapse to the main thread that owns the store
The collapse store (commitLog/stagedQueue) is module-level and shared by
in-process subagents (agent:*) and the ctx-agent (marble_origami), which
run in the same process but do not own the main transcript.
applyCollapsesIfNeeded only skipped marble_origami, so a subagent could
stage or commit a span, flip the global hasActiveReduction(), and make
the next main-thread turn suppress autocompact and the blocking
prompt-too-long preempt while projectView() no-ops against the main
messages, sending an oversized transcript to the API.
Add isMainThreadSource() and gate both application (applyCollapsesIfNeeded,
isWithheldPromptTooLong, recoverFromOverflow) and fallback suppression
(autoCompact shouldAutoCompact, query collapseOwnsIt) to the owning
thread. Subagents now autocompact and preempt their own oversized turns
normally and never mutate the shared store.
Also adds the staged-only hasActiveReduction regression CodeRabbit
requested.
* fix(context-collapse): persist archived count so resumed stats stay accurate
restoreContextCollapseState rebuilt each commit with an empty archived
list, and getStats summed that list, so after a resume /context, the
context visualization, the token warning, and ctx_inspect reported
'N spans summarized (0 messages)' even though projectView was actively
removing the archived spans. The persisted-entry docstring claimed
projectView lazily refills the archive, but it only splices by boundary
uuid and never does.
The archived messages are never read back (only their count fed
getStats), so replace the per-commit Message[] with a persisted
archivedCount. It is written with each commit and restored on resume;
pre-field sessions restore as 0. getStats now reports the same figure
live and after resume.
* fix(context-collapse): keep collapse summary non-snippable across user merge
Preserving isMeta on the system->user conversion was not enough: when the
collapsed span ends right before the next user turn, normalizeMessagesForAPI
merges the summary into that real user message. Under HISTORY_SNIP
mergeUserMessages clears isMeta whenever an operand is real user content and
keeps the real turn's uuid, so the combined block — which carries the only
<collapsed> replacement for the archived span — got a snip id and the model
could queue it for removal.
Carry an isCollapseSummary marker onto the converted user message and through
mergeUserMessages (either operand), strip any snip id already baked into the
real turn when the merge absorbs a summary, and skip such blocks in
appendMessageTagToUserMessage. The merged block stays non-snippable
regardless of merge direction or isMeta being cleared.
* fix(context-collapse): preserve collapse marker on split, drop empty snip blocks
normalizeMessages split path now forwards isCollapseSummary so an array-backed
collapse summary keeps its non-snippable marker across API normalization.
stripSnipTagsFromContent drops a text block whose only content was the snip
marker, so the merge recovery path no longer emits an empty text block.
|
||
|
|
716c1d47f6 |
feat(compact): auto-compact prompt on /resume + determinate progress bar (#1386)
* feat(compact): auto-compact prompt on /resume + determinate progress bar On /resume, if the conversation exceeds 70% of the auto-compact threshold, a dialog appears offering to compact before continuing. Shows token count, context window usage, and effective window percentage. Also adds a determinate progress bar during compaction that advances as the summary streams in. - Add RESUME_COMPACT_PROMPT feature flag (enabled) - Add shouldPromptCompactOnResume() threshold gate - Add ResumeCompactPrompt dialog component - Emit compact_progress events during streaming in compact service - Render ProgressBar next to spinner during compaction in REPL - Add 5 tests for threshold gating logic * feat(compact): determinate progress bar via streamed text deltas Wire compact_progress events from streamed summary output (forkedAgent onStreamEvent forwards text deltas) and render a determinate ProgressBar in the REPL, replacing the indeterminate spinner during compaction. Extracts the bar into a CompactProgressBar component shared by manual /compact and the resume-triggered path. Emits coarse hooks/start ticks on the session-memory path so the bar moves immediately. * fix(compact): keep spinner visible and use bracketed progress bar Render the "Compacting conversation" spinner throughout compaction with the progress bar beneath it, instead of swapping the spinner out for the bar. Redraw the bar as a bracketed fill ([███····]) with no background rectangle, so it no longer reads as one solid block. * docs: remove resume-compact-prompt plan file Per PR review feedback — drop the planning doc from the PR. * fix(compact): prompt to compact on CLI --continue/--resume startup Startup resume paths (--continue, --resume <id>, ResumeConversation screen) install initialMessages via the initial useState rather than the resume() callback, so the threshold check never ran. Schedule the prompt from the mount-time initialMessages effect as well. * fix(compact): use MessageType alias for resumeCompactPending state main aliases the message type import as `Message as MessageType`; the rebased resumeCompactPending state still referenced the bare `Message`. * fix(compact): always close session-memory progress and fix progress-ratio units - Wrap the session-memory compaction attempt in try/finally so compact_end is always emitted, even when it returns null or throws, preventing a stuck progress bar/spinner. - Clear compactProgressRatio in resetLoadingState so an aborted or errored compaction does not leave the progress bar rendered in the idle UI. - Fix the progress denominator unit mismatch: estimatedOutputChars now uses a token-to-char converted estimate (preCompactTokenCount) instead of the token-scale preCompactTokenCount * 0.25, so progress no longer advances too fast and hits the cap prematurely. |
||
|
|
9e902db866 |
feat(agent-routing): model-only agent routes (set the verifier or any agent's model on the current provider) (#1617)
* feat(agent-routing): allow model-only agentModels entries in settings schema * feat(agent-routing): resolve model-only agent routes that reuse the current provider * feat(agent-routing): enforce org model allowlist for model-only agent routes * docs(agent-routing): document model-only routes and built-in agent keys * test(agent-routing): stub and assert partial-entry routing warnings Silence the intentional console.error noise from partial agentModels entries in CI logs by stubbing console.error per describe block and asserting the expected warning message fires, so future routing warnings or failures are not masked. * test(agent-routing): consolidate shouldEnforceModelAllowlist import Move the import to the top import block instead of mid-file. |
||
|
|
cdc8057496 |
feat: enable HISTORY_SNIP — model-callable snip tool for context management (#1407)
* feat(snip): implement HISTORY_SNIP — model-callable snip tool for context management
- snipProjection.ts: boundary detection + view filter (isSnipBoundaryMessage, projectSnippedView)
- snipCompact.ts: pending registry, snipCompactIfNeeded, shouldNudgeForSnips, SNIP_NUDGE_TEXT
- SnipTool/: model-callable snip tool with Zod schema (prompt.ts + SnipTool.ts)
- types/message.ts: add SystemCompactBoundaryMessage export
- scripts/build.ts: enable HISTORY_SNIP: true
- QueryEngine.ts: fix snipReplay return type
* docs: add MCP_SKILLS implementation plan
* docs: add HISTORY_SNIP implementation plan
* fix(snip): prune headless store on snip-boundary replay
The snipReplay path called snipCompactIfNeeded with a {force:true} option
that the function never read, and the pending-snip set was already cleared
when the boundary was produced in query.ts — so the replay always reported
nothing removed and mutableMessages never shrank in long SDK sessions.
Prune the store by the boundary's own removedUuids via projectSnippedView
instead. Also drop two planning docs that were committed to the branch.
* fix(snip): persist snip boundary in SDK/headless transcripts
When a snip boundary was yielded in the SDK/headless path, snipReplay pruned
the in-memory mutableMessages store but the branch broke before adding the
boundary to the local messages array or calling recordTranscript. Later
transcript writes used the pre-snip messages copy, so the on-disk transcript
kept the removed messages and no snipMetadata boundary. After a restart or
--resume, loadTranscriptFile reconstructed the un-snipped history and the
context reduction was lost.
Mirror the boundary into the local messages copy and record it when the snip
executes, matching the compact_boundary path. recordTranscript is append-only
by UUID, so the pre-snip messages already on disk remain and the appended
boundary (carrying snipMetadata.removedUuids) lets applySnipRemovals prune
them on load.
Add a loadTranscriptFile round-trip test covering the previously-untested
snip replay: a persisted boundary prunes its removedUuids and relinks
survivors whose parentUuid pointed into the removed gap.
* fix(history-snip): record paired tool-result removals and scope pending snips per conversation
Two issues in the snip path:
1. Persist every removed message. snipCompactIfNeeded drops the paired
tool-result user messages of a snipped assistant tool-use message from the
live context, but the boundary only recorded the explicitly-marked UUIDs.
projectSnippedView / loadTranscriptFile replay solely from
snipMetadata.removedUuids, so on --resume the tool results came back orphaned
(their assistant message stayed removed) and part of the reduction was lost.
Record the paired tool-result UUIDs in removedUuids so replay drops the same
set the live snip dropped.
2. Scope pending snips per conversation. The pending registry was module-global
and stored model-facing short IDs, then cleared unconditionally on every
snipCompactIfNeeded pass. With concurrent in-process sessions, session B could
clear A's pending IDs (losing A's snip) or, on a short-ID collision, prune the
wrong message. Resolve short IDs to full UUIDs at mark time against the
snipping conversation's own messages, and consume only the UUIDs present in
the current message array. UUIDs are globally unique, so the registry
self-scopes: one session can no longer consume or mis-target another's.
* fix(history-snip): drop paired assistant tool-use when snipping a tool-result message
[id:] tags are appended to user messages only, so the model snips a
tool-result user message, not the assistant tool_use. The previous
pairing only ran assistant->user; snipping a tool-result left the
preceding assistant tool_use orphaned, so the next API-prep pass
synthesized a placeholder result and the tool interaction was never
actually removed from live context or from replay.
Pair in both directions: when a snipped user message's tool_results all
belong to an assistant turn, drop that assistant tool_use too (mirroring
the existing .every() guard so partially-snipped turns are kept), and
record its UUID in the boundary's removedUuids so replay drops the same
set.
* fix(history-snip): add SnipBoundaryMessage render component
HISTORY_SNIP ships enabled, so Message.tsx reaches the snip_boundary
render branch after the first snip. That branch requires
./messages/SnipBoundaryMessage.js and renders its named
SnipBoundaryMessage export, but no source file existed — the build
emitted a missing-module-stub exporting only a default noop, so the
named component was undefined and the render crashed right after a
successful snip.
Add the component, mirroring CompactBoundaryMessage: a single dimmed
line marking the snip with the removed-message count and the transcript
shortcut. The build now resolves the import (no stub) and the named
export is present in the bundle.
* build: guard against enabled-feature imports resolving to missing-module stubs
The missing-import scanner stubs any unresolved relative import to a noop
default export. For a require behind a DISABLED feature flag that is correct
(dead-code-eliminated, never bundled). But when a flag is ENABLED the gated
require becomes live and the stub silently degrades a real module to
() => null, so a named export resolves to undefined and crashes the first
time that path runs. SnipBoundaryMessage shipped exactly this way: build,
smoke, and unit tests all passed while the UI crashed on the first snip.
Feature-flag DCE removes disabled branches before bundling, so every
missing-module-stub marker left in dist/cli.mjs is reachable in the shipped
build. After the CLI bundle, fail the build on any stub marker not explicitly
grandfathered in ACCEPTABLE_RUNTIME_STUBS (seeded with the pre-existing
stubs), and warn on stale allowlist entries. Verified: removing the
SnipBoundaryMessage source makes the guard fail and name the module.
* fix(history-snip): drop unmirrored force-snip command registration
force-snip is gated on HISTORY_SNIP but its source (./commands/force-snip.js)
was never mirrored into this build, so require(...).default resolved to the
missing-module stub's noop. That truthy noop was spread into the command list
(commands.ts:252), registering a bare () => null as a command with no name,
description, or call — broken the moment anything enumerates commands. Enabling
HISTORY_SNIP turned this live, same class as the SnipBoundaryMessage crash.
Remove the registration rather than ship a phantom command: the implementation
is not present in this tree, so the honest behavior is to not register it. Drop
the matching ACCEPTABLE_RUNTIME_STUBS entry so the bundle guard stays strict.
* fix(history-snip): don't snip a tool result that would orphan a surviving tool_use
The result-side pairing dropped an explicitly-snipped tool-result user
message even when its paired assistant turn had other, un-snipped tool
calls. That left the assistant holding a tool_use with no matching result,
which the next API-prep pass repairs with a synthetic placeholder
(src/utils/messages.ts), so the snip never actually took effect and the
restored context still carried the stale interaction.
Block-level surgery on the surviving assistant is not an option: replay
(projectSnippedView / loadTranscriptFile) drops whole UUIDs, not blocks, so
the live store and a --resume would diverge. Instead, treat an unclean snip
as a no-op: a tool_use is safely removable only if its whole assistant turn
goes with it (the assistant is explicitly snipped, or every tool_use in it
has its result snipped). A tool-result user message whose results don't all
pair to a removable tool_use is kept, and no boundary is emitted when nothing
was cleanly removable, keeping live context and replay identical.
* docs(build): document the bundle-stub guard as a coarse tripwire
The guard rationale claimed every missing-module-stub marker left in
dist/cli.mjs is reachable in the shipped build and that each allowlisted
entry is latent runtime debt behind an enabled flag. That overstates it: the
scanner keys missing modules by specifier string, so a same-named specifier
missing in one importer (including a test file) can leave a marker even when
another importer resolves the real module, and a marker can sit on a path
that never runs. Reword the comment and error message so a flagged stub reads
as "inspect this", not "confirmed runtime crash"; the guard reliably catches
a NEW stub appearing where none was expected, which is its actual value.
* fix(build): canonicalize bundle stub markers before diffing the allowlist
The bundle guard compared raw `missing-module-stub:` marker text against
ACCEPTABLE_RUNTIME_STUBS, but the marker format is not stable across build
hosts: locally Bun emits the relative import specifier
(`./commands/fork/index.js`), while on the Linux CI merge run it emitted the
same grandfathered stubs as absolute source paths
(`/home/runner/work/openclaude/openclaude/src/commands/fork/index.ts`). The raw
diff therefore failed `bun run smoke` on CI for already-allowlisted stubs and
also reported them as stale.
Canonicalize both the bundle markers and the allowlist to a stable key (the
basename without extension) before diffing, so a stub matches in either form.
Basename is the only reduction that unifies a relative specifier of unknown
depth with an absolute path (a fixed path-segment count breaks single-segment
specifiers like `./dream.js`). The allowlist keeps the readable full specifiers;
diagnostics still print the raw marker. Guard against two allowlist entries
sharing a basename (which would let one silently cover an unrelated stub) by
failing the build if the canonical set is smaller than the allowlist.
* chore(build): drop allowlist stubs resolved by current main
Rebasing onto current main brings in the per-importer scanner (#1399)
and the real sources for four previously-stubbed modules, so they no
longer emit missing-module markers:
- ../../utils/hooks/ssrfGuard.js (per-importer keying, #1399/#1450)
- ./dream.js (/dream restored, #1399)
- ./UserForkBoilerplateMessage.js (source mirrored, #1451)
- ./commands/fork/index.js (unmirrored /fork dropped, #1451)
The bundle guard flagged all four as stale allowlist entries. Remove
them and refresh the guard rationale comment, which described the
pre-#1399 specifier-string scanner; the scanner now keys per importer.
* fix(history-snip): expose snip id on pure tool-result messages
appendMessageTagToUserMessage() only appended the [id:...] tag to a
string body or an existing text block. A user message that is purely
tool_result blocks (the normal shape for large Read/Bash outputs) has
no text block, so it returned unchanged and carried no visible id. Those
are exactly the highest-value snip targets the feature prompts the model
to remove, yet the model had no id to reference them by.
Append a dedicated text block holding the tag when a tool-result-only
message has no text block. The tool_result block is left intact, so snip
pairing is unaffected, and the tag lands on the API-bound copy only.
Export the function and add colocated tests covering string body, text
block, the pure tool_result case, and meta passthrough.
* fix(build): key bundle-stub guard on repo-relative path, not basename
The guard canonicalized every missing-module-stub marker to its basename
before checking the allowlist, so a future stub named constants.ts (or
cachedMCConfig.ts, MonitorMcpDetailDialog.ts) from any other directory
would be treated as allowlisted and slip past the guard — the exact
regression class the guard exists to catch.
Post-#1399 the per-importer scanner records each stub as the resolved
absolute source path, which differs across build hosts only by the
repo-root prefix. So key on the repo-relative path from src/ onward
(without extension): stable across hosts yet path-specific, so a stub
cannot mask a same-named file elsewhere. Drop the now-moot basename
collision guard and store the allowlist as repo-relative keys.
* fix(history-snip): describe snip as a queued, refusable request
SnipTool's tool result said "Marked N message(s) for removal. They will
be removed from context before the next model call" based only on the
count of input IDs. But snipCompactIfNeeded() can refuse the exact
request on the next turn: it keeps a tool_result whose paired tool_use
would survive (snipping it would orphan the tool call), freeing 0 tokens
and emitting no boundary. The model was told the output would be removed,
then saw it still in context with no failure signal, so it treated a
structural no-op as a successful context reduction.
Reword the tool result to describe the snip as a queued request that may
be refused, name the one refusal condition (would orphan a paired tool
call, e.g. one result from a parallel-tool turn), and give the model the
observable signal and repair: a kept message re-shows its [id:...] tag
next turn (tags are re-applied every API-prep pass), and snipping all of
that turn's tool results together removes them cleanly.
Add SnipTool.test.ts pinning the queued/refusable wording.
* test(history-snip): import UserMessage from its canonical module
messages.snipTag.test.ts imported UserMessage from ../query.js, which
imports the type but does not re-export it (TS2459). Import it from
../types/message.js, the canonical source messages.ts itself uses, so
the snip test files typecheck cleanly.
* fix(history-snip): make snip id tag injection idempotent
appendMessageTagToUserMessage() documents that it only mutates the
API-bound copy, but query.ts builds the next loop state's toolResults
from normalizeMessagesForAPI([update.message]) (query.ts:1589) and stores
that normalized, already-tagged output into state.messages
(query.ts:1976). With HISTORY_SNIP enabled the tag is carried forward as
conversation state, so the next turn re-normalizes it and appends the
same [id:...] a second time. In multi-tool agent loops every prior tool
result accumulates another duplicate tag each iteration, bloating context
and showing the model repeated IDs that are meant to be an API-projection
affordance only.
Guard the append: if the message already carries its own [id:<id>] token
(string body, last text block, or the dedicated tool_result text block),
return it unchanged. The token is derived from the message's own uuid, so
its presence means it was already tagged. Adds 3 idempotency tests.
* fix(history-snip): expose every parallel-tool sibling id before merge
normalizeMessagesForAPI tagged snip [id:] markers only after merging
consecutive user messages. A parallel-tool assistant turn yields several
adjacent tool_result user messages; the merge keeps just the first
operand's uuid, so on the resume/reload path (where the persisted
transcript is the untagged original) only the first sibling's id reached
the model. snipCompactIfNeeded refuses to drop one result of such a turn
(it would orphan the surviving tool_use), so the model needed every
sibling's id to request the whole-turn removal the snip prompt instructs,
and could never form it: a permanent no-op.
Inject the tag per user message before the merge instead, so each
sibling carries its own [id:] and joinTextAtSeam preserves them all,
matching the live path where each result is tagged at push time. The
post-merge sweep stays (idempotent) to tag user messages synthesized
during normalization (local_command, attachments).
Test: merging tagged parallel siblings keeps every sibling id and both
tool_result blocks.
* test(history-snip): type snip-replay test ids as UUID
loadTranscriptFile() returns Map<UUID, TranscriptMessage>, but the test
id() helper returned plain string, so every messages.has/get/
buildConversationChain call in the persisted-snip replay test raised a
TS2345 against the UUID-keyed map. Type id() as UUID (casting the literal
once at the source) so the new replay coverage does not add touched-path
typecheck debt. Also clears the same error cluster in the pre-existing
compact-boundary tests that share the helper.
* docs(history-snip): drop removed /force-snip from setMessages comment
The QueryEngine setMessages comment cited /force-snip as its example of a
message-mutating slash command, but that command was removed. Point the
example at /clear (src/commands/clear/conversation.ts), which still mutates
the message array via setMessages, so the comment stays accurate.
* refactor(history-snip): type SnipBoundaryMessage removedUuids as string[]
removedUuids holds message UUID strings throughout the snip feature, but
the SnipBoundaryMessage prop typed it as unknown[]. Narrow it to string[]
so the type carries intent and the test fixture no longer needs an
`as never` cast to satisfy the prop (the cast bypassed type checking and
could have hidden a real fixture/prop mismatch).
* fix(history-snip): drop stale cachedMCConfig stub-allowlist entry
cachedMCConfig.ts now exists in the tree and bundles as real code, so it
is no longer emitted as a missing-module stub. The grandfathered baseline
listed it among acceptable stubs, which made the new guard print a stale
warning and, worse, would silently accept a future reintroduced
cachedMCConfig stub as known debt instead of flagging it. Drop the entry so
the allowlist matches the actual bundle (VerifyPlanExecutionTool/constants
and MonitorMcpDetailDialog).
* fix(history-snip): guard paired snip drops and report queued count
Two CodeRabbit findings on the snip compaction path:
- Mixed-content turns: the inferred paired-drop ran its .every() check over
filtered tool blocks only, so an assistant turn like [text, tool_use] (or a
user [tool_result, text]) was treated as fully droppable and its text was
silently removed when the paired half was snipped. Require the whole message
to be tool blocks before an inferred drop; otherwise treat the snip as a
no-op (the explicit-snip path, where the model deliberately targets a message,
is unchanged and still removes wholesale).
- Queued count: markForSnip only enqueues short IDs it can resolve against the
conversation, but SnipTool reported sniped = input.message_ids.length, which
overstated the result when IDs were stale or unresolvable. markForSnip now
returns the distinct resolved UUIDs and SnipTool reports that length.
* fix(history-snip): align snip prompt with queued-not-guaranteed contract
The tool description told the model snipped IDs are "permanently remove[d]
... before the next model call", but snipCompactIfNeeded queues the request
and keeps a message when removing it would orphan a paired tool_use (the
tool_result already says so). Match the description to that contract so the
model does not treat a structural no-op as a guaranteed removal.
|
||
|
|
b900364dbe |
fix(fork): render forked-worker messages, drop unmirrored /fork command (#1451)
* fix(fork): add UserForkBoilerplateMessage, drop unmirrored /fork command FORK_SUBAGENT ships enabled, which made two missing-module paths live: 1. UserTextMessage renders <UserForkBoilerplateMessage> whenever a user message contains <fork-boilerplate> (produced by forkSubagent.buildChildMessage). No source file existed, so the build stubbed the import to a noop default and the named component was undefined, crashing the render of any forked-worker message. 2. The /fork slash command required ./commands/fork/index.js, whose source was never mirrored; its noop .default was spread into the command list, registering a command with no name/description/call. Add the component, rendering a compact dimmed marker with just the directive (parsed off FORK_DIRECTIVE_PREFIX) instead of dumping the verbose worker rules block into the transcript. Remove the /fork registration: the implicit-fork machinery (AgentTool/forkSubagent) is present and keeps working; only the unmirrored slash command is dropped. Verified: both stubs gone from dist/cli.mjs, the real component is bundled, smoke passes, and component + commands tests pass. * docs(fork): align /fork contract with implicit-fork-only behavior Removing the unmirrored /fork command left two stale contract references: - forkSubagent.ts claimed '/fork <directive> slash command is available'. - branch/index.ts dropped /branch's 'fork' alias when FORK_SUBAGENT was on (on the assumption a dedicated /fork command existed), so the build had neither the command nor the alias and /fork resolved to nothing. Restore /branch as the unconditional owner of the 'fork' alias (its historical pre-FORK_SUBAGENT behavior, honoring the original 'always have a fork entry point' intent), drop the now-unused feature import, and correct the forkSubagent doc to state the slash command is not in this build and forking is implicit. |
||
|
|
1d48f8e855 |
test(build): assert WebFetch binds the real SSRF guard in the bundle (#1450)
#1399 already fixed the specifier-collision class by tracking missing relative imports per importer, which also resolves the WebFetch ssrfGuard case (the test-file string literal now only stubs the test importer, never WebFetch). The remaining gap is bundle-level coverage: the existing security-hardening test reads source only and would pass even if the shipped CLI bundle had stubbed the guard to a noop. Rebase onto current main (dropping the now-redundant scanner change) and add a dist/cli.mjs assertion alongside the /dream regression test: the real ssrfGuard blocked-address error is present and ssrfGuard is not replaced by a missing-module stub. |
||
|
|
f111eaa1b3 |
feat: enable MCP_SKILLS — discover skill:// resources as invocable skills (#1408)
* feat(mcp-skills): implement MCP skill discovery via skill:// resources
- mcpSkills.ts: fetchMcpSkillsForClient — lists MCP resources, filters skill://
URIs, reads each via resources/read, parses frontmatter, builds skill commands
with loadedFrom/source: 'mcp'. Memoized per server name (LRU, size 20).
- isSkillResource: pure helper to detect skill:// URI scheme
- deriveMcpSkillName: namespaced name builder (mcp__<server>__<name>)
- Enable MCP_SKILLS: true in scripts/build.ts
All call sites, cache-invalidation paths, and consumers were already wired
behind feature('MCP_SKILLS'). Only the module itself was missing. Fixes the
"fetchMcpSkillsForClient is not a function" crash (#856) when the flag was
force-enabled without the module present.
* fix(mcp-skills): discard hooks frontmatter from remote MCP skills
A skill:// resource's hooks frontmatter was carried through the
parseSkillFrontmatterFields spread into the Command, and the slash-command
path registered command.hooks as session hooks on invocation. This let any
connected MCP server install local command hooks that later run shell in the
user's workspace, bypassing the loadedFrom === 'mcp' inline-shell guard by
moving the payload into frontmatter hooks instead of the markdown body.
Discard hooks at the MCP construction site so untrusted remote skills can
never become registrable session hooks.
* fix(mcp-skills): discard allowed-tools frontmatter from remote MCP skills
Like hooks, a skill:// resource's allowed-tools frontmatter flowed through
the parseSkillFrontmatterFields spread into the Command. On the user-typed
slash path (/mcp__server__skill) those tools are written into
alwaysAllowRules.command, so a remote MCP server could auto-approve tool
calls (e.g. Bash) that its own skill body then drives the model to make —
with no permission prompt. The inline-shell guard for loadedFrom === 'mcp'
does not cover this.
Discard allowed-tools at the MCP construction site so remote skills can't
auto-grant tools; the model still prompts on each tool use. The model-invoked
SkillTool path already gates non-empty allowedTools via
skillHasOnlySafeProperties, but the slash path bypasses checkPermissions.
* fix(mcp-skills): skip @-mention attachment scanning for remote MCP skill bodies
A skill:// resource's markdown body flows through getMessagesForPromptSlashCommand
into getAttachmentMessages, which scans for @-mentions and MCP resource refs and
reads them before the model continues. skipSkillDiscovery only gates skill
discovery, not @-mention file reads, so a remote skill could embed @~/.ssh/config
or @.env and exfiltrate local file contents into the conversation with no tool
permission prompt — the same class as the already-stripped hooks/allowed-tools.
Gate the scan input on loadedFrom === 'mcp' (new attachmentScanInputForCommand
helper): the body still reaches the model verbatim, but its @-mentions are no
longer auto-read. Thread-level attachments are unaffected (input=null only gates
the user-input branch in getAttachments).
|