diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md new file mode 100644 index 00000000..6f4d7283 --- /dev/null +++ b/.papercuts/troubleshooting.md @@ -0,0 +1,6 @@ +# Troubleshooting + +- Pi 0.80.10 can choose the oldest oversized user turn as `firstKeptEntryId`, leaving both summary inputs empty and producing a no-op checkpoint. When the journal has a newer turn, retry `prepareCompaction` with a minimal retained-tail budget; still refuse the checkpoint if both summary inputs remain empty. +- `Session.getEntries()` includes abandoned branches. Synchronization markers must be read from `Session.getBranch()` or a rolled-back partial write can still look committed. +- Child-runtime unit tests load outside Electron. Keep usage accounting behind an injected callback (with a production-only dynamic import) instead of statically importing the Electron-backed singleton into the reusable child registry. +- A compaction model can overflow on the very history it is supposed to summarize. Strip binary images first and map-reduce serialized fragments within a conservative fraction of the model window before the final Pi checkpoint call. diff --git a/docs/plans/README.md b/docs/plans/README.md index 2f7812c6..8b616b7b 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -4,18 +4,18 @@ This directory is the source of truth for Aiden's implementation plans. The engi ## Active and partial -| Plan | Status | Current state | -| -------------------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | -| [Compaction](compaction-plan.md) | Partial | Pi-native semantic checkpoints now ship; durable memory, summary validation, provider-native paths, and measured rollout tuning remain open. | -| [Designer Mode](designer-mode-plan.md) | Planned | Phase 0 validation has not started in the runtime. | -| [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Partial | Stored Pi catalogs, cache-only hydration, and explicit provider refresh ship; remote overlays for otherwise-static providers remain open. | -| [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | -| [Performance, Stability, Battery, and Efficiency](performance-stability-efficiency-plan.md) | Planned | Whole-app source audit is complete; implementation starts with instrumentation, durable state, and hard memory bounds. | -| [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, refresh, and voice credential lookup ship; custom composition, provenance, scalable UX, and rollout cleanup remain. | -| [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, and the canonical Phase 7B1 storage seam are complete; app-lifetime coordinator activation is next. | -| [Taracodlab Learnings](taracodlab-learnings-plan.md) | Partial | Phases A–B and D, plus core Phase E, are implemented; the remaining roadmap is open. | -| [Update, Microphone, and Computer Use Hardening](update-microphone-computer-use-hardening-plan.md) | Partial | Installed acceptance found a silent stalled download; observable full-download recovery is implemented, while repaired-build → next-release and clean-TCC acceptance remain. | +| Plan | Status | Current state | +| -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | +| [Compaction](compaction-plan.md) | Partial | Pi-native checkpoints plus lifecycle, crash recovery, bounded map-reduce, and structural summary validation ship; durable memory, provider-native paths, semantic audits/repair, and measured rollout tuning remain open. | +| [Designer Mode](designer-mode-plan.md) | Planned | Phase 0 validation has not started in the runtime. | +| [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Partial | Stored Pi catalogs, cache-only hydration, and explicit provider refresh ship; remote overlays for otherwise-static providers remain open. | +| [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | +| [Performance, Stability, Battery, and Efficiency](performance-stability-efficiency-plan.md) | Planned | Whole-app source audit is complete; implementation starts with instrumentation, durable state, and hard memory bounds. | +| [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, custom provider composition, canonical assistant provenance, and voice credential lookup ship; scalable UX and rollout cleanup remain. | +| [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, and the canonical Phase 7B1 storage seam are complete; app-lifetime coordinator activation is next. | +| [Taracodlab Learnings](taracodlab-learnings-plan.md) | Partial | Phases A–B and D, plus core Phase E, are implemented; the remaining roadmap is open. | +| [Update, Microphone, and Computer Use Hardening](update-microphone-computer-use-hardening-plan.md) | Partial | Installed acceptance found a silent stalled download; observable full-download recovery is implemented, while repaired-build → next-release and clean-TCC acceptance remain. | ## Completed diff --git a/docs/plans/compaction-plan.md b/docs/plans/compaction-plan.md index b3081414..70dd1816 100644 --- a/docs/plans/compaction-plan.md +++ b/docs/plans/compaction-plan.md @@ -1,12 +1,12 @@ # Best overall approach -# Aiden delivery status — 2026-08-06 +# Aiden delivery status — 2026-08-14 Aiden now ships the Pi-native baseline described by this research plan. The implementation is pinned to the installed `@earendil-works/pi-agent-core@0.80.10` behavior and delegates summary prompting, cut points, retained-tail reconstruction, split turns, repeated-summary updates, and tool-pair safety to Pi core. -The delivered boundary includes append-only private chat journals, pre-prompt and post-response checks, proactive threshold compaction, one compact-and-retry attempt for context overflow, failure-safe checkpoint commits, child-agent parity, bounded renderer activity, and journal removal with chat deletion. The pre-existing deterministic `generation-context` transform remains as an emergency request-safety layer for pathological payloads and static prompt/tool overhead. +The delivered boundary includes append-only private chat journals, current-turn-aware pre-prompt and post-response checks, proactive threshold compaction, one compact-and-retry attempt for context overflow, crash-recoverable journal transactions, failure-safe checkpoint commits, child-agent parity, bounded renderer activity, corrupt/orphan journal recovery, model-neutral historical images, structurally validated summaries, and bounded map-reduce when the summarizer input itself is too large. The pre-existing deterministic `generation-context` transform remains an emergency request-safety layer for pathological payloads and static prompt/tool overhead; whenever it reduces a request, Aiden now forces a durable semantic checkpoint afterward. -The broader plan intentionally remains Partial. Durable cross-task memory, independent summary-quality validation and repair, provider-native compaction, replay benchmarks, rollout telemetry, and per-model policy tuning remain follow-on work rather than being implied by the Pi parity delivery. +The broader plan intentionally remains Partial. Durable cross-task memory, semantic identifier/latest-request audits with repair, provider-native compaction, replay benchmarks, rollout telemetry, and per-model policy tuning remain follow-on work rather than being implied by the Pi parity delivery. I treated **“Py” as Pi**, and I also included **PydanticAI**, because its newer tiered-compaction design captures many of the best ideas in one framework. @@ -23,11 +23,11 @@ The strongest approach is **not** “summarize the entire conversation when the My recommended design combines: -* **PydanticAI’s tiered orchestration** -* **OpenCode and Pi’s recent-tail and turn-boundary handling** -* **OpenClaw’s memory flush and summary quality audit** -* **Hermes’s configurable context-engine abstraction and dual trigger system** -* **Codex’s provider-native compaction lifecycle when the backend supports it** +- **PydanticAI’s tiered orchestration** +- **OpenCode and Pi’s recent-tail and turn-boundary handling** +- **OpenClaw’s memory flush and summary quality audit** +- **Hermes’s configurable context-engine abstraction and dual trigger system** +- **Codex’s provider-native compaction lifecycle when the backend supports it** --- @@ -46,11 +46,11 @@ My recommended design combines: OpenCode currently uses several distinct mechanisms rather than one destructive summary operation: -* Older completed tool results can be marked compacted while a protected recent tool-output region remains. -* A recent tail is selected by turns and token budget. -* The preserved tail can include part of an oversized turn when necessary. -* Media is stripped and tool output is truncated before asking the compaction model to summarize. -* The previous summary is supplied as an anchor and updated rather than blindly stacking summaries. +- Older completed tool results can be marked compacted while a protected recent tool-output region remains. +- A recent tail is selected by turns and token budget. +- The preserved tail can include part of an oversized turn when necessary. +- Media is stripped and tool output is truncated before asking the compaction model to summarize. +- The previous summary is supplied as an anchor and updated rather than blindly stacking summaries. Its summary contract is explicitly operational: objective, important details, work state, next move, and relevant files. It also instructs the model to preserve exact paths, commands, symbols, errors, URLs, and identifiers. @@ -70,8 +70,8 @@ The entry records the summary, first retained entry, token count before compacti Pi also handles two cases that many simple implementations overlook: -* It avoids separating tool results from their corresponding calls. -* When one enormous turn cannot fit, it supports a split-turn summary rather than discarding the entire turn. +- It avoids separating tool results from their corresponding calls. +- When one enormous turn cannot fit, it supports a split-turn summary rather than discarding the entire turn. It tracks files read and modified cumulatively across repeated compactions and branch summaries. @@ -81,8 +81,8 @@ It tracks files read and modified cumulatively across repeated compactions and b Hermes separates compression into two levels: -* An in-loop compressor for normal context management. -* A higher-threshold gateway safety net that catches sessions which reach the agent already too large. +- An in-loop compressor for normal context management. +- A higher-threshold gateway safety net that catches sessions which reach the agent already too large. Its main compressor follows a head-middle-tail architecture: @@ -110,18 +110,18 @@ Before compaction, it can run a silent **memory flush** that asks the agent to w Its pruning system is cache-aware: -* It can delay pruning while a provider prompt cache is still useful. -* It first soft-trims large tool outputs. -* It hard-clears them only when pressure remains high and enough tokens will actually be reclaimed. -* It protects recent assistant turns and bootstrap context. +- It can delay pruning while a provider prompt cache is still useful. +- It first soft-trims large tool outputs. +- It hard-clears them only when pressure remains high and enough tokens will actually be reclaimed. +- It protects recent assistant turns and bootstrap context. Most importantly, OpenClaw treats summary generation as an output requiring verification. Its safeguard contract requires sections for: -* decisions, -* open work, -* constraints, -* pending user asks, -* exact identifiers. +- decisions, +- open work, +- constraints, +- pending user asks, +- exact identifiers. It checks that required sections exist, opaque identifiers survived, and the latest user request is represented. @@ -185,14 +185,14 @@ The full event history should remain recoverable. Do not replace the original transcript with the summary. Instead, append a compaction checkpoint containing: -* checkpoint ID, -* transcript range covered, -* retained-tail boundary, -* summary, -* token counts before and after, -* model and prompt version, -* validation result, -* relevant structured metadata. +- checkpoint ID, +- transcript range covered, +- retained-tail boundary, +- summary, +- token counts before and after, +- model and prompt version, +- validation result, +- relevant structured metadata. This makes compaction reversible, debuggable, and auditable. @@ -218,14 +218,14 @@ A checkpoint is a **task handoff**, not a generic chat summary. Its purpose is to let the next model continue the work correctly. It should communicate: -* current objective, -* current task state, -* decisions already made, -* unresolved work, -* important constraints, -* active blockers, -* exact references needed for continuity, -* immediate next action. +- current objective, +- current task state, +- decisions already made, +- unresolved work, +- important constraints, +- active blockers, +- exact references needed for continuity, +- immediate next action. Use a fixed schema rather than unstructured prose. A JSON object can be stored internally and rendered to Markdown for models that respond better to natural text. @@ -253,42 +253,42 @@ The exact policy should be configurable, but this is a strong default. These should survive without paraphrasing whenever feasible: -* System, developer, safety, and permission instructions -* The most recent unresolved user request -* Recent working turns -* Exact IDs, paths, hashes, ports, URLs, dates, commands, and error strings -* Tool-call and tool-result relationships -* User corrections and explicit reversals -* Approval status and action boundaries -* Current active artifact references -* Any information whose wording has contractual or operational significance +- System, developer, safety, and permission instructions +- The most recent unresolved user request +- Recent working turns +- Exact IDs, paths, hashes, ports, URLs, dates, commands, and error strings +- Tool-call and tool-result relationships +- User corrections and explicit reversals +- Approval status and action boundaries +- Current active artifact references +- Any information whose wording has contractual or operational significance ## Preserve semantically These can be converted into structured state: -* Goal and desired outcome -* Completed work -* Current work -* Decisions and rationale -* Constraints and preferences -* Known blockers -* Failed approaches worth avoiding -* Tests run and high-level results -* Next actions +- Goal and desired outcome +- Completed work +- Current work +- Decisions and rationale +- Constraints and preferences +- Known blockers +- Failed approaches worth avoiding +- Tests run and high-level results +- Next actions ## Externalize These should normally move out of the prompt and remain retrievable: -* Full source files -* Long shell output -* Search-result dumps -* Large API responses -* Generated patches -* Images and audio already processed -* Detailed reports and documents -* Old execution traces +- Full source files +- Long shell output +- Search-result dumps +- Large API responses +- Generated patches +- Images and audio already processed +- Detailed reports and documents +- Old execution traces The checkpoint should retain a reference plus a short statement of why the artifact matters. @@ -296,15 +296,15 @@ The checkpoint should retain a reference plus a short statement of why the artif These are usually safe to eliminate from the prompt view: -* Duplicate file reads -* Repeated tool results -* Superseded plans -* Retries containing identical user input -* Routine acknowledgements -* Successful low-value command output -* Old reasoning that produced a decision already recorded -* Previous summaries that have been incorporated into a newer validated summary -* Stale speculative branches +- Duplicate file reads +- Repeated tool results +- Superseded plans +- Retries containing identical user input +- Routine acknowledgements +- Successful low-value command output +- Old reasoning that produced a decision already recorded +- Previous summaries that have been incorporated into a newer validated summary +- Stale speculative branches The original records should still remain in the transcript or artifact store. @@ -331,10 +331,10 @@ Then run a trigger ladder. Run inexpensive cleanup before significant pressure develops: -* deduplicate repeated reads, -* remove stale media payloads, -* clamp abnormal single messages, -* trim large old tool outputs. +- deduplicate repeated reads, +- remove stale media payloads, +- clamp abnormal single messages, +- trim large old tool outputs. This can run per request, but only apply mutations when the token savings justify invalidating provider caches. @@ -377,14 +377,14 @@ Keep this distinct from normal compaction so you can measure how often your regu Calculate token usage for each category: -* stable instructions, -* memory, -* checkpoint, -* recent messages, -* tool schemas, -* tool calls/results, -* attachments, -* expected response. +- stable instructions, +- memory, +- checkpoint, +- recent messages, +- tool schemas, +- tool calls/results, +- attachments, +- expected response. Use the active model’s tokenizer when available. Character estimates are acceptable only as a fallback. @@ -392,12 +392,12 @@ Use the active model’s tokenizer when available. Character estimates are accep Before selecting boundaries: -* repair malformed tool pairs, -* normalize message roles, -* replace stale binary/media content with references, -* mark duplicate retry messages, -* identify previous compaction checkpoints, -* classify messages by source and authority. +- repair malformed tool pairs, +- normalize message roles, +- replace stale binary/media content with references, +- mark duplicate retry messages, +- identify previous compaction checkpoints, +- classify messages by source and authority. This prevents the summarizer from receiving an invalid or misleading transcript. @@ -425,12 +425,12 @@ Walk backward from the newest message using a token budget. Boundary rules: -* Prefer complete user turns. -* Never separate a tool result from the call that created it. -* Preserve a minimum number of recent turns. -* Allow a controlled split-turn mode for a single enormous agent run. -* Record the exact first retained event ID. -* Use token budgets rather than message counts as the primary mechanism. +- Prefer complete user turns. +- Never separate a tool result from the call that created it. +- Preserve a minimum number of recent turns. +- Allow a controlled split-turn mode for a single enormous agent run. +- Record the exact first retained event ID. +- Use token budgets rather than message counts as the primary mechanism. ## Stage E: Build the summary input @@ -463,10 +463,10 @@ Generate into temporary state. Nothing should be removed yet. A dedicated summarization model can be used, but it must: -* have enough context for the selected input, -* reliably follow the schema, -* preserve literal identifiers, -* cost less only when quality remains acceptable. +- have enough context for the selected input, +- reliably follow the schema, +- preserve literal identifiers, +- cost less only when quality remains acceptable. A smaller model is not automatically a good summarizer. @@ -476,16 +476,16 @@ Validation should be partly deterministic. Check: -* required sections or schema fields exist, -* summary is non-empty, -* latest unresolved user request is represented, -* required exact identifiers remain, -* current objective exists, -* open work exists when the task is unfinished, -* summary does not introduce unknown files or completed actions, -* tool and artifact references remain valid, -* compacted context is actually smaller, -* expected next request now fits. +- required sections or schema fields exist, +- summary is non-empty, +- latest unresolved user request is represented, +- required exact identifiers remain, +- current objective exists, +- open work exists when the task is unfinished, +- summary does not introduce unknown files or completed actions, +- tool and artifact references remain valid, +- compacted context is actually smaller, +- expected next request now fits. A second model can provide an optional semantic audit, but it should not replace deterministic checks. @@ -506,12 +506,12 @@ Never commit an empty summary. Only after validation: -* append the checkpoint, -* mark the covered event range, -* update the active context boundary, -* record token and quality metrics, -* preserve the old transcript, -* increment the compaction generation. +- append the checkpoint, +- mark the covered event range, +- update the active context boundary, +- record token and quality metrics, +- preserve the old transcript, +- increment the compaction generation. The session should either use the old valid context or the new valid context—never a half-written mixture. @@ -534,22 +534,22 @@ ContextEngine Possible implementations: -* `TieredSummaryEngine` -* `ProviderNativeEngine` -* `SlidingWindowEngine` -* `RetrievalBackedEngine` -* `TokenBudgetResetEngine` -* future lossless or hierarchical engines +- `TieredSummaryEngine` +- `ProviderNativeEngine` +- `SlidingWindowEngine` +- `RetrievalBackedEngine` +- `TokenBudgetResetEngine` +- future lossless or hierarchical engines The provider adapter should tell the engine: -* context-window size, -* maximum output, -* tokenizer, -* prompt-cache behavior, -* native compaction support, -* supported message/tool structure, -* overflow error patterns. +- context-window size, +- maximum output, +- tokenizer, +- prompt-cache behavior, +- native compaction support, +- supported message/tool structure, +- overflow error patterns. This resembles Hermes’s configurable context-engine approach while avoiding provider-specific logic inside the main agent loop. @@ -561,13 +561,13 @@ Compaction can reduce token use while simultaneously destroying cache reuse. Treat cache behavior as part of the planner: -* Keep the stable system prefix unchanged. -* Avoid modifying old messages on every turn. -* Perform pruning in meaningful batches. -* Require a minimum expected token reduction before invalidating a cached prefix. -* Prefer append-only checkpoints. -* Track cache read and write tokens independently from ordinary input tokens. -* Use provider-native context editing when it preserves server-side state more efficiently. +- Keep the stable system prefix unchanged. +- Avoid modifying old messages on every turn. +- Perform pruning in meaningful batches. +- Require a minimum expected token reduction before invalidating a cached prefix. +- Prefer append-only checkpoints. +- Track cache read and write tokens independently from ordinary input tokens. +- Use provider-native context editing when it preserves server-side state more efficiently. OpenClaw’s pruning system explicitly delays edits around cache TTLs, while Hermes notes that changes in the middle of the prompt invalidate the later cache prefix. @@ -581,14 +581,14 @@ Do not evaluate compaction only by token reduction. Create long synthetic agent runs containing: -* multiple user corrections, -* exact IDs and paths, -* a current unresolved ask, -* several completed and incomplete tasks, -* large tool outputs, -* failed commands, -* branching plans, -* permission-sensitive instructions. +- multiple user corrections, +- exact IDs and paths, +- a current unresolved ask, +- several completed and incomplete tasks, +- large tool outputs, +- failed commands, +- branching plans, +- permission-sensitive instructions. After compaction, give the agent a continuation task and measure whether it behaves correctly. @@ -627,11 +627,11 @@ Track: Build: -* provider-aware token measurement, -* per-message and per-category usage, -* projected next-turn budget, -* overflow detection, -* compaction telemetry. +- provider-aware token measurement, +- per-message and per-category usage, +- projected next-turn budget, +- overflow detection, +- compaction telemetry. **Exit condition:** You can explain exactly why a session compacted. @@ -639,11 +639,11 @@ Build: Introduce: -* append-only transcript events, -* compaction checkpoint events, -* retained-boundary IDs, -* checkpoint generations, -* active-context reconstruction. +- append-only transcript events, +- compaction checkpoint events, +- retained-boundary IDs, +- checkpoint generations, +- active-context reconstruction. **Exit condition:** Every compacted session can be reconstructed or rolled back. @@ -651,11 +651,11 @@ Introduce: Implement: -* oversized-message clamping, -* repeated-read deduplication, -* stale media replacement, -* soft and hard tool-result trimming, -* tool-pair repair. +- oversized-message clamping, +- repeated-read deduplication, +- stale media replacement, +- soft and hard tool-result trimming, +- tool-pair repair. **Exit condition:** Large tool-heavy sessions survive longer without semantic summarization. @@ -663,12 +663,12 @@ Implement: Add: -* fixed summary schema, -* previous-checkpoint reconciliation, -* recent-tail selection, -* split-turn support, -* staged summarization, -* optional dedicated summarizer. +- fixed summary schema, +- previous-checkpoint reconciliation, +- recent-tail selection, +- split-turn support, +- staged summarization, +- optional dedicated summarizer. **Exit condition:** Checkpoints consistently let a fresh model continue the task. @@ -676,11 +676,11 @@ Add: Add: -* pre-compaction memory extraction, -* durable versus working memory, -* provenance and expiration metadata, -* retrieval of externalized artifacts, -* once-per-cycle flush tracking. +- pre-compaction memory extraction, +- durable versus working memory, +- provenance and expiration metadata, +- retrieval of externalized artifacts, +- once-per-cycle flush tracking. **Exit condition:** Long-term facts do not depend on a task summary surviving forever. @@ -688,14 +688,14 @@ Add: Implement: -* structural validation, -* identifier checking, -* pending-ask coverage, -* contradiction checks, -* minimum token-reduction check, -* repair retry, -* model fallback, -* non-destructive failure. +- structural validation, +- identifier checking, +- pending-ask coverage, +- contradiction checks, +- minimum token-reduction check, +- repair retry, +- model fallback, +- non-destructive failure. **Exit condition:** No failed or empty summary can replace valid context. @@ -703,11 +703,11 @@ Implement: Add adapters for: -* native Responses compaction, -* server-side context editing, -* provider prompt caching, -* model-specific thresholds, -* local transcript reconciliation. +- native Responses compaction, +- server-side context editing, +- provider prompt caching, +- model-specific thresholds, +- local transcript reconciliation. **Exit condition:** Local and server-side context cannot silently diverge. @@ -715,11 +715,11 @@ Add adapters for: Build: -* replay benchmark, -* long-horizon task suite, -* compaction dashboard, -* per-model policy configuration, -* gradual rollout controls. +- replay benchmark, +- long-horizon task suite, +- compaction dashboard, +- per-model policy configuration, +- gradual rollout controls. **Exit condition:** Threshold and model choices are based on measured continuation quality, not intuition. diff --git a/docs/plans/completed/pi-native-compaction-plan.md b/docs/plans/completed/pi-native-compaction-plan.md index 59db032e..754d87ab 100644 --- a/docs/plans/completed/pi-native-compaction-plan.md +++ b/docs/plans/completed/pi-native-compaction-plan.md @@ -41,6 +41,35 @@ The Pi journal lives below Electron `userData`, is keyed by chat ID, and is neve All four delivery phases and their verification gates completed on 2026-08-06. +## Reliability hardening — 2026-08-14 + +A follow-up audit closed lifecycle gaps around the shipped boundary: + +- the exact enriched slash-skill user turn is now journaled before provider I/O; +- pre-prompt pressure includes the current user turn and zero-usage rehydrated history; +- deterministic emergency reduction forces a durable semantic checkpoint afterward; +- failed overflow attempts, including silent `length` overflows, are moved off the active journal branch before retry; +- journal message/marker batches roll back atomically on partial append failure; +- child prompts are journaled once and receive the same pre-prompt pressure check; +- empty summaries are rejected, small context windows receive bounded reserve/tail settings, and semantic checkpoints survive emergency pruning; +- overflow retries reset streamed renderer text, and terminal chat snapshots reach cache before animation handoff. + +The focused `test:compaction` gate now covers the core coordinator, emergency projection, child runtime, renderer stream reset, and terminal cache handoff. + +## Completeness hardening — 2026-08-15 + +A full Pi core/SDK integration audit closed the remaining correctness and durability gaps inside Aiden's shipped architecture: + +- foreground effectful tools now execute sequentially, cancellation reaches network and schedule mutations, and transient provider failures retry once from a durably abandoned attempt; +- long tool loops compact between provider turns, output-limit overflows recover safely, and terminal Pi content replaces incomplete stream projections exactly; +- journals use crash-recoverable transaction envelopes, validate and quarantine malformed duplicates, delete corrupt indexed data, reconcile generic orphans, and clean Pi session resources with chat deletion; +- historical images remain model-neutral in the journal and are projected per request, while compaction replaces binary payloads with continuity markers; +- oversized summarizer input uses bounded map-reduce, structurally malformed summaries cannot become checkpoints, and summary calls are included in usage accounting; +- canonical assistant protocol payloads preserve provider/API/model, reasoning/tool blocks, signatures, response identity, and usage across restart or provider switches without exposing that private payload to the renderer; +- custom endpoints now use Pi provider composition, generic Pi reasoning levels are honored, and one-hour cache-write usage is retained. + +Pi `AgentHarness` remains an architectural reference rather than Aiden's top-level runtime: Aiden intentionally owns Electron chat persistence, workspace authority, approval UI, renderer streaming, and child-agent supervision. Harness-only branch-tree UI, interactive foreground steer/follow-up UX, and image-generation surfaces remain deliberate product features, not hidden parity claims. + ## Phases ### Phase 1 — Native session and compaction controller diff --git a/main/handlers/chats.append.contract.test.ts b/main/handlers/chats.append.contract.test.ts index 8d843fad..80f7ff24 100644 --- a/main/handlers/chats.append.contract.test.ts +++ b/main/handlers/chats.append.contract.test.ts @@ -9,6 +9,20 @@ import { } from "../../renderer/shared/chat-message-contract.js"; const source = fs.readFileSync(new URL("./chats.ts", import.meta.url), "utf8"); +const projectionSource = fs.readFileSync( + new URL("../services/visible-chat-projection.ts", import.meta.url), + "utf8", +); + +test("private canonical Pi protocol never crosses the renderer chat boundary", () => { + assert.match( + projectionSource, + /const \{ pi: _privatePiProtocol, \.\.\.visible \} = message/u, + ); + assert.match(source, /chat: chatForRenderer\(chat\)/u); + assert.match(source, /return chatForRenderer\(chat\)/u); + assert.match(source, /return chatForRenderer\(copied\)/u); +}); test("indeterminate appends fence create and append for the renderer document", () => { const create = source.slice( @@ -184,5 +198,8 @@ test("append admission charges encoded image representation and metadata", () => }, { turnId: "turn-1", providerId: "provider", model: "model" }, ); - assert.ok(parsed.retainedBytes >= data.length + Buffer.byteLength("providermodel", "utf8")); + assert.ok( + parsed.retainedBytes >= + data.length + Buffer.byteLength("providermodel", "utf8"), + ); }); diff --git a/main/handlers/chats.ts b/main/handlers/chats.ts index 83351d47..6a966278 100644 --- a/main/handlers/chats.ts +++ b/main/handlers/chats.ts @@ -29,13 +29,20 @@ import { } from "../services/chat-append-commit.js"; import { appendReconciliationFailureMessage } from "../../renderer/shared/chat-message-contract.js"; import { ASSISTANT_WORKSPACE_ID } from "../../renderer/shared/assistant.js"; -import { parseAssistantChatCreate, parseChatCreate } from "./chat-create-params.js"; +import { + parseAssistantChatCreate, + parseChatCreate, +} from "./chat-create-params.js"; import { isChatCreateReconciliationRequiredError } from "../services/chat-store-core.js"; -import { parseChatCopyRequest, parseChatOnlyRequest } from "./chat-session-params.js"; +import { + parseChatCopyRequest, + parseChatOnlyRequest, +} from "./chat-session-params.js"; import { safeExportFileName, writeAidenChatExportForRenderer, } from "../services/chat-export.js"; +import { chatForRenderer } from "../services/visible-chat-projection.js"; function asString(value: unknown, name: string): string { if (typeof value !== "string" || value.length === 0) { @@ -48,7 +55,9 @@ export function registerChatHistoryHandlers(): void { let chatCopyActive = false; let chatExportActive = false; ipcMain.handle("chats:list", async (_event, workspaceId?: unknown) => - chatStore.list(typeof workspaceId === "string" && workspaceId ? workspaceId : undefined), + chatStore.list( + typeof workspaceId === "string" && workspaceId ? workspaceId : undefined, + ), ); ipcMain.handle("chats:get", async (_event, id: unknown) => { @@ -63,7 +72,7 @@ export function registerChatHistoryHandlers(): void { // retains a retry marker even if the one-shot settlement event was missed. reconciliationRequired ||= llmClient.isChatOwnedByInactiveRenderer(chatId); return { - chat, + chat: chatForRenderer(chat), reconciliation: reconciliationRequired ? { chatId, @@ -87,12 +96,15 @@ export function registerChatHistoryHandlers(): void { } const parsed = parseChatCreate(input); if (parsed.workspaceId === ASSISTANT_WORKSPACE_ID) { - throw new Error("Aiden Assistant chats require the Assistant chat creation path."); + throw new Error( + "Aiden Assistant chats require the Assistant chat creation path.", + ); } const mutationAdmission = parsed.workspaceId ? workspaceMutationGate.admit(parsed.workspaceId) : undefined; - let workspaceOperation: ReturnType | undefined; + let workspaceOperation: + ReturnType | undefined; try { workspaceOperation = parsed.workspaceId ? admitRendererOwnedWorkspaceOperation( @@ -101,12 +113,19 @@ export function registerChatHistoryHandlers(): void { parsed.workspaceId, ) : undefined; - if (parsed.workspaceId && !(await configStore.getWorkspace(parsed.workspaceId))) { + if ( + parsed.workspaceId && + !(await configStore.getWorkspace(parsed.workspaceId)) + ) { throw new Error("The selected workspace is no longer available."); } const assertCurrent = () => { - if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); - if (mutationAdmission?.signal.aborted || workspaceOperation?.signal.aborted) { + if (owner.isDestroyed()) + throw new Error("The renderer document is no longer active."); + if ( + mutationAdmission?.signal.aborted || + workspaceOperation?.signal.aborted + ) { throw new Error("The workspace changed before the chat was created."); } if (llmClient.requiresAppendReconciliation(owner.documentId)) { @@ -114,7 +133,9 @@ export function registerChatHistoryHandlers(): void { } }; try { - return await chatStore.create({ ...parsed, assertCurrent }); + return chatForRenderer( + await chatStore.create({ ...parsed, assertCurrent }), + ); } catch (error) { if (isChatCreateReconciliationRequiredError(error)) { llmClient.markAppendReconciliationRequired(owner.documentId); @@ -138,24 +159,28 @@ export function registerChatHistoryHandlers(): void { // creation from forging the persisted identity that main treats as mode. const owner = rendererDocumentOwner( event, - () => new Error("Assistant chats require the active application document."), + () => + new Error("Assistant chats require the active application document."), ); if (llmClient.requiresAppendReconciliation(owner.documentId)) { throw new Error(appendReconciliationFailureMessage("blocked")); } const parsed = parseAssistantChatCreate(input); const assertCurrent = () => { - if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + if (owner.isDestroyed()) + throw new Error("The renderer document is no longer active."); if (llmClient.requiresAppendReconciliation(owner.documentId)) { throw new Error(appendReconciliationFailureMessage("blocked")); } }; try { - return await chatStore.create({ - ...parsed, - workspaceId: ASSISTANT_WORKSPACE_ID, - assertCurrent, - }); + return chatForRenderer( + await chatStore.create({ + ...parsed, + workspaceId: ASSISTANT_WORKSPACE_ID, + assertCurrent, + }), + ); } catch (error) { if (isChatCreateReconciliationRequiredError(error)) { llmClient.markAppendReconciliationRequired(owner.documentId); @@ -168,11 +193,16 @@ export function registerChatHistoryHandlers(): void { } }); - ipcMain.handle("chats:rename", async (_event, id: unknown, title: unknown) => { + ipcMain.handle( + "chats:rename", + async (_event, id: unknown, title: unknown) => { await chatStore.rename(asString(id, "id"), asString(title, "title")); - }); + }, + ); - ipcMain.handle("chats:renameWithFoundationModels", async (_event, id: unknown) => + ipcMain.handle( + "chats:renameWithFoundationModels", + async (_event, id: unknown) => chatTitleService.renameWithFoundationModels(asString(id, "id")), ); @@ -193,13 +223,17 @@ export function registerChatHistoryHandlers(): void { try { finishCopy = llmClient.beginChatCopy(parsed.chatId); if (!finishCopy) { - throw new Error("Finish the current response or approval before copying this chat."); + throw new Error( + "Finish the current response or approval before copying this chat.", + ); } const source = await chatStore.get(parsed.chatId); if (!source) throw new Error("The chat is no longer available."); const workspaceId = persistedChatWorkspaceId(source.workspaceId); if (workspaceId === ASSISTANT_WORKSPACE_ID) { - throw new Error("Assistant chats cannot be copied into the main chat surface."); + throw new Error( + "Assistant chats cannot be copied into the main chat surface.", + ); } const mutationAdmission = workspaceMutationGate.admit(workspaceId); const workspaceOperation = admitRendererOwnedWorkspaceOperation( @@ -235,7 +269,7 @@ export function registerChatHistoryHandlers(): void { workspaceId: persistedChatWorkspaceId(copied.workspaceId), updatedAt: copied.updatedAt, }); - return copied; + return chatForRenderer(copied); } catch (error) { if (isChatCreateReconciliationRequiredError(error)) { llmClient.markAppendReconciliationRequired(owner.documentId); @@ -269,7 +303,9 @@ export function registerChatHistoryHandlers(): void { try { finishExport = llmClient.beginChatExport(chatId); if (!finishExport) { - throw new Error("Finish the current response or approval before exporting this chat."); + throw new Error( + "Finish the current response or approval before exporting this chat.", + ); } const chat = await chatStore.get(chatId); if (!chat) throw new Error("The chat is no longer available."); @@ -286,7 +322,8 @@ export function registerChatHistoryHandlers(): void { filters: [{ name: "Aiden chat", extensions: ["json"] }], properties: ["createDirectory", "showOverwriteConfirmation"], }); - if (result.canceled || !result.filePath) return { status: "cancelled" as const }; + if (result.canceled || !result.filePath) + return { status: "cancelled" as const }; if (owner.isDestroyed()) { throw new Error("The renderer document is no longer active."); } @@ -310,49 +347,72 @@ export function registerChatHistoryHandlers(): void { const nextWorkspaceId = asString(workspaceId, "workspaceId"); const finishMove = llmClient.beginChatWorkspaceChange(chatId); if (!finishMove) { - throw new Error("Finish or stop the current response before changing workspaces."); + throw new Error( + "Finish or stop the current response before changing workspaces.", + ); } try { if (!(await configStore.getWorkspace(nextWorkspaceId))) { throw new Error(`Workspace ${nextWorkspaceId} not found.`); } - return await chatStore.moveEmptyChatToWorkspace(chatId, nextWorkspaceId); + return chatForRenderer( + await chatStore.moveEmptyChatToWorkspace(chatId, nextWorkspaceId), + ); } finally { finishMove(); } }, ); - ipcMain.handle("chats:setComputerUse", async (event, id: unknown, enabled: unknown) => { + ipcMain.handle( + "chats:setComputerUse", + async (event, id: unknown, enabled: unknown) => { const owner = rendererDocumentOwner( event, - () => new Error("Computer Use settings require the active application document."), + () => + new Error( + "Computer Use settings require the active application document.", + ), ); const chatId = asString(id, "id"); - if (typeof enabled !== "boolean") throw new Error("Invalid Computer Use chat setting."); + if (typeof enabled !== "boolean") + throw new Error("Invalid Computer Use chat setting."); const release = llmClient.beginComputerUseSettingChange(chatId); if (!release) { - throw new Error("Finish or stop the current response before changing Computer Use."); + throw new Error( + "Finish or stop the current response before changing Computer Use.", + ); } const controller = new AbortController(); const removeInvalidation = owner.onInvalidated(() => - controller.abort(new Error("The renderer document is no longer active.")), + controller.abort( + new Error("The renderer document is no longer active."), + ), ); try { if (enabled) { const status = await computerUseStatus.status({ signal: controller.signal, }); - if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + if (owner.isDestroyed()) + throw new Error("The renderer document is no longer active."); if (!status.ready) throw new Error(status.detail); } - if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); - return await chatStore.setComputerUseEnabled(chatId, enabled, () => !owner.isDestroyed()); + if (owner.isDestroyed()) + throw new Error("The renderer document is no longer active."); + return chatForRenderer( + await chatStore.setComputerUseEnabled( + chatId, + enabled, + () => !owner.isDestroyed(), + ), + ); } finally { removeInvalidation(); release(); } - }); + }, + ); ipcMain.handle("chats:remove", async (_event, id: unknown) => { const chatId = asString(id, "id"); @@ -365,14 +425,24 @@ export function registerChatHistoryHandlers(): void { try { await subagentRunStore.deleteChat(chatId); } catch (error) { - logger.error("subagents", "Could not delete private subagent history.", error); + logger.error( + "subagents", + "Could not delete private subagent history.", + error, + ); throw new Error("Aiden could not delete this chat's subagent history."); } try { await piCompactionSessionStore.deleteChat(chatId); } catch (error) { - logger.error("pi", "Could not delete the private compaction journal.", error); - throw new Error("Aiden could not delete this chat's compaction history."); + logger.error( + "pi", + "Could not delete the private compaction journal.", + error, + ); + throw new Error( + "Aiden could not delete this chat's compaction history.", + ); } // remove() also reconciles an index entry whose payload is already // missing or corrupt, while propagating real filesystem failures. @@ -384,11 +454,17 @@ export function registerChatHistoryHandlers(): void { } finally { if (!releaseAdmission) { try { - releaseAdmission = !(await subagentRunStore.pendingChatDeletions()).includes(chatId); + releaseAdmission = !( + await subagentRunStore.pendingChatDeletions() + ).includes(chatId); } catch (error) { // An indeterminate durable state must keep generation admission // closed until restart reconciliation can safely finish the delete. - logger.error("subagents", "Could not inspect pending chat deletion state.", error); + logger.error( + "subagents", + "Could not inspect pending chat deletion state.", + error, + ); } } // A durable but incomplete intent keeps admission closed for this @@ -397,7 +473,9 @@ export function registerChatHistoryHandlers(): void { } }); - ipcMain.handle("chats:appendMessage", (event, id: unknown, message: unknown, meta?: unknown) => { + ipcMain.handle( + "chats:appendMessage", + (event, id: unknown, message: unknown, meta?: unknown) => { // Parse and project the entire renderer envelope synchronously. The raw // IPC objects are never captured by the asynchronous persistence frame. const parsed = parseChatAppend(id, message, meta); @@ -416,14 +494,17 @@ export function registerChatHistoryHandlers(): void { } = parsed; const owner = rendererDocumentOwner( event, - () => new Error("Chat messages require the active application document."), + () => + new Error("Chat messages require the active application document."), ); if (llmClient.requiresAppendReconciliation(owner.documentId)) { throw new Error(appendReconciliationFailureMessage("blocked")); } const turn = llmClient.beginChatTurn(chatId, turnId, owner.documentId); if (!turn) { - throw new Error("Wait for the previous response to finish saving before sending again."); + throw new Error( + "Wait for the previous response to finish saving before sending again.", + ); } turn.onReleased(owner.onInvalidated(turn.release)); try { @@ -438,12 +519,16 @@ export function registerChatHistoryHandlers(): void { return (async () => { let appended = false; try { - const authoritativeChat = skillReference ? await chatStore.get(chatId) : undefined; + const authoritativeChat = skillReference + ? await chatStore.get(chatId) + : undefined; if (skillReference && !authoritativeChat) { throw new Error("This chat is no longer available."); } if (!turn.isActive()) { - throw new Error("This message turn expired before it could be saved."); + throw new Error( + "This message turn expired before it could be saved.", + ); } const workspaceId = authoritativeChat ? persistedChatWorkspaceId(authoritativeChat.workspaceId) @@ -465,7 +550,8 @@ export function registerChatHistoryHandlers(): void { }); } const userMessageId = randomUUID(); - const isCurrent = () => turn.isActive() && workspaceAdmission?.signal.aborted !== true; + const isCurrent = () => + turn.isActive() && workspaceAdmission?.signal.aborted !== true; const append = (skill?: { provenance: { version: 1; @@ -513,15 +599,19 @@ export function registerChatHistoryHandlers(): void { }, { resolveFresh: (resolvedWorkspaceId, invocationId) => - skillRegistry.resolveFresh(resolvedWorkspaceId, invocationId), + skillRegistry.resolveFresh( + resolvedWorkspaceId, + invocationId, + ), isCurrent, - prepareLease: (prepared) => turn.prepareSkillInvocation(prepared), + prepareLease: (prepared) => + turn.prepareSkillInvocation(prepared), append, }, ) : await append(); appended = true; - return chat; + return chatForRenderer(chat); } catch (error) { if (isAppendReconciliationRequiredError(error)) { llmClient.markAppendReconciliationRequired(owner.documentId); @@ -535,7 +625,8 @@ export function registerChatHistoryHandlers(): void { turn.settleAsyncWork(); } })(); - }); + }, + ); ipcMain.handle("chats:abandonTurn", (event, id: unknown, turnId: unknown) => { const owner = rendererDocumentOwner( @@ -546,6 +637,10 @@ export function registerChatHistoryHandlers(): void { if (!isSafeSubagentIdentifier(parsedTurnId)) { throw new Error("Invalid chat message turn identifier."); } - return llmClient.abandonChatTurn(asString(id, "id"), parsedTurnId, owner.documentId); + return llmClient.abandonChatTurn( + asString(id, "id"), + parsedTurnId, + owner.documentId, + ); }); } diff --git a/main/index.ts b/main/index.ts index cef1be06..0cbfacaf 100644 --- a/main/index.ts +++ b/main/index.ts @@ -56,7 +56,10 @@ import type { import { devLogPath, initDevLog } from "./services/dev-log.js"; import { scheduleService } from "./services/schedule-service.js"; import { registerAppPathOpener } from "./services/app-navigation.js"; -import { effectiveBindings, migrateLegacyKeybindings } from "../renderer/shared/keybindings.js"; +import { + effectiveBindings, + migrateLegacyKeybindings, +} from "../renderer/shared/keybindings.js"; import type { NotificationChannel } from "../renderer/preload-channels.js"; import type { AppSettings } from "./services/types.js"; import { ONBOARDING_COMPLETE_STORAGE_KEY } from "../renderer/shared/onboarding.js"; @@ -112,16 +115,19 @@ let closeGuard = { path: undefined as string | undefined, saving: false, }; -let protectedAction: "close" | "quit" | "reload" | "onboarding-reset" | null = null; +let protectedAction: "close" | "quit" | "reload" | "onboarding-reset" | null = + null; let forceAppQuit = false; let cleanupStarted = false; let lifecycleCheckInFlight = false; let shutdownStarted = false; let installUpdateOnQuit = false; let pendingPackagedSubagentSoakReceipt: SubagentPackagedSoakSession | undefined; -const disposeAppUpdateStateSubscription = appUpdateService.subscribe((snapshot) => { - ipcMain.broadcast("app:update-state", snapshot); -}); +const disposeAppUpdateStateSubscription = appUpdateService.subscribe( + (snapshot) => { + ipcMain.broadcast("app:update-state", snapshot); + }, +); const SUBAGENT_PACKAGED_SOAK_WAIT_MS = 30_000; const SUBAGENT_PACKAGED_SOAK_POLL_MS = 25; @@ -178,7 +184,10 @@ function hasCloseGuard(): boolean { return closeGuard.dirty || closeGuard.gitBusy || closeGuard.saving; } -function confirmProtectedAction(window: BrowserWindow, action: "close" | "reload"): boolean { +function confirmProtectedAction( + window: BrowserWindow, + action: "close" | "reload", +): boolean { if (closeGuard.gitBusy) { dialog.showMessageBoxSync(window, { type: "info", @@ -216,7 +225,9 @@ function confirmProtectedAction(window: BrowserWindow, action: "close" | "reload : "Reloading Aiden will permanently discard those edits.", buttons: [ "Keep Editing", - action === "close" ? "Discard Edits and Close" : "Discard Edits and Reload", + action === "close" + ? "Discard Edits and Close" + : "Discard Edits and Reload", ], defaultId: 0, cancelId: 0, @@ -249,7 +260,11 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { } catch (error) { shutdownStarted = false; computerUseSettings.resumeAfterCancelledShutdown(); - logger.error("main", "Computer Use state was not durable; Aiden will stay open.", error); + logger.error( + "main", + "Computer Use state was not durable; Aiden will stay open.", + error, + ); return; } } @@ -267,7 +282,11 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { ); } } catch (error) { - logger.error("main", "Parent generation shutdown did not complete cleanly.", error); + logger.error( + "main", + "Parent generation shutdown did not complete cleanly.", + error, + ); } const subagentsSettled = await subagentRuntimeRegistry.shutdown(); if (!subagentsSettled) { @@ -278,16 +297,17 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { } const session = pendingPackagedSubagentSoakReceipt; pendingPackagedSubagentSoakReceipt = undefined; - const quitReceiptFinalization = await tryFinalizeSubagentPackagedSoakQuitReceipt( - session, - parentSettled, - subagentsSettled, - { - flushMetrics: () => subagentHealthMetrics.flush(), - snapshotMetrics: () => subagentHealthMetrics.snapshotForPackagedSoak(), - writeReceipt: writeSubagentPackagedSoakReceipt, - }, - ); + const quitReceiptFinalization = + await tryFinalizeSubagentPackagedSoakQuitReceipt( + session, + parentSettled, + subagentsSettled, + { + flushMetrics: () => subagentHealthMetrics.flush(), + snapshotMetrics: () => subagentHealthMetrics.snapshotForPackagedSoak(), + writeReceipt: writeSubagentPackagedSoakReceipt, + }, + ); if (quitReceiptFinalization.status === "lifecycle_unsettled") { logger.warn( "main", @@ -305,7 +325,9 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { quitReceiptFinalization.error, ); } - if (requiresSubagentPackagedSoakFailureExit(session, quitReceiptFinalization)) { + if ( + requiresSubagentPackagedSoakFailureExit(session, quitReceiptFinalization) + ) { logger.error( "main", "Packaged subagent soak finalization did not create a valid receipt; exiting with failure.", @@ -328,7 +350,11 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { terminalService.flushHistory(), ]); } catch (error) { - logger.error("main", "Application service shutdown did not complete cleanly.", error); + logger.error( + "main", + "Application service shutdown did not complete cleanly.", + error, + ); } forceAppQuit = true; if (installUpdateOnQuit) { @@ -342,7 +368,9 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { app.quit(); } -async function refreshCloseGuardFromRenderer(window: BrowserWindow): Promise { +async function refreshCloseGuardFromRenderer( + window: BrowserWindow, +): Promise { try { const latest = (await window.webContents.executeJavaScript( `({ @@ -364,7 +392,8 @@ async function refreshCloseGuardFromRenderer(window: BrowserWindow): Promise= 0 + return Number.isSafeInteger(latest?.revision) && + Number(latest.revision) >= 0 ? Number(latest.revision) : 0; } catch (error) { @@ -385,7 +414,10 @@ async function refreshCloseGuardFromRenderer(window: BrowserWindow): Promise { +async function armRendererUnload( + window: BrowserWindow, + revision: number, +): Promise { try { return ( (await window.webContents.executeJavaScript( @@ -411,7 +443,8 @@ async function authorizeProtectedAction( for (let attempt = 0; attempt < 3; attempt += 1) { const revision = await refreshCloseGuardFromRenderer(window); if (revision === null) return false; - if (hasCloseGuard() && !confirmProtectedAction(window, action)) return false; + if (hasCloseGuard() && !confirmProtectedAction(window, action)) + return false; if (await armRendererUnload(window, revision)) return true; } if (!window.isDestroyed()) { @@ -472,13 +505,19 @@ async function requestApplicationQuit(window: BrowserWindow): Promise { await computerUseSettings.shutdown(); } catch (error) { computerUseSettings.resumeAfterCancelledShutdown(); - logger.error("main", "Computer Use state was not durable; quit was cancelled.", error); + logger.error( + "main", + "Computer Use state was not durable; quit was cancelled.", + error, + ); if (!window.isDestroyed()) { dialog.showMessageBoxSync(window, { type: "error", title: "Aiden couldn't save Computer Use", - message: "Aiden will stay open because Computer Use could not be safely turned off.", - detail: "Check that the app can write its settings, then try quitting again.", + message: + "Aiden will stay open because Computer Use could not be safely turned off.", + detail: + "Check that the app can write its settings, then try quitting again.", buttons: ["Keep Aiden Open"], defaultId: 0, noLink: true, @@ -503,7 +542,9 @@ async function requestApplicationQuit(window: BrowserWindow): Promise { } } -async function clearRendererOnboardingCompletion(window: BrowserWindow): Promise { +async function clearRendererOnboardingCompletion( + window: BrowserWindow, +): Promise { try { return ( (await window.webContents.executeJavaScript( @@ -517,8 +558,14 @@ async function clearRendererOnboardingCompletion(window: BrowserWindow): Promise )) === true ); } catch (error) { - logger.error("main", "Could not clear the onboarding completion marker.", error); - throw new Error("Aiden couldn’t prepare onboarding for restart. Try again."); + logger.error( + "main", + "Could not clear the onboarding completion marker.", + error, + ); + throw new Error( + "Aiden couldn’t prepare onboarding for restart. Try again.", + ); } } @@ -533,12 +580,21 @@ async function restoreRendererOnboardingCompletion( true, ); } catch (error) { - logger.error("main", "Could not restore the onboarding completion marker.", error); + logger.error( + "main", + "Could not restore the onboarding completion marker.", + error, + ); } } async function requestOnboardingReset(window: BrowserWindow): Promise { - if (lifecycleCheckInFlight || shutdownStarted || installUpdateOnQuit || window.isDestroyed()) { + if ( + lifecycleCheckInFlight || + shutdownStarted || + installUpdateOnQuit || + window.isDestroyed() + ) { return false; } lifecycleCheckInFlight = true; @@ -559,7 +615,8 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { dialog.showMessageBoxSync(window, { type: "error", title: "Aiden couldn't save Computer Use", - message: "Onboarding was not reset because Computer Use could not be safely turned off.", + message: + "Onboarding was not reset because Computer Use could not be safely turned off.", detail: "Check that the app can write its settings, then try again.", buttons: ["Keep Aiden Open"], defaultId: 0, @@ -569,7 +626,8 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { return false; } - const onboardingWasComplete = await clearRendererOnboardingCompletion(window); + const onboardingWasComplete = + await clearRendererOnboardingCompletion(window); protectedAction = "onboarding-reset"; if (!(await closeRendererBeforeShutdown(window))) { protectedAction = null; @@ -584,7 +642,11 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { computerUseSettings.resumeAfterCancelledShutdown(); settingsPrepared = false; protectedAction = null; - logger.error("main", "Onboarding reset was incomplete after the renderer closed.", error); + logger.error( + "main", + "Onboarding reset was incomplete after the renderer closed.", + error, + ); try { await createMainWindow(); } catch (recoveryError) { @@ -598,8 +660,10 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { dialog.showMessageBoxSync(mainWindow, { type: "error", title: "Aiden couldn't finish the reset", - message: "Some setup data could not be cleared. Retry Reset onboarding.", - detail: "Aiden reopened without deleting your chats, projects, schedules, or skills.", + message: + "Some setup data could not be cleared. Retry Reset onboarding.", + detail: + "Aiden reopened without deleting your chats, projects, schedules, or skills.", buttons: ["Keep Aiden Open"], defaultId: 0, noLink: true, @@ -625,16 +689,22 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { } ipcMain.handle("app:setCloseGuard", (event, value: unknown) => { - if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) return false; - const input = (typeof value === "object" && value !== null ? value : {}) as Record< - string, - unknown - >; + const input = ( + typeof value === "object" && value !== null ? value : {} + ) as Record; closeGuard = { dirty: input.dirty === true, gitBusy: input.gitBusy === true, - path: typeof input.path === "string" && input.path.length <= 4_096 ? input.path : undefined, + path: + typeof input.path === "string" && input.path.length <= 4_096 + ? input.path + : undefined, saving: input.saving === true, }; return true; @@ -642,12 +712,21 @@ ipcMain.handle("app:setCloseGuard", (event, value: unknown) => { ipcMain.handle("app:resetOnboarding", async (event) => { const window = mainWindow; - if (!window || window.isDestroyed() || event.sender.id !== window.webContents.id) return false; + if ( + !window || + window.isDestroyed() || + event.sender.id !== window.webContents.id + ) + return false; return requestOnboardingReset(window); }); ipcMain.handle("app:getUpdateState", (event) => { - if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) { + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) { return { status: "idle", version: null, @@ -656,15 +735,26 @@ ipcMain.handle("app:getUpdateState", (event) => { return appUpdateService.snapshot(); }); -ipcMain.handle("app:checkForUpdates", async (event): Promise => { - if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) { - return { outcome: "unavailable" }; - } - return appUpdateService.checkNow(false); -}); +ipcMain.handle( + "app:checkForUpdates", + async (event): Promise => { + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) { + return { outcome: "unavailable" }; + } + return appUpdateService.checkNow(false); + }, +); ipcMain.handle("app:restartToUpdate", (event): AppUpdateRestartResult => { - if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) { + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) { return { accepted: false, reason: "unavailable", @@ -692,13 +782,19 @@ ipcMain.handle("app:restartToUpdate", (event): AppUpdateRestartResult => { }); ipcMain.handle("app:renderer-ready", (event) => { - if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) return false; rendererReadiness.markReady(); return true; }); -async function applyDockIconPreference(preference: DockIconPreference): Promise { +async function applyDockIconPreference( + preference: DockIconPreference, +): Promise { if (process.platform !== "darwin" || !app.dock) return false; const iconPath = preference === "monochrome" @@ -709,13 +805,16 @@ async function applyDockIconPreference(preference: DockIconPreference): Promise< ? path.join(process.resourcesPath, "app-icon.png") : path.join(app.getAppPath(), "resources", "app-icon.png"); const icon = nativeImage.createFromPath(iconPath); - if (icon.isEmpty()) throw new Error(`Dock icon is unavailable: ${path.basename(iconPath)}`); + if (icon.isEmpty()) + throw new Error(`Dock icon is unavailable: ${path.basename(iconPath)}`); app.dock.setIcon(icon); await app.dock.show(); return true; } -async function restoreDockIconPreference(preference: DockIconPreference): Promise { +async function restoreDockIconPreference( + preference: DockIconPreference, +): Promise { try { await applyDockIconPreference(preference); } catch (error) { @@ -724,22 +823,35 @@ async function restoreDockIconPreference(preference: DockIconPreference): Promis try { await applyDockIconPreference("aiden"); } catch (fallbackError) { - logger.warn("main", "Could not restore the default Dock icon", fallbackError); + logger.warn( + "main", + "Could not restore the default Dock icon", + fallbackError, + ); } } } ipcMain.handle("app:setDockIcon", async (event, value: unknown) => { - if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) return false; - if (value !== "aiden" && value !== "monochrome") throw new Error("Invalid Dock icon preference."); + if (value !== "aiden" && value !== "monochrome") + throw new Error("Invalid Dock icon preference."); return applyDockIconPreference(value); }); function openExternalUrl(value: string): void { try { const url = new URL(value); - if (url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:") { + if ( + url.protocol === "http:" || + url.protocol === "https:" || + url.protocol === "mailto:" + ) { void shell.openExternal(url.toString()); } } catch { @@ -801,10 +913,16 @@ async function createMainWindow(): Promise { mainWindow !== createdWindow ) return; - const recovery = mainWindowLoads.replace(createdWindow.loadURL(mainWindowUrl)); + const recovery = mainWindowLoads.replace( + createdWindow.loadURL(mainWindowUrl), + ); void recovery.promise.catch((error: unknown) => { if (!mainWindowLoads.isCurrent(recovery)) return; - logger.error("main", "Could not recover the main renderer after it exited.", error); + logger.error( + "main", + "Could not recover the main renderer after it exited.", + error, + ); if (!createdWindow.isDestroyed()) createdWindow.destroy(); }); }); @@ -909,9 +1027,15 @@ function deliverMainWindowNotificationSafely( channel: NotificationChannel, payload: Record, ): void { - void deliverMainWindowNotification(channel, payload).catch((error: unknown) => { - logger.warn("main", `Could not deliver renderer command "${channel}".`, error); - }); + void deliverMainWindowNotification(channel, payload).catch( + (error: unknown) => { + logger.warn( + "main", + `Could not deliver renderer command "${channel}".`, + error, + ); + }, + ); } function showMainWindow(): void { @@ -921,7 +1045,9 @@ function showMainWindow(): void { } function pauseForPackagedSubagentSoak(): Promise { - return new Promise((resolve) => setTimeout(resolve, SUBAGENT_PACKAGED_SOAK_POLL_MS)); + return new Promise((resolve) => + setTimeout(resolve, SUBAGENT_PACKAGED_SOAK_POLL_MS), + ); } async function waitForPackagedSubagentSoak( @@ -936,7 +1062,9 @@ async function waitForPackagedSubagentSoak( throw new Error(`Packaged subagent soak did not reach ${step}.`); } -async function runPackagedSubagentSoakRendererScript(script: string): Promise { +async function runPackagedSubagentSoakRendererScript( + script: string, +): Promise { const window = mainWindow; if (!window || window.isDestroyed()) { throw new Error("Packaged subagent soak lost its main window."); @@ -956,13 +1084,18 @@ async function packagedSubagentSoakGenerationError(): Promise { return typeof result === "string" && result ? result : null; } -async function settlePackagedSubagentSoak(session: SubagentPackagedSoakSession): Promise { +async function settlePackagedSubagentSoak( + session: SubagentPackagedSoakSession, +): Promise { if (!(await llmClient.waitForChatIdle(SUBAGENT_PACKAGED_SOAK_CHAT_ID))) { - throw new Error("Packaged subagent soak did not settle its parent generation."); + throw new Error( + "Packaged subagent soak did not settle its parent generation.", + ); } await waitForPackagedSubagentSoak( "child settlement", - () => !subagentRuntimeRegistry.hasChatChildren(SUBAGENT_PACKAGED_SOAK_CHAT_ID), + () => + !subagentRuntimeRegistry.hasChatChildren(SUBAGENT_PACKAGED_SOAK_CHAT_ID), ); await subagentHealthMetrics.flush(); await writeSubagentPackagedSoakReceipt( @@ -977,41 +1110,58 @@ async function settlePackagedSubagentSoak(session: SubagentPackagedSoakSession): * main-only and fixed-function: normal users have no new IPC, renderer API, or * automation endpoint. */ -async function runPackagedSubagentSoak(session: SubagentPackagedSoakSession): Promise { - await deliverMainWindowNotification("app:navigate", { path: SUBAGENT_PACKAGED_SOAK_CHAT_PATH }); +async function runPackagedSubagentSoak( + session: SubagentPackagedSoakSession, +): Promise { + await deliverMainWindowNotification("app:navigate", { + path: SUBAGENT_PACKAGED_SOAK_CHAT_PATH, + }); await waitForPackagedSubagentSoak("composer readiness", () => runPackagedSubagentSoakRendererScript(SUBAGENT_PACKAGED_SOAK_SEND_SCRIPT), ); await waitForPackagedSubagentSoak("child start", async () => { const generationError = await packagedSubagentSoakGenerationError(); if (generationError) { - throw new Error(`Packaged subagent soak parent generation failed: ${generationError}`); + throw new Error( + `Packaged subagent soak parent generation failed: ${generationError}`, + ); } - return subagentRuntimeRegistry.hasChatChildren(SUBAGENT_PACKAGED_SOAK_CHAT_ID); + return subagentRuntimeRegistry.hasChatChildren( + SUBAGENT_PACKAGED_SOAK_CHAT_ID, + ); }); // Ownership alone is intentionally insufficient: a child is registered // before it acquires a slot and dispatches provider work. Wait for Pi's // response callback so the loopback child request is actually in flight. await waitForPackagedSubagentSoak("child provider response", () => - subagentRuntimeRegistry.hasChatProviderResponse(SUBAGENT_PACKAGED_SOAK_CHAT_ID), + subagentRuntimeRegistry.hasChatProviderResponse( + SUBAGENT_PACKAGED_SOAK_CHAT_ID, + ), ); await waitForPackagedSubagentSoak( "aggregate child start", - async () => (await subagentHealthMetrics.snapshotForPackagedSoak()).starts === 1, + async () => + (await subagentHealthMetrics.snapshotForPackagedSoak()).starts === 1, ); const action = subagentPackagedSoakAction(session.control.mode); switch (action.kind) { case "renderer_stop": await waitForPackagedSubagentSoak("user stop", () => - runPackagedSubagentSoakRendererScript(SUBAGENT_PACKAGED_SOAK_STOP_SCRIPT), + runPackagedSubagentSoakRendererScript( + SUBAGENT_PACKAGED_SOAK_STOP_SCRIPT, + ), ); await settlePackagedSubagentSoak(session); return; case "main_navigate": - await deliverMainWindowNotification("app:navigate", { path: action.path }); + await deliverMainWindowNotification("app:navigate", { + path: action.path, + }); await waitForPackagedSubagentSoak("Settings navigation", () => - runPackagedSubagentSoakRendererScript(SUBAGENT_PACKAGED_SOAK_SETTINGS_VISIBLE_SCRIPT), + runPackagedSubagentSoakRendererScript( + SUBAGENT_PACKAGED_SOAK_SETTINGS_VISIBLE_SCRIPT, + ), ); await settlePackagedSubagentSoak(session); return; @@ -1026,13 +1176,19 @@ registerAppPathOpener(async (path) => { await deliverMainWindowNotification("app:navigate", { path }); }); -function setupApplicationMenu(settings: AppSettings, acceleratorsEnabled = true): void { +function setupApplicationMenu( + settings: AppSettings, + acceleratorsEnabled = true, +): void { if (!acceleratorsEnabled) { Menu.setApplicationMenu(null); return; } - const bindings = effectiveBindings(migrateLegacyKeybindings(settings.keybindings, settings)); - const command = (commandId: keyof typeof bindings) => bindings[commandId] ?? undefined; + const bindings = effectiveBindings( + migrateLegacyKeybindings(settings.keybindings, settings), + ); + const command = (commandId: keyof typeof bindings) => + bindings[commandId] ?? undefined; const menu = Menu.buildFromTemplate([ { label: app.getName(), @@ -1100,7 +1256,8 @@ function setupApplicationMenu(settings: AppSettings, acceleratorsEnabled = true) label: "Reload", accelerator: "Command+R", click: () => { - if (mainWindow && !mainWindow.isDestroyed()) void requestWindowReload(mainWindow); + if (mainWindow && !mainWindow.isDestroyed()) + void requestWindowReload(mainWindow); }, }, { @@ -1170,14 +1327,21 @@ if (!ownsSingleInstanceLock) { reloadPortableConfig, async (previous, next) => { await Promise.all([ - reconcileExternalProviderCredentialChanges(previous.providers, next.providers), - reconcileExternalMcpCredentialChanges(previous.mcpServers, next.mcpServers, (serverId) => - mcpManager.disconnect(serverId), + reconcileExternalProviderCredentialChanges( + previous.providers, + next.providers, + ), + reconcileExternalMcpCredentialChanges( + previous.mcpServers, + next.mcpServers, + (serverId) => mcpManager.disconnect(serverId), ), ]); }, ); - setPortableCredentialSnapshotListener(() => reloadAndReconcilePortableConfig.syncCurrent()); + setPortableCredentialSnapshotListener(() => + reloadAndReconcilePortableConfig.syncCurrent(), + ); const portableConfigWatcher = createPortableConfigWatcher( reloadAndReconcilePortableConfig, () => { @@ -1185,28 +1349,42 @@ if (!ownsSingleInstanceLock) { ipcMain.broadcast("app:config-externally-changed", {}); }, (error: unknown) => - logger.warn("portable-config", "Failed to re-read the portable config", error), + logger.warn( + "portable-config", + "Failed to re-read the portable config", + error, + ), ); app .whenReady() .then(async () => { const runtimeProfile = currentRuntimeProfile(); - if (runtimeProfile.id === "development" && process.platform === "darwin") { + if ( + runtimeProfile.id === "development" && + process.platform === "darwin" + ) { app.dock?.setBadge("DEV"); } const packagedSubagentSoak = await loadSubagentPackagedSoakSession({ isPackaged: isPackagedRuntime(), }); if (packagedSubagentSoak && !subagentsEnabled()) { - throw new Error("Packaged subagent soak requires the internal subagent opt-in."); + throw new Error( + "Packaged subagent soak requires the internal subagent opt-in.", + ); } if (!isPackagedRuntime()) { initDevLog(path.join(runtimeProfile.logsPath, "aiden-dev.log")); - logger.info("dev-log", `Writing dev log to ${devLogPath() ?? "unknown"}`); + logger.info( + "dev-log", + `Writing dev log to ${devLogPath() ?? "unknown"}`, + ); } try { - terminalService.installHistoryStore(await TerminalHistoryStore.create()); + terminalService.installHistoryStore( + await TerminalHistoryStore.create(), + ); } catch (error) { logger.warn( "terminal", @@ -1221,18 +1399,23 @@ if (!ownsSingleInstanceLock) { await piCompactionSessionStore.deleteChat(chatId); await chatStore.remove(chatId); }); + await piCompactionSessionStore.reconcileChats( + new Set((await chatStore.list()).map((chat) => chat.id)), + ); await reconcilePendingManagedWorktreeDeletions({ listWorkspaces: () => configStore.listWorkspaces(), deletionPending: (workspace) => { const managed = workspace.managedWorktree; - if (!managed?.worktreeGitDir || !managed.ownershipToken) return Promise.resolve(false); + if (!managed?.worktreeGitDir || !managed.ownershipToken) + return Promise.resolve(false); return gitManagedWorktreeDeletionPending( managed.worktreePath, managed.worktreeGitDir, managed.ownershipToken, ); }, - blockWorkspace: (workspaceId) => scheduleService.cancelWorkspace(workspaceId), + blockWorkspace: (workspaceId) => + scheduleService.cancelWorkspace(workspaceId), deleteWorktree: async (workspace) => { const managed = workspace.managedWorktree!; await gitDeleteManagedWorktree( @@ -1247,7 +1430,8 @@ if (!ownsSingleInstanceLock) { managed.worktreeInode, ); }, - removeWorkspaceRecord: (workspaceId) => configStore.removeWorkspace(workspaceId), + removeWorkspaceRecord: (workspaceId) => + configStore.removeWorkspace(workspaceId), finalizeDeletion: async (workspace) => { const managed = workspace.managedWorktree!; await gitFinalizeManagedWorktreeDeletion( @@ -1290,7 +1474,11 @@ if (!ownsSingleInstanceLock) { try { await reconcilePendingMcpCredentialCleanup(); } catch (error) { - logger.error("mcp", "Could not reconcile an interrupted MCP credential cleanup.", error); + logger.error( + "mcp", + "Could not reconcile an interrupted MCP credential cleanup.", + error, + ); } const appearance = normalizeAppearanceConfig(settings.appearance); nativeTheme.themeSource = appearance.mode; @@ -1302,7 +1490,11 @@ if (!ownsSingleInstanceLock) { void deliverMainWindowNotification("app:command", { commandId: "composer.focus", }).catch((error: unknown) => { - logger.warn("shortcut", "Could not focus the composer from the global shortcut", error); + logger.warn( + "shortcut", + "Could not focus the composer from the global shortcut", + error, + ); }); }); initDictationShortcut(() => { @@ -1312,7 +1504,11 @@ if (!ownsSingleInstanceLock) { void deliverMainWindowNotification("app:command", { commandId: "assistant.open", }).catch((error: unknown) => { - logger.warn("assistant", "Could not open Aiden from the global shortcut", error); + logger.warn( + "assistant", + "Could not open Aiden from the global shortcut", + error, + ); }); }); try { @@ -1331,7 +1527,10 @@ if (!ownsSingleInstanceLock) { // The active profile's portable config is user-editable, so pick // hand-edits up without a restart. Registered after whenReady because // powerMonitor is only usable once the app is ready. - app.on("browser-window-focus", () => void portableConfigWatcher.refresh()); + app.on( + "browser-window-focus", + () => void portableConfigWatcher.refresh(), + ); powerMonitor.on("resume", () => void portableConfigWatcher.refresh()); await createMainWindow(); diff --git a/main/services/chat-store-core.test.ts b/main/services/chat-store-core.test.ts index 5443947c..bf60ca69 100644 --- a/main/services/chat-store-core.test.ts +++ b/main/services/chat-store-core.test.ts @@ -63,6 +63,60 @@ test("serializes assistant persistence with a background title update", async (t ); }); +test("persists canonical Pi assistant provenance across restart without crossing the visible-copy boundary", async (t) => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), "aiden-chat-pi-provenance-"), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const first = createChatStore(async () => directory); + const chat = await first.create({ providerId: "google", model: "new-model" }); + await first.appendMessage(chat.id, { + role: "user", + content: "Remember provider history", + }); + const saved = await first.appendMessage(chat.id, { + role: "assistant", + content: "Historical answer", + model: "claude-old", + reasoning: "Historical reasoning", + pi: { + role: "assistant", + content: [ + { type: "thinking", thinking: "Historical reasoning" }, + { type: "text", text: "Historical answer" }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-old", + responseId: "response-old", + usage: { + input: 8, + output: 4, + cacheRead: 1, + cacheWrite: 2, + cacheWrite1h: 1, + totalTokens: 15, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 20, + }, + }); + const assistantId = saved.messages[saved.messages.length - 1]!.id; + + const restarted = createChatStore(async () => directory); + const restored = await restarted.get(chat.id); + assert.equal(restored?.messages[1]?.pi?.provider, "anthropic"); + assert.equal(restored?.messages[1]?.pi?.api, "anthropic-messages"); + assert.equal(restored?.messages[1]?.pi?.responseId, "response-old"); + const copied = await restarted.copyVisibleHistory({ + sourceChatId: chat.id, + throughAssistantMessageId: assistantId, + }); + assert.equal(copied.messages[1]?.pi, undefined); + assert.equal(copied.messages[1]?.reasoning, undefined); +}); + test("chat payload writes are atomic when staged-file sync fails", async (t) => { const directory = await fs.mkdtemp( path.join(os.tmpdir(), "aiden-chat-sync-failure-"), diff --git a/main/services/chat-store-core.ts b/main/services/chat-store-core.ts index c1666f8c..28407cbc 100644 --- a/main/services/chat-store-core.ts +++ b/main/services/chat-store-core.ts @@ -17,6 +17,7 @@ import { parseSubagentMessageReferenceV1 } from "../../renderer/shared/subagent- import { migrateLegacyPiProviderId } from "../../renderer/shared/google-provider.js"; import { parseSkillProvenanceV1 } from "../../renderer/shared/slash-commands.js"; import { safeStoredAttachments } from "./attachment-contract.js"; +import { parseStoredPiAssistantMessage } from "./pi-message-storage.js"; import { projectVisibleChatMessage, projectVisibleChatMetadata, @@ -395,6 +396,10 @@ export function createChatStore( message.reasoning.trim() ? message.reasoning : undefined, + pi: + message.role === "assistant" + ? parseStoredPiAssistantMessage(message.pi) + : undefined, timeline: message.role === "assistant" ? parseGenerationTimeline(message.timeline) @@ -645,9 +650,7 @@ export function createChatStore( } }; const metadata = projectVisibleChatMetadata(source); - const suffix = input.throughAssistantMessageId - ? " (fork)" - : " (copy)"; + const suffix = input.throughAssistantMessageId ? " (fork)" : " (copy)"; const maximumBaseLength = Math.max(1, 120 - suffix.length); const title = `${Array.from( metadata.title.slice(0, maximumBaseLength * 2), @@ -660,7 +663,8 @@ export function createChatStore( charge(metadata.providerId); charge(metadata.model); for (let index = 0; index <= throughIndex; index += 1) { - const message = projectVisibleChatMessage(source.messages[index]); + const sourceMessage = source.messages[index]; + const message = projectVisibleChatMessage(sourceMessage); if (!message) continue; if (copiedMessages.length >= MAX_VISIBLE_COPY_MESSAGES) { throw new Error("This chat has too many messages to copy safely."); @@ -835,6 +839,10 @@ export function createChatStore( message.reasoning.trim() ? message.reasoning : undefined, + pi: + message.role === "assistant" + ? parseStoredPiAssistantMessage(message.pi) + : undefined, attachments: safeStoredAttachments(message.attachments), skill: message.role === "user" diff --git a/main/services/generation-context.test.ts b/main/services/generation-context.test.ts index 600af7ec..49d96f8a 100644 --- a/main/services/generation-context.test.ts +++ b/main/services/generation-context.test.ts @@ -1,12 +1,17 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { AssistantMessage, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; +import type { + AssistantMessage, + ToolResultMessage, + UserMessage, +} from "@earendil-works/pi-ai"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { assertGenerationContextCapacity, compactGenerationContext, createGenerationContextTransform, limitComputerUseImages, + projectMessagesForModel, } from "./generation-context.js"; const options = { @@ -19,7 +24,10 @@ function user(content: string): UserMessage { return { role: "user", content, timestamp: Date.now() }; } -function assistant(toolCallId: string, toolName = "read_file"): AssistantMessage { +function assistant( + toolCallId: string, + toolName = "read_file", +): AssistantMessage { return { role: "assistant", content: [ @@ -55,7 +63,8 @@ function toolResult( role: "toolResult", toolCallId, toolName, - content: typeof content === "string" ? [{ type: "text", text: content }] : content, + content: + typeof content === "string" ? [{ type: "text", text: content }] : content, isError: false, timestamp: Date.now(), }; @@ -65,18 +74,26 @@ function assertToolProtocolIsPaired(messages: AgentMessage[]): void { const toolCallIds = new Set( messages.flatMap((message) => message.role === "assistant" - ? message.content.filter((part) => part.type === "toolCall").map((part) => part.id) + ? message.content + .filter((part) => part.type === "toolCall") + .map((part) => part.id) : [], ), ); const toolResultIds = new Set( - messages.flatMap((message) => (message.role === "toolResult" ? [message.toolCallId] : [])), + messages.flatMap((message) => + message.role === "toolResult" ? [message.toolCallId] : [], + ), ); assert.deepEqual(toolResultIds, toolCallIds); } test("returns the original context when it fits the model window", () => { - const messages: AgentMessage[] = [user("Hello"), assistant("one"), toolResult("one", "small")]; + const messages: AgentMessage[] = [ + user("Hello"), + assistant("one"), + toolResult("one", "small"), + ]; const result = compactGenerationContext(messages, options); assert.equal(result.compacted, false); @@ -85,6 +102,32 @@ test("returns the original context when it fits the model window", () => { assert.equal(result.usedContextFallback, false); }); +test("projects model-neutral image history only for vision requests", () => { + const imageUser: UserMessage = { + role: "user", + content: [ + { type: "text", text: "inspect this" }, + { type: "image", data: "private-image", mimeType: "image/png" }, + ], + timestamp: Date.now(), + }; + const neutral: AgentMessage[] = [imageUser]; + + assert.equal( + JSON.stringify(projectMessagesForModel(neutral, true)).includes( + "private-image", + ), + true, + ); + const textOnly = projectMessagesForModel(neutral, false); + assert.equal(JSON.stringify(textOnly).includes("private-image"), false); + assert.match( + JSON.stringify(textOnly), + /retained in Aiden's private journal/u, + ); + assert.equal(JSON.stringify(neutral).includes("private-image"), true); +}); + test("limitComputerUseImages keeps the newest screenshots and leaves other results alone", () => { const messages: AgentMessage[] = [user("Use the desktop.")]; for (let index = 0; index < 5; index += 1) { @@ -118,11 +161,14 @@ test("limitComputerUseImages keeps the newest screenshots and leaves other resul message.role === "toolResult" && message.toolName === "computer_use", ); const captures = computerUseResults.filter((result) => - result.content.some((part) => part.type === "text" && part.text.startsWith("capture-")), + result.content.some( + (part) => part.type === "text" && part.text.startsWith("capture-"), + ), ); assert.equal(computerUseResults.length, 10); assert.equal( - computerUseResults.filter((r) => r.content.some((p) => p.type === "image")).length, + computerUseResults.filter((r) => r.content.some((p) => p.type === "image")) + .length, 3, ); assert.equal( @@ -147,7 +193,11 @@ test("limitComputerUseImages keeps the newest screenshots and leaves other resul true, ); assert.match( - String(captures[0]?.content[0]?.type === "text" ? captures[0].content[0].text : ""), + String( + captures[0]?.content[0]?.type === "text" + ? captures[0].content[0].text + : "", + ), /capture-0/u, ); }); @@ -206,11 +256,17 @@ test("bounds a Codex-sized discovery loop while preserving recent evidence and t const messages: AgentMessage[] = [user("Inspect the provider runtime.")]; for (let index = 0; index < 38; index += 1) { const id = `read-${index}`; - messages.push(assistant(id), toolResult(id, `${id}\n${"x".repeat(20_000)}`)); + messages.push( + assistant(id), + toolResult(id, `${id}\n${"x".repeat(20_000)}`), + ); } for (let index = 0; index < 8; index += 1) { const id = `grep-${index}`; - messages.push(assistant(id, "grep"), toolResult(id, `${id}\n${"y".repeat(20_000)}`, "grep")); + messages.push( + assistant(id, "grep"), + toolResult(id, `${id}\n${"y".repeat(20_000)}`, "grep"), + ); } const originalFirstResult = (messages[2] as ToolResultMessage).content[0]; const result = compactGenerationContext(messages, options); @@ -220,10 +276,16 @@ test("bounds a Codex-sized discovery loop while preserving recent evidence and t assert.ok(result.estimatedTokensBefore > result.inputBudgetTokens); assert.ok(result.estimatedTokensAfter <= result.inputBudgetTokens); assert.equal(originalFirstResult?.type, "text"); - assert.equal(originalFirstResult?.type === "text" ? originalFirstResult.text.length : 0, 20_007); + assert.equal( + originalFirstResult?.type === "text" ? originalFirstResult.text.length : 0, + 20_007, + ); const transformedText = JSON.stringify(result.messages); - assert.match(transformedText, /payload omitted to stay within the model context window/); + assert.match( + transformedText, + /payload omitted to stay within the model context window/, + ); assert.match(transformedText, /grep-7/); assert.doesNotMatch(transformedText, /Call the tool again/u); assertToolProtocolIsPaired(result.messages); @@ -280,7 +342,9 @@ test("drops oldest complete chat turns before sacrificing the active request", ( for (let index = 0; index < 5; index += 1) { messages.push(user(`old-user-${index}-${"u".repeat(18_000)}`), { ...assistant(`old-${index}`), - content: [{ type: "text", text: `old-answer-${index}-${"a".repeat(18_000)}` }], + content: [ + { type: "text", text: `old-answer-${index}-${"a".repeat(18_000)}` }, + ], stopReason: "stop", }); } @@ -295,7 +359,42 @@ test("drops oldest complete chat turns before sacrificing the active request", ( assert.ok(result.removedHistoryMessages > 0); const finalMessage = result.messages[result.messages.length - 1]; assert.equal(finalMessage?.role, "user"); - assert.equal(finalMessage?.role === "user" ? finalMessage.content : "", "current-request"); + assert.equal( + finalMessage?.role === "user" ? finalMessage.content : "", + "current-request", + ); + assert.ok(result.estimatedTokensAfter <= result.inputBudgetTokens); +}); + +test("keeps the semantic checkpoint while pruning its retained tail", () => { + const messages: AgentMessage[] = [ + { + role: "compactionSummary", + summary: "Durable checkpoint: keep this exact decision.", + tokensBefore: 100_000, + timestamp: 1, + }, + ]; + for (let index = 0; index < 4; index += 1) { + messages.push(user(`tail-user-${index}-${"u".repeat(12_000)}`), { + ...assistant(`tail-${index}`), + content: [ + { type: "text", text: `tail-answer-${index}-${"a".repeat(12_000)}` }, + ], + stopReason: "stop", + }); + } + messages.push(user("current-request")); + + const result = compactGenerationContext(messages, { + ...options, + contextWindow: 16_000, + }); + + assert.equal(result.compacted, true); + assert.equal(result.messages[0]?.role, "compactionSummary"); + assert.match(JSON.stringify(result.messages[0]), /keep this exact decision/u); + assert.equal(result.messages[result.messages.length - 1]?.role, "user"); assert.ok(result.estimatedTokensAfter <= result.inputBudgetTokens); }); @@ -325,7 +424,10 @@ test("bounds oversized tool text while retaining image evidence without mutating const transformedResult = result.messages.find( (message): message is ToolResultMessage => message.role === "toolResult", ); - assert.equal(transformedResult?.content.filter((part) => part.type === "image").length, 2); + assert.equal( + transformedResult?.content.filter((part) => part.type === "image").length, + 2, + ); assert.deepEqual(messages, snapshot); assertToolProtocolIsPaired(result.messages); }); @@ -341,7 +443,10 @@ test("replaces an oversized active request with a bounded fail-safe notice", () assert.equal(result.usedContextFallback, true); assert.ok(result.estimatedTokensAfter <= result.inputBudgetTokens); assert.equal(result.messages.length, 1); - assert.match(JSON.stringify(result.messages), /fewer\/lower-size attachments/u); + assert.match( + JSON.stringify(result.messages), + /fewer\/lower-size attachments/u, + ); assert.equal((messages[0] as UserMessage).content.length, 100_000); }); @@ -365,7 +470,10 @@ test("rejects a model whose static prompt and tools cannot fit even the fail-saf test("never rejects when compaction inputs or observers fail", async () => { const circular: Record = {}; circular.self = circular; - const messages: AgentMessage[] = [user("safe fallback"), user("x".repeat(100_000))]; + const messages: AgentMessage[] = [ + user("safe fallback"), + user("x".repeat(100_000)), + ]; let observerCalls = 0; const transform = createGenerationContextTransform( { diff --git a/main/services/generation-context.ts b/main/services/generation-context.ts index e7b67ecf..b8344621 100644 --- a/main/services/generation-context.ts +++ b/main/services/generation-context.ts @@ -21,6 +21,8 @@ export interface GenerationContextOptions { contextWindow: number; systemPrompt: string; tools: readonly AgentTool[]; + /** Project model-neutral journal images only when this request can accept them. */ + supportsImages?: boolean; } export interface GenerationContextCompaction { @@ -67,15 +69,44 @@ function staticContextTokens(options: GenerationContextOptions): number { } function messageTokens(messages: AgentMessage[]): number { - return messages.reduce((total, message) => total + estimateTokens(message), 0); + return messages.reduce( + (total, message) => total + estimateTokens(message), + 0, + ); } function isToolResult(message: AgentMessage): message is ToolResultMessage { return message.role === "toolResult"; } +const OMITTED_IMAGE_TEXT = + "[Image content retained in Aiden's private journal but omitted from this text-only model request.]"; + +/** Project model-neutral journal history onto one model's input modalities. */ +export function projectMessagesForModel( + messages: readonly AgentMessage[], + supportsImages: boolean, +): AgentMessage[] { + if (supportsImages) return messages as AgentMessage[]; + return messages.map((message) => { + if ( + (message.role !== "user" && message.role !== "toolResult") || + typeof message.content === "string" || + !message.content.some((part) => part.type === "image") + ) { + return message; + } + const content = message.content.filter((part) => part.type !== "image"); + const notice = { type: "text" as const, text: OMITTED_IMAGE_TEXT }; + return { ...message, content: [...content, notice] } as AgentMessage; + }); +} + /** Keep only the newest Computer Use screenshots while preserving every text result. */ -export function limitComputerUseImages(messages: AgentMessage[], keep = 3): AgentMessage[] { +export function limitComputerUseImages( + messages: AgentMessage[], + keep = 3, +): AgentMessage[] { const imageIndexes: number[] = []; for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; @@ -87,7 +118,9 @@ export function limitComputerUseImages(messages: AgentMessage[], keep = 3): Agen imageIndexes.push(index); } } - const keepCount = Number.isFinite(keep) ? Math.max(0, Math.floor(keep)) : imageIndexes.length; + const keepCount = Number.isFinite(keep) + ? Math.max(0, Math.floor(keep)) + : imageIndexes.length; if (imageIndexes.length <= keepCount) return messages; const strip = new Set(imageIndexes.slice(0, imageIndexes.length - keepCount)); return messages.map((message, index) => { @@ -153,7 +186,10 @@ function truncateToolResult(message: ToolResultMessage): { message: { ...message, content: [ - { type: "text", text: truncateText(text, TOOL_RESULT_TEXT_LIMIT_CHARS) }, + { + type: "text", + text: truncateText(text, TOOL_RESULT_TEXT_LIMIT_CHARS), + }, ...images, ], }, @@ -168,7 +204,10 @@ function lastUserIndex(messages: AgentMessage[]): number { return -1; } -function removeOldHistoricalTurn(messages: AgentMessage[], preserveUserTurns: number): number { +function removeOldHistoricalTurn( + messages: AgentMessage[], + preserveUserTurns: number, +): number { const currentUser = lastUserIndex(messages); if (currentUser <= 0) return 0; const userIndexes = messages @@ -177,8 +216,11 @@ function removeOldHistoricalTurn(messages: AgentMessage[], preserveUserTurns: nu if (userIndexes.length <= preserveUserTurns) return 0; const nextUser = userIndexes[1]; if (nextUser === undefined || nextUser > currentUser) return 0; - messages.splice(0, nextUser); - return nextUser; + const firstUser = userIndexes[0]; + if (firstUser === undefined) return 0; + const removed = nextUser - firstUser; + messages.splice(firstUser, removed); + return removed; } function replaceToolResult(messages: AgentMessage[], index: number): boolean { @@ -211,7 +253,10 @@ function protectedRecentToolResults( const message = messages[index]; if (!message || !isToolResult(message)) continue; const nextTokens = tokens + estimateTokens(message); - if (protectedIndexes.size < MIN_RECENT_TOOL_RESULTS || nextTokens <= budgetTokens) { + if ( + protectedIndexes.size < MIN_RECENT_TOOL_RESULTS || + nextTokens <= budgetTokens + ) { protectedIndexes.add(index); tokens = nextTokens; continue; @@ -243,20 +288,28 @@ interface GenerationContextLimits { inputBudgetTokens: number; } -function contextLimits(options: GenerationContextOptions): GenerationContextLimits { +function contextLimits( + options: GenerationContextOptions, +): GenerationContextLimits { const contextWindow = Number.isFinite(options.contextWindow) && options.contextWindow > 0 ? Math.max(1, Math.floor(options.contextWindow)) : 1; const responseReserve = Math.min( DEFAULT_COMPACTION_SETTINGS.reserveTokens, - Math.max(MIN_RESERVE_TOKENS, Math.floor(contextWindow * RESPONSE_RESERVE_RATIO)), + Math.max( + MIN_RESERVE_TOKENS, + Math.floor(contextWindow * RESPONSE_RESERVE_RATIO), + ), ); const safetyReserve = Math.max( MIN_RESERVE_TOKENS, Math.floor(contextWindow * SAFETY_RESERVE_RATIO), ); - const reserveTokens = Math.min(contextWindow - 1, responseReserve + safetyReserve); + const reserveTokens = Math.min( + contextWindow - 1, + responseReserve + safetyReserve, + ); return { contextWindow, reserveTokens, @@ -270,9 +323,13 @@ function contextLimits(options: GenerationContextOptions): GenerationContextLimi * system prompt or tool schemas. Fail before provider I/O when even Aiden's * bounded recovery notice cannot fit beside that static context. */ -export function assertGenerationContextCapacity(options: GenerationContextOptions): void { +export function assertGenerationContextCapacity( + options: GenerationContextOptions, +): void { if (!Number.isFinite(options.contextWindow) || options.contextWindow <= 0) { - throw new Error("The selected model does not report a usable context window."); + throw new Error( + "The selected model does not report a usable context window.", + ); } const limits = contextLimits(options); const fallbackTokens = estimateTokens(contextFallback([])); @@ -287,8 +344,11 @@ export function compactGenerationContext( messages: AgentMessage[], options: GenerationContextOptions, ): GenerationContextCompaction { - const retained = limitComputerUseImages(messages); - const { contextWindow, reserveTokens, staticTokens, inputBudgetTokens } = contextLimits(options); + const retained = limitComputerUseImages( + projectMessagesForModel(messages, options.supportsImages !== false), + ); + const { contextWindow, reserveTokens, staticTokens, inputBudgetTokens } = + contextLimits(options); const estimatedMessageTokensBefore = messageTokens(retained); const providerEstimate = estimateContextTokens(retained); const providerAwareTokens = providerEstimate.tokens; @@ -306,7 +366,10 @@ export function compactGenerationContext( : messageTokens(retained.slice(0, providerEstimate.lastUsageIndex + 1)); const providerPrefixRatio = providerEstimate.usageTokens > 0 && estimatedPrefixTokens > 0 - ? Math.max(1, (providerEstimate.usageTokens - staticTokens) / estimatedPrefixTokens) + ? Math.max( + 1, + (providerEstimate.usageTokens - staticTokens) / estimatedPrefixTokens, + ) : 1; const estimatedTotalTokens = (candidate: AgentMessage[]) => { const estimatedMessages = messageTokens(candidate); @@ -319,12 +382,16 @@ export function compactGenerationContext( // measurement no longer describes this outbound request. return Math.ceil(heuristicTotal); } - const retainedPrefixTokens = messageTokens(candidate.slice(0, anchorIndex + 1)); + const retainedPrefixTokens = messageTokens( + candidate.slice(0, anchorIndex + 1), + ); const trailingTokens = messageTokens(candidate.slice(anchorIndex + 1)); return Math.ceil( Math.max( heuristicTotal, - staticTokens + retainedPrefixTokens * providerPrefixRatio + trailingTokens, + staticTokens + + retainedPrefixTokens * providerPrefixRatio + + trailingTokens, ), ); }; @@ -359,7 +426,8 @@ export function compactGenerationContext( if (truncated.truncated) truncatedToolResults += 1; return truncated.message; }); - const overBudget = () => estimatedTotalTokens(transformed) > inputBudgetTokens; + const overBudget = () => + estimatedTotalTokens(transformed) > inputBudgetTokens; let removedHistoryMessages = 0; let compactedToolResults = 0; let removedCurrentTurnMessages = 0; @@ -378,7 +446,11 @@ export function compactGenerationContext( RECENT_TOOL_OUTPUT_BUDGET_TOKENS, Math.max(MIN_RESERVE_TOKENS, Math.floor(messageBudgetTokens * 0.45)), ); - const protectedIndexes = protectedRecentToolResults(transformed, toolIndexes, recentBudget); + const protectedIndexes = protectedRecentToolResults( + transformed, + toolIndexes, + recentBudget, + ); for (const index of toolIndexes) { if (!overBudget()) break; @@ -386,7 +458,9 @@ export function compactGenerationContext( if (replaceToolResult(transformed, index)) compactedToolResults += 1; } - const newestProtected = [...protectedIndexes].sort((left, right) => left - right); + const newestProtected = [...protectedIndexes].sort( + (left, right) => left - right, + ); while (overBudget() && newestProtected.length > MIN_RECENT_TOOL_RESULTS) { const index = newestProtected.shift(); if (index !== undefined && replaceToolResult(transformed, index)) { @@ -400,8 +474,17 @@ export function compactGenerationContext( if (overBudget()) { const currentUser = lastUserIndex(transformed); if (currentUser > 0) { - transformed.splice(0, currentUser); - removedHistoryMessages += currentUser; + let checkpointIndex = -1; + for (let index = currentUser - 1; index >= 0; index -= 1) { + if (transformed[index]?.role === "compactionSummary") { + checkpointIndex = index; + break; + } + } + const start = checkpointIndex >= 0 ? checkpointIndex + 1 : 0; + const removed = currentUser - start; + transformed.splice(start, removed); + removedHistoryMessages += removed; } } diff --git a/main/services/generation-messages.test.ts b/main/services/generation-messages.test.ts index 629c82b1..5b0e572c 100644 --- a/main/services/generation-messages.test.ts +++ b/main/services/generation-messages.test.ts @@ -8,6 +8,7 @@ import { toPiMessages, } from "./generation-messages.js"; import { SkillInvocationError } from "../../renderer/shared/slash-commands.js"; +import { parseStoredPiAssistantMessage } from "./pi-message-storage.js"; const model: Model<"openai-completions"> = { id: "test", @@ -80,7 +81,11 @@ test("matches Pi's installed tool-result image serialization gate", () => { toolName: "computer_use", content: [ { type: "text" as const, text: "capture" }, - { type: "image" as const, data: "TOOL_IMAGE_SENTINEL", mimeType: "image/png" }, + { + type: "image" as const, + data: "TOOL_IMAGE_SENTINEL", + mimeType: "image/png", + }, ], details: null, isError: false, @@ -109,6 +114,73 @@ test("journal rehydration preserves the authoritative chat timestamp", () => { assert.equal(message.timestamp, 123_456); }); +test("journal rehydration preserves canonical mixed-provider Pi provenance", () => { + const canonical = { + role: "assistant" as const, + content: [ + { type: "thinking" as const, thinking: "Provider-authored thought" }, + { type: "text" as const, text: "Historical answer" }, + ], + api: "anthropic-messages" as const, + provider: "anthropic", + model: "claude-historical", + responseModel: "claude-historical-2026", + responseId: "response-historical", + usage: { + input: 10, + output: 4, + cacheRead: 2, + cacheWrite: 3, + cacheWrite1h: 1, + totalTokens: 19, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: 456, + }; + const message = chatMessageToPiMessage( + { + id: "message-provenance", + role: "assistant", + content: "Historical answer", + createdAt: 456, + pi: canonical, + }, + model, + false, + ); + assert.deepEqual(message, canonical); + assert.equal( + message.role === "assistant" ? message.provider : "", + "anthropic", + ); + assert.equal( + message.role === "assistant" ? message.model : "", + "claude-historical", + ); +}); + +test("stored Pi provenance rejects malformed nested provider protocol", () => { + const valid = { + role: "assistant", content: [{ type: "text", text: "answer" }], + api: "anthropic-messages", provider: "anthropic", model: "claude", + usage: { + input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", timestamp: 10, + }; + assert.deepEqual(parseStoredPiAssistantMessage(valid), valid); + for (const malformed of [ + { ...valid, provider: "" }, + { ...valid, content: [{ type: "image", data: "private" }] }, + { ...valid, content: [{ type: "toolCall", id: "", name: "read", arguments: {} }] }, + { ...valid, usage: {} }, + { ...valid, usage: { ...valid.usage, input: Number.NaN } }, + { ...valid, usage: { ...valid.usage, cost: { ...valid.usage.cost, total: -1 } } }, + ]) assert.equal(parseStoredPiAssistantMessage(malformed), undefined); +}); + test("an explicit invocation overrides only the exact in-memory current turn", () => { const persisted = { id: "message-2", @@ -130,18 +202,24 @@ test("an explicit invocation overrides only the exact in-memory current turn", ( assert.match(serialized, /PRIVATE_SKILL_INSTRUCTIONS/u); assert.equal(serialized.match(/Attached file: note\.txt/gu)?.length, 1); assert.equal(serialized.includes("IMAGE_SENTINEL"), true); - assert.ok(serialized.indexOf("PRIVATE_SKILL_INSTRUCTIONS") < serialized.indexOf("note.txt")); + assert.ok( + serialized.indexOf("PRIVATE_SKILL_INSTRUCTIONS") < + serialized.indexOf("note.txt"), + ); }); test("skill message text is rejected before aggregate attachment concatenation exceeds its budget", () => { assert.throws( () => chatUserTextWithAttachments("12345", undefined, 4), (error: unknown) => - error instanceof SkillInvocationError && error.code === "instructions_too_large", + error instanceof SkillInvocationError && + error.code === "instructions_too_large", ); assert.throws( - () => chatUserTextWithAttachments("tail", params.messages[0]!.attachments, 30), + () => + chatUserTextWithAttachments("tail", params.messages[0]!.attachments, 30), (error: unknown) => - error instanceof SkillInvocationError && error.code === "instructions_too_large", + error instanceof SkillInvocationError && + error.code === "instructions_too_large", ); }); diff --git a/main/services/generation-messages.ts b/main/services/generation-messages.ts index 85fb6a30..39c95181 100644 --- a/main/services/generation-messages.ts +++ b/main/services/generation-messages.ts @@ -1,4 +1,10 @@ -import type { Api, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai"; +import type { + Api, + ImageContent, + Message, + Model, + TextContent, +} from "@earendil-works/pi-ai"; import { SkillInvocationError } from "../../renderer/shared/slash-commands.js"; import type { ChatMessage, ChatStartParams } from "./types.js"; @@ -22,19 +28,30 @@ function userMessage( if (!attachments?.length) return { role: "user", content, timestamp }; const parts: (TextContent | ImageContent)[] = []; const textFiles = includeTextAttachments - ? attachments.filter((attachment) => attachment.kind === "text" && attachment.text) + ? attachments.filter( + (attachment) => attachment.kind === "text" && attachment.text, + ) : []; const textPrefix = textFiles - .map((attachment) => `Attached file: ${attachment.name}\n\`\`\`\n${attachment.text}\n\`\`\``) + .map( + (attachment) => + `Attached file: ${attachment.name}\n\`\`\`\n${attachment.text}\n\`\`\``, + ) .join("\n\n"); - const combinedText = (contentFirst ? [content, textPrefix] : [textPrefix, content]) + const combinedText = ( + contentFirst ? [content, textPrefix] : [textPrefix, content] + ) .filter(Boolean) .join("\n\n"); if (combinedText) parts.push({ type: "text", text: combinedText }); if (supportsImages) { for (const attachment of attachments) { if (attachment.kind === "image" && attachment.data) { - parts.push({ type: "image", data: attachment.data, mimeType: attachment.mimeType }); + parts.push({ + type: "image", + data: attachment.data, + mimeType: attachment.mimeType, + }); } } } @@ -94,7 +111,12 @@ export function toPiMessages( }; } - return userMessage(message.content, message.attachments, supportsImages, now); + return userMessage( + message.content, + message.attachments, + supportsImages, + now, + ); }); } @@ -120,6 +142,9 @@ export function chatMessageToPiMessage( false, ); } + if (message.role === "assistant" && message.pi) { + return structuredClone(message.pi); + } const params: ChatStartParams = { chatId: "journal-rehydration", providerId: model.provider, diff --git a/main/services/generation-runtime.test.ts b/main/services/generation-runtime.test.ts index 725e154d..42cab4ce 100644 --- a/main/services/generation-runtime.test.ts +++ b/main/services/generation-runtime.test.ts @@ -1,12 +1,16 @@ import assert from "node:assert/strict"; import { createServer } from "node:http"; import test from "node:test"; -import { anthropicMessagesApi, openAICompletionsApi } from "@earendil-works/pi-ai/compat"; +import { + anthropicMessagesApi, + openAICompletionsApi, +} from "@earendil-works/pi-ai/compat"; import type { Model, ProviderStreams } from "@earendil-works/pi-ai"; import { PI_AUTH_COMPATIBILITY_TOKEN, buildAgentRuntimeOptions, resolveGenerationThinkingLevel, + reconcileTerminalAssistantProjection, resolveRuntimeApiKey, resolveRuntimeBaseUrl, resolveRuntimeHeaders, @@ -18,8 +22,10 @@ import { terminalAssistantText, terminalAssistantTextFallback, terminalGenerationError, + terminalGenerationLengthError, terminalGenerationInterruptionError, terminalGenerationWasAborted, + waitForAbortableDelay, waitForGenerationStateClear, } from "./generation-runtime.js"; @@ -28,11 +34,31 @@ test("uses only the connection-bound runtime model as the image gate", () => { assert.equal(runtimeSupportsImages({ input: ["text", "image"] }), true); }); -test("native model thinking stays a small fail-closed runtime contract", () => { - assert.equal(resolveGenerationThinkingLevel("google", { reasoning: true }, "high"), "high"); - assert.equal(resolveGenerationThinkingLevel("google", { reasoning: true }, undefined), "off"); - assert.equal(resolveGenerationThinkingLevel("google", { reasoning: false }, "high"), "off"); - assert.equal(resolveGenerationThinkingLevel("openai", { reasoning: true }, "high"), "off"); +test("model thinking preserves native normalization and honors generic Pi reasoning", () => { + assert.equal( + resolveGenerationThinkingLevel("google", { reasoning: true }, "high"), + "high", + ); + assert.equal( + resolveGenerationThinkingLevel("google", { reasoning: true }, undefined), + "off", + ); + assert.equal( + resolveGenerationThinkingLevel("google", { reasoning: false }, "high"), + "off", + ); + assert.equal( + resolveGenerationThinkingLevel("openai", { reasoning: true }, "high"), + "high", + ); + assert.equal( + resolveGenerationThinkingLevel( + "bedrock", + { reasoning: true, thinkingLevelMap: { low: "low" } }, + "high", + ), + "off", + ); assert.equal( resolveGenerationThinkingLevel( "openai-codex", @@ -68,7 +94,14 @@ test("native model thinking stays a small fail-closed runtime contract", () => { ), "max", ); - assert.equal(resolveGenerationThinkingLevel("openai-codex", { reasoning: false }, "high"), "off"); + assert.equal( + resolveGenerationThinkingLevel( + "openai-codex", + { reasoning: false }, + "high", + ), + "off", + ); assert.equal( resolveGenerationThinkingLevel( "anthropic", @@ -176,7 +209,12 @@ test("forwards the chat identity through Pi Agent options into the native stream assert.equal(agentOptions.sessionId, "chat-session-123"); assert.throws( - () => agentOptions.streamFn?.(model, { messages: [] }, { sessionId: agentOptions.sessionId }), + () => + agentOptions.streamFn?.( + model, + { messages: [] }, + { sessionId: agentOptions.sessionId }, + ), /captured/u, ); assert.equal(receivedSessionId, "chat-session-123"); @@ -185,7 +223,10 @@ test("forwards the chat identity through Pi Agent options into the native stream }); test("uses an in-memory non-secret credential for an explicitly keyless provider", () => { - assert.equal(resolveRuntimeApiKey({ needsKey: false }, null), PI_AUTH_COMPATIBILITY_TOKEN); + assert.equal( + resolveRuntimeApiKey({ needsKey: false }, null), + PI_AUTH_COMPATIBILITY_TOKEN, + ); assert.equal( resolveRuntimeApiKey({ needsKey: false }, "old saved key"), PI_AUTH_COMPATIBILITY_TOKEN, @@ -193,11 +234,17 @@ test("uses an in-memory non-secret credential for an explicitly keyless provider assert.deepEqual(resolveRuntimeHeaders({ kind: "openai", needsKey: false }), { Authorization: null, }); - assert.deepEqual(resolveRuntimeHeaders({ kind: "anthropic", needsKey: false }), { - Authorization: null, - "x-api-key": null, - }); - assert.equal(resolveRuntimeHeaders({ kind: "openai", needsKey: true }), undefined); + assert.deepEqual( + resolveRuntimeHeaders({ kind: "anthropic", needsKey: false }), + { + Authorization: null, + "x-api-key": null, + }, + ); + assert.equal( + resolveRuntimeHeaders({ kind: "openai", needsKey: true }), + undefined, + ); }); test("keeps keyless auth off the wire and normalizes local reasoning", async (t) => { @@ -214,7 +261,9 @@ test("keeps keyless auth off the wire and normalizes local reasoning", async (t) object: "chat.completion.chunk", created: 0, model: "local", - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + choices: [ + { index: 0, delta: { role: "assistant" }, finish_reason: null }, + ], })}\n\n`, ); response.write( @@ -349,7 +398,10 @@ test("uses Anthropic's single version path without sending keyless auth headers" }, { event: "message_stop", data: { type: "message_stop" } }, ] - .map(({ event, data }) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + .map( + ({ event, data }) => + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, + ) .join(""), ); }); @@ -444,7 +496,10 @@ test("sends adaptive Claude thinking with the selected native effort", async (t) }, { event: "message_stop", data: { type: "message_stop" } }, ] - .map(({ event, data }) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + .map( + ({ event, data }) => + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, + ) .join(""), ); }); @@ -495,7 +550,10 @@ test("sends adaptive Claude thinking with the selected native effort", async (t) }); test("preserves normal API-key requirements for authenticated providers", () => { - assert.equal(resolveRuntimeApiKey({ needsKey: true }, " live-key "), "live-key"); + assert.equal( + resolveRuntimeApiKey({ needsKey: true }, " live-key "), + "live-key", + ); assert.equal(resolveRuntimeApiKey({ needsKey: true }, " "), undefined); assert.equal(resolveRuntimeApiKey({ needsKey: true }, null), undefined); }); @@ -517,9 +575,32 @@ test("classifies terminal Pi errors without turning an aborted turn into an erro }), "The model couldn't complete this response.", ); - assert.equal(terminalGenerationError({ role: "assistant", stopReason: "aborted" }), null); - assert.equal(terminalGenerationWasAborted({ role: "assistant", stopReason: "aborted" }), true); - assert.equal(terminalGenerationWasAborted({ role: "toolResult", stopReason: "aborted" }), false); + assert.equal( + terminalGenerationError({ role: "assistant", stopReason: "aborted" }), + null, + ); + assert.equal( + terminalGenerationWasAborted({ role: "assistant", stopReason: "aborted" }), + true, + ); + assert.equal( + terminalGenerationWasAborted({ role: "toolResult", stopReason: "aborted" }), + false, + ); +}); + +test("surfaces output-limit stops as saved partial responses", () => { + assert.match( + terminalGenerationLengthError({ + role: "assistant", + stopReason: "length", + }) ?? "", + /output limit.*partial response was saved/iu, + ); + assert.equal( + terminalGenerationLengthError({ role: "assistant", stopReason: "stop" }), + null, + ); }); test("surfaces a dependency abort unless the app explicitly requested cancellation", () => { @@ -531,6 +612,15 @@ test("surfaces a dependency abort unless the app explicitly requested cancellati assert.equal(terminalGenerationInterruptionError(false, false), null); }); +test("retry backoff is immediately cancellable", async () => { + const controller = new AbortController(); + const started = Date.now(); + const waiting = waitForAbortableDelay(5_000, controller.signal); + controller.abort(new Error("stop now")); + await assert.rejects(waiting, /stop now/u); + assert.ok(Date.now() - started < 500); +}); + test("uses final assistant text when a provider does not stream text deltas", () => { assert.equal( terminalAssistantText({ @@ -570,13 +660,19 @@ test("exposes only deliberate provider reasoning and ignores redacted blocks", ( { type: "text", text: "Final answer." }, ], }; - assert.equal(terminalAssistantReasoning(message), "Inspect the request.\n\nDraft the answer."); + assert.equal( + terminalAssistantReasoning(message), + "Inspect the request.\n\nDraft the answer.", + ); assert.equal( terminalAssistantReasoningFallback(message, false), terminalAssistantReasoning(message), ); assert.equal(terminalAssistantReasoningFallback(message, true), ""); - assert.equal(terminalAssistantReasoning({ role: "user", content: message.content }), ""); + assert.equal( + terminalAssistantReasoning({ role: "user", content: message.content }), + "", + ); }); test("falls back per assistant turn instead of dropping a later terminal-only result", () => { @@ -600,3 +696,29 @@ test("falls back per assistant turn instead of dropping a later terminal-only re "", ); }); + +test("reconciles interleaved streamed blocks to Pi terminal content order", () => { + const projection = reconcileTerminalAssistantProjection( + { + full: "Earlier turn.Streamed second block then first.", + reasoning: "Earlier reasoning.Streamed late then early.", + }, + { full: "Earlier turn.".length, reasoning: "Earlier reasoning.".length }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Early thought." }, + { type: "text", text: "First block. " }, + { type: "thinking", thinking: "Late thought." }, + { type: "text", text: "Second block." }, + ], + }, + true, + ); + assert.equal(projection.full, "Earlier turn.First block. Second block."); + assert.equal( + projection.reasoning, + "Earlier reasoning.\n\nEarly thought.\n\nLate thought.", + ); + assert.equal(projection.changed, true); +}); diff --git a/main/services/generation-runtime.ts b/main/services/generation-runtime.ts index b5353a0c..710b6f53 100644 --- a/main/services/generation-runtime.ts +++ b/main/services/generation-runtime.ts @@ -2,7 +2,12 @@ // keyless-provider and terminal-error contracts have fast, deterministic tests. import type { AgentOptions } from "@earendil-works/pi-agent-core"; -import type { Api, Model, ProviderHeaders, ProviderStreams } from "@earendil-works/pi-ai"; +import type { + Api, + Model, + ProviderHeaders, + ProviderStreams, +} from "@earendil-works/pi-ai"; import { googleThinkingLevelsForModel, isGoogleThinkingLevel, @@ -21,7 +26,10 @@ import { import { ANTHROPIC_PROVIDER_ID } from "./anthropic-provider.js"; import { OPENAI_CODEX_PROVIDER_ID } from "./codex-provider.js"; import { GOOGLE_PROVIDER_ID } from "./google-provider.js"; -import { isLmStudioProviderId, isOllamaProviderId } from "./custom-provider-id.js"; +import { + isLmStudioProviderId, + isOllamaProviderId, +} from "./custom-provider-id.js"; /** * Pi's current compatibility transports require a non-empty constructor value @@ -40,7 +48,7 @@ export function shouldExposeReasoning(providerId: string): boolean { ); } -/** Fail closed outside Aiden's native, reasoning-capable provider contracts. */ +/** Preserve provider-specific normalization while honoring every Pi reasoning model. */ export function resolveGenerationThinkingLevel( providerId: string, model: Pick, "reasoning" | "thinkingLevelMap">, @@ -66,11 +74,21 @@ export function resolveGenerationThinkingLevel( ? requested : normalizeAnthropicThinkingLevel(levels, undefined); } - return "off"; + if (!model.reasoning || !requested || requested === "off") return "off"; + if ( + model.thinkingLevelMap && + (!(requested in model.thinkingLevelMap) || + model.thinkingLevelMap[requested] === null) + ) { + return "off"; + } + return requested; } /** The connection-bound runtime model is the sole request-time image authority. */ -export function runtimeSupportsImages(model: Pick, "input">): boolean { +export function runtimeSupportsImages( + model: Pick, "input">, +): boolean { return model.input.includes("image"); } @@ -84,7 +102,9 @@ export function resolveRuntimeBaseUrl(provider: { baseUrl: string; }): string { const baseUrl = provider.baseUrl.replace(/\/+$/u, ""); - return provider.kind === "anthropic" ? baseUrl.replace(/\/v1$/iu, "") : baseUrl; + return provider.kind === "anthropic" + ? baseUrl.replace(/\/v1$/iu, "") + : baseUrl; } export function resolveRuntimeApiKey( @@ -179,9 +199,12 @@ export async function waitForGenerationStateClear( const remaining = deadline - Date.now(); if (remaining <= 0) return false; const pending = completions().filter( - (completion): completion is Promise => completion !== null && completion !== undefined, + (completion): completion is Promise => + completion !== null && completion !== undefined, + ); + const pause = new Promise((resolve) => + setTimeout(resolve, Math.min(25, remaining)), ); - const pause = new Promise((resolve) => setTimeout(resolve, Math.min(25, remaining))); await Promise.race( pending.length > 0 ? [pause, Promise.allSettled(pending).then(() => undefined)] @@ -191,6 +214,25 @@ export async function waitForGenerationStateClear( return true; } +/** Wait without making Stop advisory during a bounded retry backoff. */ +export async function waitForAbortableDelay( + ms: number, + signal?: AbortSignal, +): Promise { + if (ms <= 0) return; + if (signal?.aborted) + throw signal.reason ?? new Error("Generation was cancelled."); + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + const abort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("Generation was cancelled.")); + }; + signal?.addEventListener("abort", abort, { once: true }); + timer.unref?.(); + }); +} + /** Keep the chat identity and provider transport attached to every Pi Agent turn. */ export function buildAgentRuntimeOptions( chatId: string, @@ -205,7 +247,9 @@ export function buildAgentRuntimeOptions( apiKey: options?.apiKey ?? runtime.apiKey, // Runtime headers are last so a keyless provider cannot inherit an // Authorization header from Pi's default client setup. - headers: runtime.headers ? { ...options?.headers, ...runtime.headers } : options?.headers, + headers: runtime.headers + ? { ...options?.headers, ...runtime.headers } + : options?.headers, }), }; } @@ -217,13 +261,29 @@ type TerminalAssistantMessage = { }; /** Extract a Pi protocol-level terminal error from an Agent message. */ -export function terminalGenerationError(message: TerminalAssistantMessage): string | null { - if (message.role !== "assistant" || message.stopReason !== "error") return null; - return message.errorMessage?.trim() || "The model couldn't complete this response."; +export function terminalGenerationError( + message: TerminalAssistantMessage, +): string | null { + if (message.role !== "assistant" || message.stopReason !== "error") + return null; + return ( + message.errorMessage?.trim() || "The model couldn't complete this response." + ); +} + +/** A length stop is a usable partial response, but never a successful completion. */ +export function terminalGenerationLengthError( + message: TerminalAssistantMessage, +): string | null { + return message.role === "assistant" && message.stopReason === "length" + ? "The model reached its output limit. The partial response was saved; ask it to continue." + : null; } /** Pi reports user-initiated stops as a terminal assistant message as well. */ -export function terminalGenerationWasAborted(message: TerminalAssistantMessage): boolean { +export function terminalGenerationWasAborted( + message: TerminalAssistantMessage, +): boolean { return message.role === "assistant" && message.stopReason === "aborted"; } @@ -238,8 +298,12 @@ export function terminalGenerationInterruptionError( } /** Return final text when a provider completes without emitting text deltas. */ -export function terminalAssistantText(message: { role?: string; content?: unknown }): string { - if (message.role !== "assistant" || !Array.isArray(message.content)) return ""; +export function terminalAssistantText(message: { + role?: string; + content?: unknown; +}): string { + if (message.role !== "assistant" || !Array.isArray(message.content)) + return ""; return message.content .filter( (part): part is { type: "text"; text: string } => @@ -261,11 +325,17 @@ export function terminalAssistantTextFallback( } /** Return visible, non-redacted thinking blocks from a terminal Pi assistant message. */ -export function terminalAssistantReasoning(message: { role?: string; content?: unknown }): string { - if (message.role !== "assistant" || !Array.isArray(message.content)) return ""; +export function terminalAssistantReasoning(message: { + role?: string; + content?: unknown; +}): string { + if (message.role !== "assistant" || !Array.isArray(message.content)) + return ""; return message.content .filter( - (part): part is { type: "thinking"; thinking: string; redacted?: boolean } => + ( + part, + ): part is { type: "thinking"; thinking: string; redacted?: boolean } => typeof part === "object" && part !== null && (part as { type?: unknown }).type === "thinking" && @@ -283,3 +353,38 @@ export function terminalAssistantReasoningFallback( ): string { return receivedReasoningDelta ? "" : terminalAssistantReasoning(message); } + +export interface TerminalAssistantProjection { + full: string; + reasoning: string; + changed: boolean; +} + +/** + * Provider delta events can arrive in block/event order that differs from the + * canonical Pi message content order. Replace only the current assistant turn + * with Pi's terminal projection so persistence and the renderer stay exact. + */ +export function reconcileTerminalAssistantProjection( + accumulated: { full: string; reasoning: string }, + turnStart: { full: number; reasoning: number }, + message: { role?: string; content?: unknown }, + exposeReasoning: boolean, +): TerminalAssistantProjection { + if (message.role !== "assistant") { + return { ...accumulated, changed: false }; + } + const full = `${accumulated.full.slice(0, turnStart.full)}${terminalAssistantText(message)}`; + const terminalReasoning = exposeReasoning + ? terminalAssistantReasoning(message) + : ""; + const reasoningPrefix = accumulated.reasoning.slice(0, turnStart.reasoning); + const reasoning = terminalReasoning + ? `${reasoningPrefix}${reasoningPrefix.trim() ? "\n\n" : ""}${terminalReasoning}` + : reasoningPrefix; + return { + full, + reasoning, + changed: full !== accumulated.full || reasoning !== accumulated.reasoning, + }; +} diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index 2bc2107f..d8c76621 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -29,21 +29,24 @@ import { import { skillRegistry } from "./skill-registry-main.js"; import { buildAgentRuntimeOptions, + reconcileTerminalAssistantProjection, resolveGenerationThinkingLevel, runtimeSupportsImages, settleGenerationCleanup, shouldExposeReasoning, - terminalAssistantReasoningFallback, - terminalAssistantTextFallback, terminalGenerationError, + terminalGenerationLengthError, terminalGenerationInterruptionError, terminalGenerationWasAborted, + waitForAbortableDelay, waitForGenerationStateClear, } from "./generation-runtime.js"; import { ANTHROPIC_PROVIDER_ID } from "./anthropic-provider.js"; import { resolveModelRuntime } from "./model-runtime.js"; import { assistantUsageRecord } from "./usage-accounting.js"; import { usageStore } from "./usage-store.js"; +import { storedPiAssistantMessage } from "./pi-message-storage.js"; +import { chatForRenderer } from "./visible-chat-projection.js"; import { cancelWorkspaceGenerationsAndSettle } from "./workspace-mutation-gate.js"; import type { ApprovalDecision, @@ -74,11 +77,14 @@ import { ToolApprovalCoordinator } from "./tool-approval.js"; import { chatMessageToPiMessage } from "./generation-messages.js"; import { createPiCompactionModels, + needsImmediatePiCompaction, PiCompactionCoordinator, type PiCompactionEvent, } from "./pi-compaction-core.js"; import { appendPiMessages, + beginPiGenerationTurn, + commitPiGenerationTurn, piCompactionSessionStore, syncChatMessagesToPiSession, } from "./pi-compaction-session-store.js"; @@ -1018,6 +1024,9 @@ export const llmClient = { content, model: params.model, reasoning: reasoning.trim() ? reasoning : undefined, + pi: lastAssistantMessage + ? storedPiAssistantMessage(lastAssistantMessage) + : undefined, timeline: finalTimeline.steps.length ? finalTimeline : undefined, subagents, }, @@ -1048,15 +1057,20 @@ export const llmClient = { let reasoning = ""; let errored: string | null = null; let aborted = false; - let currentAssistantTurnHadTextDelta = false; let currentAssistantTurnHadReasoningDelta = false; + let currentAssistantTurnStart = { full: 0, reasoning: 0 }; let pendingPiMessages: AgentMessage[] = []; let lastAssistantMessage: AssistantMessage | undefined; + let emergencyContextReduction = false; let activeCompactionStepId: string | undefined; + let generationJournalLeafId: string | null = null; let piSession: Awaited> | undefined; let compaction: PiCompactionCoordinator | undefined; let candidate: Agent | null = null; + let piJournalHealthy = true; + let flushPiMessages: () => Promise = async () => + pendingPiMessages.length === 0; try { const assistantMcpInventory = authoritativeMode === "assistant" @@ -1152,12 +1166,40 @@ export const llmClient = { }; compaction = new PiCompactionCoordinator({ session: piSession, - models: createPiCompactionModels(runtime), + models: createPiCompactionModels(runtime, (message) => + usageStore.record( + assistantUsageRecord({ + message, + provider: runtime.provider, + model, + source: "compaction", + }), + ), + ), model, thinkingLevel, signal: initialization.controller.signal, onEvent: onCompactionEvent, }); + const promptJournal = piSession; + flushPiMessages = async (): Promise => { + if (pendingPiMessages.length === 0) return true; + const batch = pendingPiMessages; + pendingPiMessages = []; + try { + await appendPiMessages(promptJournal, batch); + return true; + } catch (error) { + piJournalHealthy = false; + pendingPiMessages = [...batch, ...pendingPiMessages]; + logger.error( + "pi", + `Could not append Pi session messages for stream ${streamId}.`, + error, + ); + return false; + } + }; const currentUser = [...authoritativeChat.messages] .reverse() @@ -1196,30 +1238,47 @@ export const llmClient = { } } } + let currentPromptMessages: AgentMessage[] | undefined; if (currentUser) { + const contentOverrides = new Map(); + if ( + initialization.skillInvocation?.userMessageId === currentUser.id && + initialization.skillPrompt + ) { + contentOverrides.set(currentUser.id, initialization.skillPrompt); + } await syncChatMessagesToPiSession( piSession, authoritativeChat.messages, model, supportsImages, + contentOverrides, ); + compaction.beginPrompt(); + const currentPromptCompaction = await compaction.checkContextPressure(); + if (currentPromptCompaction.messages) { + currentPromptMessages = [...currentPromptCompaction.messages]; + } } - const initialMessages = currentUser - ? [ - ...prePromptMessages, - chatMessageToPiMessage( - currentUser, - model, - supportsImages, - initialization.skillInvocation?.userMessageId === currentUser.id - ? initialization.skillPrompt - : undefined, - ), - ] - : (await piSession.buildContext()).messages; + const initialMessages = + currentPromptMessages ?? + (currentUser + ? [ + ...prePromptMessages, + chatMessageToPiMessage( + currentUser, + model, + supportsImages, + initialization.skillInvocation?.userMessageId === currentUser.id + ? initialization.skillPrompt + : undefined, + ), + ] + : (await piSession.buildContext()).messages); + generationJournalLeafId = await piSession.getLeafId(); initialization.skillInvocation = undefined; initialization.skillPrompt = undefined; - compaction.beginPrompt(); + if (!currentUser) compaction.beginPrompt(); candidate = new Agent({ ...buildAgentRuntimeOptions(params.chatId, runtime), convertToLlm, @@ -1240,8 +1299,10 @@ export const llmClient = { contextWindow: model.contextWindow, systemPrompt, tools, + supportsImages, }, (result) => { + emergencyContextReduction = true; logger.info( "pi", `Compacted generation context for stream ${streamId}.`, @@ -1266,23 +1327,50 @@ export const llmClient = { tools, messages: initialMessages, }, + // Aiden tools can mutate the same workspace, scheduler, or external + // service. Preserve model-authored ordering across the foreground run. + toolExecution: "sequential", prepareNextTurnWithContext: async ({ toolResults, context }) => { - if (!attendedAssistant) return undefined; - const state = advanceAttendedToolErrorState( - consecutiveAttendedToolErrorTurns, + let nextContext = context; + let changed = false; + if (attendedAssistant) { + const state = advanceAttendedToolErrorState( + consecutiveAttendedToolErrorTurns, + toolResults, + ); + consecutiveAttendedToolErrorTurns = state.consecutiveErrorTurns; + if (state.shouldStop) { + logger.warn( + "pi", + `Stopped attended Assistant tool retries for stream ${streamId} and requested a text-only recovery.`, + ); + nextContext = recoverAttendedToolErrorContext(context); + changed = true; + } + } + if (!(await flushPiMessages())) { + return changed ? { context: nextContext } : undefined; + } + // Tool results are now durable, so estimate the complete journal + // rather than the preceding assistant usage alone. + const immediate = needsImmediatePiCompaction( toolResults, + model.contextWindow, ); - consecutiveAttendedToolErrorTurns = state.consecutiveErrorTurns; - if (state.shouldStop) { - logger.warn( - "pi", - `Stopped attended Assistant tool retries for stream ${streamId} and requested a text-only recovery.`, - ); + const result = await compaction?.checkContextPressure({ + forceThreshold: immediate, + sealCurrentTurnIfNeeded: immediate, + }); + emergencyContextReduction = false; + if (result?.messages) { + // Pi's loop uses the returned context for this run; explicitly + // install the checkpoint for the Agent's next prompt too. + if (candidate) candidate.state.messages = [...result.messages]; return { - context: recoverAttendedToolErrorContext(context), + context: { ...nextContext, messages: [...result.messages] }, }; } - return undefined; + return changed ? { context: nextContext } : undefined; }, // Computer Use mutations always pause. Folder mutations pause in "ask" mode. beforeToolCall: async (context, signal) => { @@ -1462,8 +1550,11 @@ export const llmClient = { switch (event.type) { case "message_start": if (event.message.role === "assistant") { - currentAssistantTurnHadTextDelta = false; currentAssistantTurnHadReasoningDelta = false; + currentAssistantTurnStart = { + full: full.length, + reasoning: reasoning.length, + }; } break; case "message_update": { @@ -1478,7 +1569,6 @@ export const llmClient = { } else if (e.type === "thinking_end") timeline.thinkingEnded(); if (e.type === "text_delta") { full += e.delta; - currentAssistantTurnHadTextDelta = true; noteModelBecameReady(); sendGeneration(streamId, "chat:delta", { streamId, @@ -1517,22 +1607,36 @@ export const llmClient = { } const terminalError = terminalGenerationError(event.message); if (terminalError) errored = terminalError; + const lengthError = terminalGenerationLengthError(event.message); + if (lengthError) errored = lengthError; if (terminalGenerationWasAborted(event.message)) aborted = true; - if (!terminalError) { - // Pi may finish any assistant turn without preceding text_delta - // events. Fall back per turn so a later tool-followup is retained - // without duplicating normally streamed text. - full += terminalAssistantTextFallback( - event.message, - currentAssistantTurnHadTextDelta, - ); - if (exposeReasoning) { - const fallback = terminalAssistantReasoningFallback( - event.message, - currentAssistantTurnHadReasoningDelta, - ); - if (fallback) - reasoning += `${reasoning.trim() ? "\n\n" : ""}${fallback}`; + const projection = reconcileTerminalAssistantProjection( + { full, reasoning }, + currentAssistantTurnStart, + event.message, + exposeReasoning, + ); + if (projection.changed) { + full = projection.full; + reasoning = projection.reasoning; + // Rebuild the whole visible projection after provider block + // interleaving or a terminal-only response. + sendGeneration(streamId, "chat:delta", { + streamId, + delta: "", + reset: true, + }); + if (full) { + sendGeneration(streamId, "chat:delta", { + streamId, + delta: full, + }); + } + if (reasoning) { + sendGeneration(streamId, "chat:reasoning-delta", { + streamId, + delta: reasoning, + }); } } break; @@ -1637,33 +1741,96 @@ export const llmClient = { } const piJournal = piSession; const piCoordinator = compaction; - const flushPiMessages = async (): Promise => { - if (pendingPiMessages.length === 0) return true; - const batch = pendingPiMessages; - pendingPiMessages = []; - try { - await appendPiMessages(piJournal, batch); - return true; - } catch (error) { - pendingPiMessages = [...batch, ...pendingPiMessages]; - logger.error( - "pi", - `Could not append Pi session messages for stream ${streamId}.`, - error, + const piTurnStartLeafId = generationJournalLeafId; + let generationTurnTransactionId: string | undefined; + try { + generationTurnTransactionId = await beginPiGenerationTurn(piJournal); + } catch (error) { + piJournalHealthy = false; + logger.warn( + "pi", + `Could not begin the crash-recovery envelope for stream ${streamId}.`, + error, + ); + } + const finalizePiTurnPersistence = async (persisted: { + chat: Chat | undefined; + error: string | undefined; + messageId: string | undefined; + }) => { + if (persisted.error) { + try { + await piJournal.moveTo(piTurnStartLeafId); + } catch (error) { + logger.error( + "pi", + `Could not roll back an unpersisted Pi turn for stream ${streamId}.`, + error, + ); + } + piJournalHealthy = false; + generationTurnTransactionId = undefined; + return; + } + if (!persisted.messageId) { + await piJournal.moveTo(piTurnStartLeafId).catch(() => undefined); + generationTurnTransactionId = undefined; + return; + } + const reconcileVisibleAssistant = async () => { + await piJournal.moveTo(piTurnStartLeafId); + const visible = persisted.chat?.messages.find( + (message) => message.id === persisted.messageId, ); - return false; + if (!visible) { + throw new Error( + "The persisted assistant could not be found for Pi journal recovery.", + ); + } + await syncChatMessagesToPiSession( + piJournal, + [visible], + model, + supportsImages, + ); + generationTurnTransactionId = undefined; + }; + if (!piJournalHealthy) { + try { + await reconcileVisibleAssistant(); + } catch (recoveryError) { + logger.error( + "pi", + `Could not reconcile the persisted assistant after a Pi batch failure for stream ${streamId}.`, + recoveryError, + ); + } + return; } - }; - const markPersistedAssistant = async (messageId: string | undefined) => { - if (!messageId) return; try { - await appendPiMessages(piJournal, [], messageId); + await appendPiMessages(piJournal, [], persisted.messageId); + if (generationTurnTransactionId) { + await commitPiGenerationTurn( + piJournal, + generationTurnTransactionId, + ); + generationTurnTransactionId = undefined; + } } catch (error) { logger.warn( "pi", `Could not mark persisted assistant message for stream ${streamId}.`, error, ); + try { + await reconcileVisibleAssistant(); + } catch (recoveryError) { + logger.error( + "pi", + `Could not reconcile the persisted assistant after a Pi marker failure for stream ${streamId}.`, + recoveryError, + ); + } } }; const runWithPiCompaction = async () => { @@ -1682,27 +1849,30 @@ export const llmClient = { const completedAssistant = lastAssistantMessage as AssistantMessage | undefined; if (!completedAssistant) return; - const result = await piCoordinator.check(completedAssistant); + const result = await piCoordinator.check(completedAssistant, { + forceThreshold: emergencyContextReduction, + }); + emergencyContextReduction = false; if (result.errorMessage && completedAssistant.stopReason === "error") { errored = result.errorMessage; } if (!result.messages) return; - const rebuiltMessages = [...result.messages]; - const trailing = rebuiltMessages[rebuiltMessages.length - 1]; - if ( - result.shouldRetry && - trailing?.role === "assistant" && - trailing.stopReason === "error" - ) { - rebuiltMessages.pop(); - } - agent.state.messages = rebuiltMessages; + agent.state.messages = [...result.messages]; if (!result.shouldRetry) return; full = full.slice(0, fullLengthBeforeAttempt); reasoning = reasoning.slice(0, reasoningLengthBeforeAttempt); errored = null; aborted = false; + sendGeneration(streamId, "chat:delta", { + streamId, + delta: "", + reset: true, + }); + await waitForAbortableDelay( + result.retryDelayMs ?? 0, + initialization.controller.signal, + ); } }; @@ -1762,7 +1932,7 @@ export const llmClient = { reasoning, finalTimeline, ); - await markPersistedAssistant(persisted.messageId); + await finalizePiTurnPersistence(persisted); sendGeneration(streamId, "chat:error", { streamId, message: persisted.error @@ -1771,7 +1941,7 @@ export const llmClient = { content: full || undefined, reasoning: reasoning || undefined, timeline: finalTimeline, - chat: persisted.chat, + chat: chatForRenderer(persisted.chat ?? null) ?? undefined, }); } else if (!full.trim() && !wasCancelled) { const finalTimeline = attachClaimCheck( @@ -1783,7 +1953,7 @@ export const llmClient = { reasoning, finalTimeline, ); - await markPersistedAssistant(persisted.messageId); + await finalizePiTurnPersistence(persisted); sendGeneration(streamId, "chat:error", { streamId, message: persisted.error @@ -1791,7 +1961,7 @@ export const llmClient = { : "The model returned an empty response. Try again.", reasoning: reasoning || undefined, timeline: finalTimeline, - chat: persisted.chat, + chat: chatForRenderer(persisted.chat ?? null) ?? undefined, }); } else { // Covers both normal completion and user abort (partial `full`). @@ -1804,7 +1974,7 @@ export const llmClient = { reasoning, finalTimeline, ); - await markPersistedAssistant(persisted.messageId); + await finalizePiTurnPersistence(persisted); if (persisted.error) { sendGeneration(streamId, "chat:error", { streamId, @@ -1819,7 +1989,7 @@ export const llmClient = { content: full, reasoning: reasoning || undefined, timeline: finalTimeline, - chat: persisted.chat, + chat: chatForRenderer(persisted.chat ?? null) ?? undefined, }); } } @@ -1832,7 +2002,7 @@ export const llmClient = { reasoning, finalTimeline, ); - await markPersistedAssistant(persisted.messageId); + await finalizePiTurnPersistence(persisted); sendGeneration(streamId, "chat:error", { streamId, message: persisted.error @@ -1841,7 +2011,7 @@ export const llmClient = { content: full || undefined, reasoning: reasoning || undefined, timeline: finalTimeline, - chat: persisted.chat, + chat: chatForRenderer(persisted.chat ?? null) ?? undefined, }); } finally { try { diff --git a/main/services/model-runtime-core.ts b/main/services/model-runtime-core.ts index b161edec..83967cad 100644 --- a/main/services/model-runtime-core.ts +++ b/main/services/model-runtime-core.ts @@ -1,5 +1,15 @@ -import { anthropicMessagesApi, openAICompletionsApi } from "@earendil-works/pi-ai/compat"; -import type { Api, Model, ProviderHeaders, ProviderStreams } from "@earendil-works/pi-ai"; +import { + anthropicMessagesApi, + openAICompletionsApi, +} from "@earendil-works/pi-ai/compat"; +import { + createModels, + createProvider, + type Api, + type Model, + type ProviderHeaders, + type ProviderStreams, +} from "@earendil-works/pi-ai"; import { OPENAI_CODEX_BASE_URL, OPENAI_CODEX_PROVIDER_ID, @@ -32,7 +42,9 @@ function streamsFor(api: Api): ProviderStreams { } function apiFor(provider: StoredProvider): Api { - return provider.kind === "anthropic" ? "anthropic-messages" : "openai-completions"; + return provider.kind === "anthropic" + ? "anthropic-messages" + : "openai-completions"; } function buildModel( @@ -70,9 +82,15 @@ export interface ResolvedModelRuntime { export interface ModelRuntimeDependencies { getProvider(providerId: string): Promise; getApiKey(provider: StoredProvider): Promise; - resolveRuntimeLimits(provider: StoredProvider, modelId: string): Promise; + resolveRuntimeLimits( + provider: StoredProvider, + modelId: string, + ): Promise; codex: { - prepareRuntimeModel(modelId: string, signal?: AbortSignal): Promise>; + prepareRuntimeModel( + modelId: string, + signal?: AbortSignal, + ): Promise>; streamSimple: ProviderStreams["streamSimple"]; }; native: { @@ -128,19 +146,46 @@ export async function resolveModelRuntimeWith( ); } - const storedApiKey = provider.needsKey ? await dependencies.getApiKey(provider) : null; + const storedApiKey = provider.needsKey + ? await dependencies.getApiKey(provider) + : null; const apiKey = resolveRuntimeApiKey(provider, storedApiKey); if (provider.needsKey && !apiKey) { - throw new Error(`No API key set for ${provider.label}. Add one in Settings → Providers.`); + throw new Error( + `No API key set for ${provider.label}. Add one in Settings → Providers.`, + ); } const limits = await dependencies.resolveRuntimeLimits(provider, modelId); const model = buildModel(provider, modelId, limits); + const headers = resolveRuntimeHeaders(provider); + const models = createModels(); + models.setProvider( + createProvider({ + id: provider.id, + name: provider.label, + baseUrl: model.baseUrl, + headers, + models: [model], + auth: { + apiKey: { + name: `${provider.label} runtime key`, + resolve: async () => ({ + auth: { apiKey, headers }, + source: "Aiden custom provider", + }), + }, + }, + api: streamsFor(model.api), + }), + ); return { provider, model, apiKey, - headers: resolveRuntimeHeaders(provider), - streams: streamsFor(model.api), + headers, + // Custom endpoints now use the same Pi provider/auth/model composition as + // built-ins; the compat adapter is only the provider's API implementation. + streams: { streamSimple: models.streamSimple.bind(models) }, }; } diff --git a/main/services/onboarding-reset-lifecycle.test.ts b/main/services/onboarding-reset-lifecycle.test.ts index f6c65003..9ef94b20 100644 --- a/main/services/onboarding-reset-lifecycle.test.ts +++ b/main/services/onboarding-reset-lifecycle.test.ts @@ -3,16 +3,26 @@ import * as fs from "node:fs/promises"; import test from "node:test"; test("the onboarding reset closes the renderer before destructive cleanup and preserves unrelated local storage", async () => { - const source = await fs.readFile(new URL("../index.ts", import.meta.url), "utf8"); + const source = await fs.readFile( + new URL("../index.ts", import.meta.url), + "utf8", + ); const start = source.indexOf("async function requestOnboardingReset"); const end = source.indexOf('ipcMain.handle("app:setCloseGuard"', start); - assert.ok(start >= 0 && end > start, "onboarding reset lifecycle is registered in main"); + assert.ok( + start >= 0 && end > start, + "onboarding reset lifecycle is registered in main", + ); const reset = source.slice(start, end); - assert.match(reset, /shutdownStarted \|\| installUpdateOnQuit/u); + assert.match(reset, /shutdownStarted\s*\|\|\s*installUpdateOnQuit/u); const authorize = reset.indexOf('authorizeProtectedAction(window, "close")'); - const clearCompletion = reset.indexOf("await clearRendererOnboardingCompletion(window)"); - const closeRenderer = reset.indexOf("await closeRendererBeforeShutdown(window)"); + const clearCompletion = reset.indexOf( + "await clearRendererOnboardingCompletion(window)", + ); + const closeRenderer = reset.indexOf( + "await closeRendererBeforeShutdown(window)", + ); const clearData = reset.indexOf("await resetOnboardingData()"); const relaunch = reset.indexOf("app.relaunch()"); const shutdown = reset.indexOf("await shutdownAndQuit(true)"); @@ -22,11 +32,27 @@ test("the onboarding reset closes the renderer before destructive cleanup and pr clearCompletion > authorize, "only the onboarding completion marker is cleared after close authorization", ); - assert.ok(closeRenderer > clearCompletion, "renderer close begins after its completion marker is cleared"); - assert.ok(clearData > closeRenderer, "persistent setup is untouched until the renderer has closed"); - assert.ok(relaunch > clearData, "relaunch is armed only after destructive cleanup succeeds"); - assert.ok(shutdown > relaunch, "normal service shutdown runs after relaunch is armed"); - assert.doesNotMatch(reset, /clearStorageData/u, "unrelated renderer preferences are preserved"); + assert.ok( + closeRenderer > clearCompletion, + "renderer close begins after its completion marker is cleared", + ); + assert.ok( + clearData > closeRenderer, + "persistent setup is untouched until the renderer has closed", + ); + assert.ok( + relaunch > clearData, + "relaunch is armed only after destructive cleanup succeeds", + ); + assert.ok( + shutdown > relaunch, + "normal service shutdown runs after relaunch is armed", + ); + assert.doesNotMatch( + reset, + /clearStorageData/u, + "unrelated renderer preferences are preserved", + ); assert.match(reset, /protectedAction = "onboarding-reset"/u); assert.match(reset, /restoreRendererOnboardingCompletion/u); assert.match( @@ -34,5 +60,9 @@ test("the onboarding reset closes the renderer before destructive cleanup and pr /protectedAction = null;[\s\S]*?await createMainWindow\(\)/u, "a recovered window restores normal close protection", ); - assert.match(reset, /await createMainWindow\(\)/u, "a failed post-close reset reopens the app"); + assert.match( + reset, + /await createMainWindow\(\)/u, + "a failed post-close reset reopens the app", + ); }); diff --git a/main/services/pi-compaction-core.test.ts b/main/services/pi-compaction-core.test.ts index 96969bcf..5d2a7f24 100644 --- a/main/services/pi-compaction-core.test.ts +++ b/main/services/pi-compaction-core.test.ts @@ -1,9 +1,22 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, rm, stat } from "node:fs/promises"; +import { + appendFile, + mkdtemp, + mkdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { InMemorySessionRepo, type Session } from "@earendil-works/pi-agent-core"; +import { + InMemorySessionRepo, + JsonlSessionRepo, + type Session, +} from "@earendil-works/pi-agent-core"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { createModels, fauxAssistantMessage, @@ -12,15 +25,35 @@ import { type AssistantMessage, type Model, } from "@earendil-works/pi-ai"; -import { PiCompactionCoordinator, type PiCompactionEvent } from "./pi-compaction-core.js"; +import { + PiCompactionCoordinator, + type PiCompactionEvent, +} from "./pi-compaction-core.js"; import { AIDEN_CHAT_MESSAGE_MARKER, + AIDEN_PI_TRANSACTION, + appendPiMessages, + beginPiGenerationTurn, PiCompactionSessionStore, syncChatMessagesToPiSession, } from "./pi-compaction-session-store.js"; import type { ChatMessage } from "./types.js"; -const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; +const ZERO_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, +}; + +function structuredSummary(label: string): string { + return `## Goal\n${label}\n\n## Constraints & Preferences\n- none\n\n## Progress\n### Done\n- [x] preserved state\n\n### In Progress\n- [ ] continue\n\n### Blocked\n- none\n\n## Key Decisions\n- preserve continuity\n\n## Next Steps\n1. Continue\n\n## Critical Context\n- ${label}`; +} + +function splitSummary(label: string): string { + return `## Original Request\n${label}\n\n## Early Progress\n- preserved\n\n## Context for Suffix\n- continue`; +} async function memorySession(id = "compaction-test"): Promise { return new InMemorySessionRepo().create({ id }); @@ -80,7 +113,9 @@ async function appendCompressibleHistory( suffix = "one", ): Promise { await session.appendMessage(user(`old-${suffix}: ${"x".repeat(1_200)}`)); - await session.appendMessage(assistant(model, { input: 400, text: `middle-${suffix}` })); + await session.appendMessage( + assistant(model, { input: 400, text: `middle-${suffix}` }), + ); await session.appendMessage(user(`latest-${suffix}: ${"y".repeat(500)}`)); const last = assistant(model, { input: 950, text: `answer-${suffix}` }); await session.appendMessage(last); @@ -89,9 +124,7 @@ async function appendCompressibleHistory( test("Pi coordinator appends a native checkpoint and rebuilds from it", async () => { const { faux, models, model } = compactionFixture(); - faux.setResponses([ - fauxAssistantMessage("## Goal\nContinue.\n\n## Progress\nDone: preserved exact state."), - ]); + faux.setResponses([fauxAssistantMessage(structuredSummary("Continue."))]); const session = await memorySession(); const last = await appendCompressibleHistory(session, model); const before = await session.getEntries(); @@ -123,10 +156,10 @@ test("repeated compaction updates the previous Pi summary", async () => { const { faux, models, model } = compactionFixture(); const summarySeen: boolean[] = []; faux.setResponses([ - fauxAssistantMessage("first checkpoint"), + fauxAssistantMessage(structuredSummary("first checkpoint")), (context) => { summarySeen.push(JSON.stringify(context).includes("first checkpoint")); - return fauxAssistantMessage("updated checkpoint"); + return fauxAssistantMessage(structuredSummary("updated checkpoint")); }, ]); const session = await memorySession(); @@ -144,21 +177,28 @@ test("repeated compaction updates the previous Pi summary", async () => { assert.equal((await coordinator.check(second)).compacted, true); assert.deepEqual(summarySeen, [true]); - const compactions = (await session.getEntries()).filter((entry) => entry.type === "compaction"); + const compactions = (await session.getEntries()).filter( + (entry) => entry.type === "compaction", + ); assert.equal(compactions.length, 2); - assert.equal(compactions[compactions.length - 1]?.summary, "updated checkpoint"); + assert.equal( + compactions[compactions.length - 1]?.summary, + structuredSummary("updated checkpoint"), + ); }); test("overflow compacts and retries at most once", async () => { const { faux, models, model } = compactionFixture(); - faux.setResponses([fauxAssistantMessage("overflow checkpoint")]); + faux.setResponses([ + fauxAssistantMessage(structuredSummary("overflow checkpoint")), + ]); const session = await memorySession(); await appendCompressibleHistory(session, model); const firstOverflow = assistant(model, { stopReason: "error", errorMessage: "Request exceeds the context window.", }); - await session.appendMessage(firstOverflow); + await appendPiMessages(session, [firstOverflow]); const events: PiCompactionEvent[] = []; const coordinator = new PiCompactionCoordinator({ session, @@ -172,6 +212,35 @@ test("overflow compacts and retries at most once", async () => { const first = await coordinator.check(firstOverflow); assert.equal(first.compacted, true); assert.equal(first.shouldRetry, true); + assert.equal( + (await session.getBranch()).some( + (entry) => + entry.type === "custom" && + entry.customType === AIDEN_PI_TRANSACTION, + ), + false, + ); + assert.equal( + (await session.buildContext()).messages.some( + (message) => + message.role === "assistant" && message.stopReason === "error", + ), + false, + ); + + const recovered = assistant(model, { + stopReason: "stop", + text: "Recovered answer.", + timestamp: Date.now() + 5, + }); + await session.appendMessage(recovered); + assert.equal( + (await session.buildContext()).messages.some( + (message) => + message.role === "assistant" && message.stopReason === "error", + ), + false, + ); const secondOverflow = assistant(model, { stopReason: "error", @@ -184,16 +253,113 @@ test("overflow compacts and retries at most once", async () => { assert.equal(second.shouldRetry, false); assert.match(second.errorMessage ?? "", /after one compact-and-retry/u); assert.equal( - (await session.getEntries()).filter((entry) => entry.type === "compaction").length, + (await session.getEntries()).filter((entry) => entry.type === "compaction") + .length, 1, ); assert.equal(events.filter((event) => event.type === "start").length, 1); + assert.equal( + (await session.buildContext()).messages.some( + (message) => + message.role === "assistant" && + (message.stopReason === "error" || message.stopReason === "length"), + ), + false, + ); +}); + +test("length-stop overflow is abandoned before retry context is rebuilt", async () => { + const { faux, models, model } = compactionFixture(); + faux.setResponses([ + fauxAssistantMessage(structuredSummary("length checkpoint")), + ]); + const session = await memorySession(); + await appendCompressibleHistory(session, model); + await session.appendMessage(user("current overflow request", Date.now() + 1)); + const overflow = assistant(model, { + input: model.contextWindow, + output: 0, + stopReason: "length", + }); + await session.appendMessage(overflow); + const coordinator = new PiCompactionCoordinator({ + session, + models, + model, + thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 100 }, + }); + + const result = await coordinator.check(overflow); + + assert.equal(result.compacted, true); + assert.equal(result.shouldRetry, true); + const retryMessages = result.messages ?? []; + assert.notEqual(retryMessages[retryMessages.length - 1]?.role, "assistant"); + assert.equal( + (await session.buildContext()).messages.some( + (message) => + message.role === "assistant" && message.stopReason === "length", + ), + false, + ); +}); + +test("transient provider failures are durably abandoned and retried only once", async () => { + const { models, model } = compactionFixture(); + const session = await memorySession(); + await session.appendMessage(user("keep this request")); + const firstFailure = assistant(model, { + stopReason: "error", + errorMessage: "503 service unavailable", + }); + await appendPiMessages(session, [firstFailure]); + const coordinator = new PiCompactionCoordinator({ + session, + models, + model, + thinkingLevel: "off", + retryDelayMs: 0, + }); + + const first = await coordinator.check(firstFailure); + assert.equal(first.shouldRetry, true); + assert.equal(first.retryDelayMs, 0); + assert.equal(first.messages?.[first.messages.length - 1]?.role, "user"); + assert.equal( + (await session.buildContext()).messages.some( + (message) => + message.role === "assistant" && message.stopReason === "error", + ), + false, + ); + + const secondFailure = assistant(model, { + stopReason: "error", + errorMessage: "network error: connection reset", + timestamp: firstFailure.timestamp + 1, + }); + await appendPiMessages(session, [secondFailure]); + const second = await coordinator.check(secondFailure); + assert.equal(second.shouldRetry, false); + assert.match(second.errorMessage ?? "", /after one automatic retry/iu); + assert.equal(second.messages?.[second.messages.length - 1]?.role, "user"); + assert.equal( + (await session.buildContext()).messages.some( + (message) => + message.role === "assistant" && message.stopReason === "error", + ), + false, + ); }); test("summary failure leaves the append-only history authoritative", async () => { const { faux, models, model } = compactionFixture(); faux.setResponses([ - fauxAssistantMessage("", { stopReason: "error", errorMessage: "summarizer unavailable" }), + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "summarizer unavailable", + }), ]); const session = await memorySession(); const last = await appendCompressibleHistory(session, model); @@ -213,13 +379,191 @@ test("summary failure leaves the append-only history authoritative", async () => assert.deepEqual(await session.getEntries(), before); }); +test("an empty successful summary is rejected without hiding history", async () => { + const { faux, models, model } = compactionFixture(); + faux.setResponses([fauxAssistantMessage("")]); + const session = await memorySession(); + const last = await appendCompressibleHistory(session, model); + const before = await session.getEntries(); + const coordinator = new PiCompactionCoordinator({ + session, + models, + model, + thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 100 }, + }); + + const result = await coordinator.check(last); + + assert.equal(result.compacted, false); + assert.match(result.errorMessage ?? "", /empty summary/u); + assert.deepEqual(await session.getEntries(), before); +}); + +test("a malformed successful summary is rejected without hiding history", async () => { + const { faux, models, model } = compactionFixture(); + faux.setResponses([ + fauxAssistantMessage("A vague paragraph with no continuity structure."), + ]); + const session = await memorySession(); + const last = await appendCompressibleHistory(session, model); + const before = await session.getEntries(); + const coordinator = new PiCompactionCoordinator({ + session, + models, + model, + thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 100 }, + }); + + const result = await coordinator.check(last); + assert.equal(result.compacted, false); + assert.match(result.errorMessage ?? "", /malformed summary/iu); + assert.deepEqual(await session.getEntries(), before); +}); + +test("a length-truncated structured summary never commits", async () => { + const { faux, models, model } = compactionFixture(); + faux.setResponses([ + fauxAssistantMessage(structuredSummary("truncated"), { + stopReason: "length", + }), + ]); + const session = await memorySession(); + const last = await appendCompressibleHistory(session, model); + const before = await session.getEntries(); + const result = await new PiCompactionCoordinator({ + session, models, model, thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 100 }, + }).check(last); + assert.equal(result.compacted, false); + assert.match(result.errorMessage ?? "", /stopped before completing/iu); + assert.deepEqual(await session.getEntries(), before); +}); + +test("split-turn validation requires the dedicated prefix summary", async () => { + const { faux, models, model } = compactionFixture(); + faux.setResponses([fauxAssistantMessage(structuredSummary("wrong half"))]); + const session = await memorySession(); + await session.appendMessage(user(`one turn ${"x".repeat(800)}`, 10)); + const last = assistant(model, { input: 950, text: "retained suffix", timestamp: 20 }); + await session.appendMessage(last); + const before = await session.getEntries(); + const result = await new PiCompactionCoordinator({ + session, models, model, thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 1 }, + }).check(last); + assert.equal(result.compacted, false); + assert.match(result.errorMessage ?? "", /malformed summary/iu); + assert.deepEqual(await session.getEntries(), before); +}); + +test("a complete split-turn summary commits both independently valid halves", async () => { + const { faux, models, model } = compactionFixture(); + faux.setResponses([fauxAssistantMessage(splitSummary("one turn"))]); + const session = await memorySession(); + await session.appendMessage(user(`one turn ${"x".repeat(800)}`, 10)); + const last = assistant(model, { input: 950, text: "retained suffix", timestamp: 20 }); + await session.appendMessage(last); + const result = await new PiCompactionCoordinator({ + session, models, model, thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 1 }, + }).check(last); + assert.equal(result.compacted, true); + const checkpoint = [...(await session.getEntries())].reverse() + .find((entry) => entry.type === "compaction"); + const summary = checkpoint?.type === "compaction" ? checkpoint.summary : ""; + assert.match(summary, /No prior history/iu); + assert.match(summary, /Original Request/iu); +}); + +test("pre-prompt pressure compacts zero-usage reconstructed history", async () => { + const { faux, models, model } = compactionFixture(); + faux.setResponses( + Array.from({ length: 20 }, () => + fauxAssistantMessage(structuredSummary("seeded checkpoint")), + ), + ); + const session = await memorySession(); + await session.appendMessage(user(`older-seeded-${"x".repeat(4_000)}`, 10)); + await session.appendMessage( + assistant(model, { + input: 0, + output: 0, + text: "rehydrated", + timestamp: 20, + }), + ); + await session.appendMessage(user(`newer-seeded-${"y".repeat(4_000)}`, 30)); + await session.appendMessage( + assistant(model, { + input: 0, + output: 0, + text: "also rehydrated", + timestamp: 40, + }), + ); + await session.appendMessage(user("current", 50)); + const coordinator = new PiCompactionCoordinator({ + session, + models, + model, + thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 100 }, + }); + + const result = await coordinator.checkContextPressure(); + + assert.equal(result.compacted, true); + assert.equal(result.messages?.[0]?.role, "compactionSummary"); +}); + +test("oversized summarizer input is reduced through bounded summary fragments", async () => { + const { faux, models, model } = compactionFixture(); + faux.setResponses( + Array.from({ length: 100 }, (_, index) => (context) => + fauxAssistantMessage( + JSON.stringify(context).includes("PREFIX of a turn") + ? splitSummary(`fragment-${index}`) + : structuredSummary(`fragment-${index}`), + ), + ), + ); + const session = await memorySession(); + await session.appendMessage(user(`oversized-${"x".repeat(12_000)}`, 10)); + await session.appendMessage( + assistant(model, { input: 950, text: "older answer", timestamp: 20 }), + ); + await session.appendMessage(user("latest", 30)); + const last = assistant(model, { + input: 950, + text: "latest answer", + timestamp: 40, + }); + await session.appendMessage(last); + const coordinator = new PiCompactionCoordinator({ + session, + models, + model, + thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 100 }, + }); + + const result = await coordinator.check(last); + assert.equal(result.compacted, true); + assert.match(JSON.stringify(result.messages), /fragment-/u); +}); + test("cancelling summary generation leaves the journal uncompacted", async () => { const { faux, models, model } = compactionFixture(); faux.setResponses([ async (_context, options) => { await new Promise((resolve) => { if (options?.signal?.aborted) resolve(); - else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + else + options?.signal?.addEventListener("abort", () => resolve(), { + once: true, + }); }); return fauxAssistantMessage("", { stopReason: "aborted", @@ -264,13 +608,183 @@ test("chat synchronization is idempotent and markers stay out of context", async assert.equal(entries.filter((entry) => entry.type === "message").length, 2); assert.equal( entries.filter( - (entry) => entry.type === "custom" && entry.customType === AIDEN_CHAT_MESSAGE_MARKER, + (entry) => + entry.type === "custom" && + entry.customType === AIDEN_CHAT_MESSAGE_MARKER, ).length, 2, ); assert.equal((await session.buildContext()).messages.length, 2); }); +test("chat synchronization keeps historical images model-neutral", async () => { + const { model } = compactionFixture(); + const session = await memorySession(); + const message: ChatMessage = { + id: "image-user", + role: "user", + content: "Inspect this image", + createdAt: 10, + attachments: [ + { + id: "image-1", + kind: "image", + name: "screen.png", + mimeType: "image/png", + size: 128, + data: "private-image-data", + }, + ], + }; + + await syncChatMessagesToPiSession(session, [message], model, false); + const context = await session.buildContext(); + assert.equal( + JSON.stringify(context.messages).includes("private-image-data"), + true, + ); +}); + +test("chat synchronization rolls back a message when its marker append fails", async () => { + const { model } = compactionFixture(); + const session = await memorySession(); + const message: ChatMessage = { + id: "user-atomic", + role: "user", + content: "Only once", + createdAt: 10, + }; + const appendMarker = session.appendCustomEntry.bind(session); + let failMarker = true; + session.appendCustomEntry = async (...args) => { + if (failMarker) { + failMarker = false; + throw new Error("injected marker failure"); + } + return appendMarker(...args); + }; + + await assert.rejects( + syncChatMessagesToPiSession(session, [message], model, false), + /marker failure/u, + ); + assert.equal((await session.buildContext()).messages.length, 0); + await syncChatMessagesToPiSession(session, [message], model, false); + assert.deepEqual( + (await session.buildContext()).messages.map((entry) => entry.role), + ["user"], + ); +}); + +test("Pi message batches roll back partial appends before a safe retry", async () => { + const { model } = compactionFixture(); + const session = await memorySession(); + const first = user("first", 10); + const second = assistant(model, { text: "second", timestamp: 20 }); + const appendMessage = session.appendMessage.bind(session); + let calls = 0; + session.appendMessage = async (message) => { + calls += 1; + if (calls === 2) throw new Error("injected batch failure"); + return appendMessage(message); + }; + + await assert.rejects( + appendPiMessages(session, [first, second]), + /batch failure/u, + ); + assert.equal((await session.buildContext()).messages.length, 0); + session.appendMessage = appendMessage; + await appendPiMessages(session, [first, second]); + assert.deepEqual( + (await session.buildContext()).messages.map((entry) => entry.role), + ["user", "assistant"], + ); +}); + +test("visible assistant synchronization reconciles a committed unmarked Pi tail", async () => { + const { model } = compactionFixture(); + const session = await memorySession(); + await appendPiMessages(session, [ + assistant(model, { text: "Recovered exactly once", timestamp: 20 }), + ]); + await syncChatMessagesToPiSession( + session, + [ + { + id: "assistant-recovered", + role: "assistant", + content: "Recovered exactly once", + createdAt: 21, + }, + ], + model, + false, + ); + const branch = await session.getBranch(); + assert.equal(branch.filter((entry) => entry.type === "message").length, 1); + assert.equal( + branch.filter( + (entry) => + entry.type === "custom" && + entry.customType === AIDEN_CHAT_MESSAGE_MARKER, + ).length, + 1, + ); +}); + +test("primary generation reconciles a visible assistant after a journal batch failure", async () => { + const source = await readFile( + new URL("./llm-client.ts", import.meta.url), + "utf8", + ); + assert.doesNotMatch(source, /!persisted\.messageId \|\| !piJournalHealthy/u); + assert.match( + source, + /if \(!piJournalHealthy\) \{[\s\S]{0,500}await reconcileVisibleAssistant\(\);/u, + ); + assert.doesNotMatch(source, /chat:\s*persisted\.chat/u); + assert.match(source, /chat:\s*chatForRenderer\(persisted\.chat/u); +}); + +test("compaction transport awaits hidden-summary usage accounting", async () => { + const source = await readFile( + new URL("./pi-compaction-core.ts", import.meta.url), + "utf8", + ); + assert.match(source, /await onAssistantMessage\(message\)/u); + assert.doesNotMatch(source, /void stream\s*\.result\(\)/u); +}); + +test("journal synchronization stores the exact enriched skill turn once", async () => { + const { model } = compactionFixture(); + const session = await memorySession(); + const message: ChatMessage = { + id: "skill-user", + role: "user", + content: "Review this", + createdAt: 10, + }; + const enriched = "Exact private instructions\n\nReview this"; + + await syncChatMessagesToPiSession( + session, + [message], + model, + false, + new Map([[message.id, enriched]]), + ); + await syncChatMessagesToPiSession(session, [message], model, false); + + const context = await session.buildContext(); + assert.equal(context.messages.length, 1); + assert.equal(context.messages[0]?.role, "user"); + assert.equal( + context.messages[0]?.role === "user" ? context.messages[0].content : "", + enriched, + ); +}); + test("durable journals are private and delete with their chat", async (t) => { const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-session-")); t.after(() => rm(temporary, { recursive: true, force: true })); @@ -288,3 +802,143 @@ test("durable journals are private and delete with their chat", async (t) => { await store.deleteChat("chat-privacy-test"); await assert.rejects(stat(metadata.path), { code: "ENOENT" }); }); + +test("reopen rolls back a transaction interrupted by process death", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-crash-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const root = path.join(temporary, "sessions"); + await mkdir(root, { recursive: true }); + const firstStore = new PiCompactionSessionStore({ root: async () => root }); + const first = await firstStore.openChat("chat-crash-test"); + await first.appendMessage(user("committed", 10)); + await first.appendCustomEntry(AIDEN_PI_TRANSACTION, { + transactionId: "interrupted", + phase: "begin", + }); + await first.appendMessage(user("partial duplicate", 20)); + + const reopened = await new PiCompactionSessionStore({ + root: async () => root, + }).openChat("chat-crash-test"); + assert.deepEqual( + (await reopened.buildContext()).messages.map((message) => + message.role === "user" ? message.content : message.role, + ), + ["committed"], + ); +}); + +test("reopen rolls back a Pi batch until visible persistence commits the turn", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-turn-crash-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const root = path.join(temporary, "sessions"); + await mkdir(root, { recursive: true }); + const first = await new PiCompactionSessionStore({ root: async () => root }) + .openChat("chat-turn-crash"); + await first.appendMessage(user("visible user", 10)); + await beginPiGenerationTurn(first); + await appendPiMessages(first, [assistant(compactionFixture().model, { + text: "invisible assistant", timestamp: 20, + })]); + const reopened = await new PiCompactionSessionStore({ root: async () => root }) + .openChat("chat-turn-crash"); + assert.deepEqual((await reopened.buildContext()).messages.map((message) => message.role), ["user"]); +}); + +test("newest corrupt duplicate is quarantined and older valid history reopens", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-fallback-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const root = path.join(temporary, "sessions"); + await mkdir(root, { recursive: true }); + const repo = new JsonlSessionRepo({ + fs: new NodeExecutionEnv({ cwd: root }), + sessionsRoot: root, + }); + const older = await repo.create({ + id: "chat-fallback-test", + cwd: root, + metadata: { + kind: "aiden-chat-compaction-v1", + chatId: "chat-fallback-test", + }, + }); + await older.appendMessage(user("older valid", 10)); + await new Promise((resolve) => setTimeout(resolve, 2)); + const newer = await repo.create({ + id: "chat-fallback-test", + cwd: root, + metadata: { + kind: "aiden-chat-compaction-v1", + chatId: "chat-fallback-test", + }, + }); + await newer.appendMessage(user("newer but corrupt", 20)); + await appendFile((await newer.getMetadata()).path, "{not-json\n"); + + const reopened = await new PiCompactionSessionStore({ + root: async () => root, + }).openChat("chat-fallback-test"); + assert.match(JSON.stringify(await reopened.buildContext()), /older valid/u); +}); + +test("corrupt indexed headers delete with private chat data", async (t) => { + const temporary = await mkdtemp( + path.join(os.tmpdir(), "aiden-pi-corrupt-delete-"), + ); + t.after(() => rm(temporary, { recursive: true, force: true })); + const root = path.join(temporary, "sessions"); + await mkdir(root, { recursive: true }); + const store = new PiCompactionSessionStore({ root: async () => root }); + const session = await store.openChat("chat-corrupt-delete"); + await session.appendMessage(user("PRIVATE BODY", 10)); + const metadata = await session.getMetadata(); + const lines = (await readFile(metadata.path, "utf8")).split("\n"); + lines[0] = `{broken-header,\"chatId\":\"chat-corrupt-delete\"}`; + await writeFile(metadata.path, lines.join("\n")); + + await store.deleteChat("chat-corrupt-delete"); + await assert.rejects(stat(metadata.path), { code: "ENOENT" }); +}); + +test("deleting one chat never matches another journal's body text", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-delete-owner-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const root = path.join(temporary, "sessions"); + await mkdir(root, { recursive: true }); + const store = new PiCompactionSessionStore({ root: async () => root }); + await store.openChat("chat-a"); + const other = await store.openChat("chat-b"); + await other.appendMessage(user('{"chatId":"chat-a","id":"chat-a"}', 10)); + const otherPath = (await other.getMetadata()).path; + await store.deleteChat("chat-a"); + assert.equal((await stat(otherPath)).isFile(), true); +}); + +test("reopen repairs a torn final JSONL line and retains the committed prefix", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-torn-tail-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const root = path.join(temporary, "sessions"); + await mkdir(root, { recursive: true }); + const first = await new PiCompactionSessionStore({ root: async () => root }) + .openChat("chat-torn-tail"); + await first.appendMessage(user("durable prefix", 10)); + const metadata = await first.getMetadata(); + await appendFile(metadata.path, '{"type":"custom","id":"torn'); + const reopened = await new PiCompactionSessionStore({ root: async () => root }) + .openChat("chat-torn-tail"); + assert.match(JSON.stringify(await reopened.buildContext()), /durable prefix/u); + assert.equal((await readFile(metadata.path, "utf8")).endsWith("\n"), true); +}); + +test("startup reconciliation removes indexed orphan journals", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-orphan-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const root = path.join(temporary, "sessions"); + await mkdir(root, { recursive: true }); + const store = new PiCompactionSessionStore({ root: async () => root }); + const orphan = await store.openChat("chat-orphan-test"); + const metadata = await orphan.getMetadata(); + + await store.reconcileChats(new Set()); + await assert.rejects(stat(metadata.path), { code: "ENOENT" }); +}); diff --git a/main/services/pi-compaction-core.ts b/main/services/pi-compaction-core.ts index 92b15e27..f42e4758 100644 --- a/main/services/pi-compaction-core.ts +++ b/main/services/pi-compaction-core.ts @@ -4,6 +4,7 @@ import { compact, estimateContextTokens, estimateTokens, + generateSummary, prepareCompaction, shouldCompact, type AgentMessage, @@ -15,12 +16,19 @@ import { createModels, createProvider, isContextOverflow, + isRetryableAssistantError, type Api, type AssistantMessage, + type AssistantMessageEventStream, type Models, type ProviderStreams, } from "@earendil-works/pi-ai"; import type { ResolvedModelRuntime } from "./model-runtime-core.js"; +import { projectMessagesForModel } from "./generation-context.js"; +import { + AIDEN_CHAT_MESSAGE_MARKER, + AIDEN_PI_TRANSACTION, +} from "./pi-compaction-session-store.js"; export type PiCompactionReason = "threshold" | "overflow"; @@ -54,6 +62,7 @@ export interface PiCompactionCheckResult { /** Pi-reconstructed state to install on the live Agent after compaction. */ messages?: AgentMessage[]; errorMessage?: string; + retryDelayMs?: number; } export interface PiCompactionCoordinatorOptions { @@ -64,6 +73,8 @@ export interface PiCompactionCoordinatorOptions { settings?: CompactionSettings; signal?: AbortSignal; onEvent?: (event: PiCompactionEvent) => void; + /** Bounded host backoff for transient provider/transport retries. */ + retryDelayMs?: number; } function latestCompaction(entries: Awaited>) { @@ -75,7 +86,199 @@ function latestCompaction(entries: Awaited>) { } function estimatedMessageTokens(messages: readonly AgentMessage[]): number { - return messages.reduce((total, message) => total + estimateTokens(message), 0); + return messages.reduce( + (total, message) => total + estimateTokens(message), + 0, + ); +} + +const SPLIT_SUMMARY_MARKER = "\n\n---\n\n**Turn Context (split turn):**\n\n"; +const STRUCTURED_HEADINGS = [ + "Goal", + "Constraints & Preferences", + "Progress", + "Key Decisions", + "Next Steps", + "Critical Context", + ]; +const SPLIT_HEADINGS = [ + "Original Request", + "Early Progress", + "Context for Suffix", + ]; + +function hasHeadings(summary: string, headings: readonly string[]): boolean { + return headings.every((heading) => + new RegExp(`^## ${heading}`, "mu").test(summary), + ); +} + +function validStructuredSummary(summary: string): boolean { + return hasHeadings(summary, STRUCTURED_HEADINGS); +} + +function validFinalCompactionSummary( + summary: string, + preparation: { isSplitTurn: boolean; messagesToSummarize: AgentMessage[] }, +): boolean { + if (!preparation.isSplitTurn) return validStructuredSummary(summary); + const markerIndex = summary.indexOf(SPLIT_SUMMARY_MARKER); + if ( + markerIndex < 0 || + summary.indexOf(SPLIT_SUMMARY_MARKER, markerIndex + 1) >= 0 + ) { + return false; + } + const history = summary.slice(0, markerIndex).trim(); + const prefix = summary.slice(markerIndex + SPLIT_SUMMARY_MARKER.length); + return ( + (preparation.messagesToSummarize.length > 0 + ? validStructuredSummary(history) + : history === "No prior history.") && + hasHeadings(prefix, SPLIT_HEADINGS) + ); +} + +function requireCompleteSummaryModels(models: Models): Models { + return new Proxy(models, { + get(target, property, receiver) { + if (property === "completeSimple") { + return async (...args: Parameters) => { + const message = await target.completeSimple(...args); + if ( + message.stopReason === "length" || + message.stopReason === "toolUse" + ) { + throw new Error( + `The compaction model stopped before completing its summary (${message.stopReason}).`, + ); + } + return message; + }; + } + const value = Reflect.get(target, property, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +function compactionTranscript(messages: readonly AgentMessage[]): string { + return messages + .map((message) => + JSON.stringify(message, (_key, value) => + value && + typeof value === "object" && + (value as { type?: unknown }).type === "image" + ? { + type: "image", + mimeType: (value as { mimeType?: unknown }).mimeType, + note: "binary image retained in the private journal", + } + : value, + ), + ) + .join("\n"); +} + +/** Map-reduce oversized history so the summary request cannot overflow too. */ +async function collapseOversizedCompactionInput(options: { + messages: readonly AgentMessage[]; + models: Models; + model: import("@earendil-works/pi-ai").Model; + reserveTokens: number; + signal: AbortSignal; + thinkingLevel: ThinkingLevel; +}): Promise { + const fixedPiSafetyTokens = Math.min( + 4_096, + Math.floor(options.model.contextWindow * 0.25), + ); + const requestedOutputTokens = Math.max( + 1, + Math.min( + Math.floor(options.reserveTokens * 0.8), + options.model.maxTokens > 0 + ? options.model.maxTokens + : Number.POSITIVE_INFINITY, + ), + ); + const promptOverheadTokens = Math.min( + 1_200, + Math.floor(options.model.contextWindow * 0.15), + ); + const safeInputTokens = Math.max( + 128, + options.model.contextWindow - + fixedPiSafetyTokens - + requestedOutputTokens - + promptOverheadTokens, + ); + if (estimatedMessageTokens(options.messages) <= safeInputTokens) { + return [...options.messages]; + } + const transcript = compactionTranscript(options.messages); + let accumulated: string | undefined; + let offset = 0; + let index = 0; + while (offset < transcript.length) { + const accumulatedTokens = Math.ceil((accumulated?.length ?? 0) / 3); + const availableSourceTokens = Math.max( + 128, + safeInputTokens - accumulatedTokens, + ); + const fragmentCharacters = Math.max(384, availableSourceTokens * 3); + const fragment = transcript.slice(offset, offset + fragmentCharacters); + const estimatedFragmentCount = Math.max( + index + 1, + index + Math.ceil((transcript.length - offset) / fragmentCharacters), + ); + const result = await generateSummary( + [ + { + role: "user", + content: `Compaction source fragment ${index + 1} of approximately ${estimatedFragmentCount}:\n\n${fragment}`, + timestamp: Date.now(), + }, + ], + options.models, + options.model, + options.reserveTokens, + options.signal, + "Preserve exact requests, decisions, identifiers, paths, errors, tool outcomes, and unresolved work across every fragment.", + accumulated, + options.thinkingLevel, + ); + if (!result.ok) throw result.error; + if (!validStructuredSummary(result.value)) { + throw new Error( + "The compaction model returned a malformed intermediate summary.", + ); + } + accumulated = result.value; + offset += fragment.length; + index += 1; + } + return [ + { + role: "user", + content: `Structured map-reduce summary of oversized journal history:\n\n${accumulated ?? ""}`, + timestamp: Date.now(), + }, + ]; +} + +/** Large current-turn batches must be summarized before the next provider call. */ +export function needsImmediatePiCompaction( + messages: readonly AgentMessage[], + contextWindow: number, +): boolean { + // Pi's fast estimator intentionally caps some tool payloads. Raw serialized + // size is the conservative backstop for a just-produced current-turn batch. + const serializedTokenFloor = Math.ceil(JSON.stringify(messages).length / 4); + return ( + Math.max(estimatedMessageTokens(messages), serializedTokenFloor) >= + Math.max(1_024, Math.floor(Math.max(1, contextWindow) * 0.25)) + ); } /** @@ -85,13 +288,39 @@ function estimatedMessageTokens(messages: readonly AgentMessage[]): number { */ export class PiCompactionCoordinator { private readonly settings: CompactionSettings; - private overflowRecoveryAttempted = false; + private retryRecoveryAttempted = false; private activeAbortController?: AbortController; constructor(private readonly options: PiCompactionCoordinatorOptions) { + const contextWindow = Math.max(2, Math.floor(options.model.contextWindow)); + const defaultReserveTokens = Math.min( + DEFAULT_COMPACTION_SETTINGS.reserveTokens, + Math.max(1_024, Math.floor(contextWindow * 0.2)), + ); + const reserveTokens = Math.min( + contextWindow - 1, + Math.max( + 1, + Math.floor(options.settings?.reserveTokens ?? defaultReserveTokens), + ), + ); + const defaultKeepRecentTokens = Math.min( + DEFAULT_COMPACTION_SETTINGS.keepRecentTokens, + Math.max(1_024, Math.floor((contextWindow - reserveTokens) * 0.5)), + ); this.settings = { ...DEFAULT_COMPACTION_SETTINGS, ...options.settings, + reserveTokens, + keepRecentTokens: Math.min( + Math.max(1, contextWindow - reserveTokens - 1), + Math.max( + 1, + Math.floor( + options.settings?.keepRecentTokens ?? defaultKeepRecentTokens, + ), + ), + ), }; } @@ -101,12 +330,69 @@ export class PiCompactionCoordinator { /** Pi resets overflow recovery when a new user prompt enters the agent. */ beginPrompt(): void { - this.overflowRecoveryAttempted = false; + this.retryRecoveryAttempted = false; + } + + /** Check the reconstructed journal before provider I/O, including the new user turn. */ + async checkContextPressure( + options: { + forceThreshold?: boolean; + /** Add a private user boundary when the active oversized tool turn has no native cut point. */ + sealCurrentTurnIfNeeded?: boolean; + } = {}, + ): Promise { + if (!this.settings.enabled) return { compacted: false, shouldRetry: false }; + const branch = await this.options.session.getBranch(); + const compactionEntry = latestCompaction(branch); + const context = await this.options.session.buildContext(); + const estimate = estimateContextTokens(context.messages); + const heuristicTokens = estimatedMessageTokens(context.messages); + const usageMessage = + estimate.lastUsageIndex === null + ? undefined + : context.messages[estimate.lastUsageIndex]; + const usageIsStale = + compactionEntry && + usageMessage?.role === "assistant" && + usageMessage.timestamp <= new Date(compactionEntry.timestamp).getTime(); + const contextTokens = usageIsStale + ? heuristicTokens + : Math.max(estimate.tokens, heuristicTokens); + if ( + !options.forceThreshold && + !shouldCompact( + contextTokens, + this.options.model.contextWindow, + this.settings, + ) + ) { + return { compacted: false, shouldRetry: false }; + } + const result = await this.run("threshold", false); + if (result.compacted || !options.sealCurrentTurnIfNeeded) return result; + + // Pi cannot cut an oversized tool batch when it is the entire open turn. + // A private continuation user entry gives native compaction a valid next + // boundary; it remains in the Pi journal only and never appears in chat. + const priorLeaf = await this.options.session.getLeafId(); + await this.options.session.appendMessage({ + role: "user", + content: [ + { + type: "text", + text: "Continue the same request using the compacted current-turn checkpoint. Do not repeat completed tool calls.", + }, + ], + timestamp: Date.now(), + }); + const sealed = await this.run("threshold", false); + if (!sealed.compacted) await this.options.session.moveTo(priorLeaf); + return sealed; } async check( assistantMessage: AssistantMessage, - options: { includeAborted?: boolean } = {}, + options: { includeAborted?: boolean; forceThreshold?: boolean } = {}, ): Promise { if (!this.settings.enabled) return { compacted: false, shouldRetry: false }; if (!options.includeAborted && assistantMessage.stopReason === "aborted") { @@ -117,7 +403,8 @@ export class PiCompactionCoordinator { const compactionEntry = latestCompaction(branch); if ( compactionEntry && - assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime() + assistantMessage.timestamp <= + new Date(compactionEntry.timestamp).getTime() ) { return { compacted: false, shouldRetry: false }; } @@ -130,7 +417,15 @@ export class PiCompactionCoordinator { if (sameModel && isContextOverflow(assistantMessage, contextWindow)) { const willRetry = assistantMessage.stopReason !== "stop"; if (!willRetry) return this.run("overflow", false); - if (this.overflowRecoveryAttempted) { + const abandoned = await this.abandonRetryableAssistant(assistantMessage); + if (!abandoned.ok) { + return { + compacted: false, + shouldRetry: false, + errorMessage: abandoned.errorMessage, + }; + } + if (this.retryRecoveryAttempted) { const errorMessage = "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model."; this.options.onEvent?.({ @@ -142,10 +437,41 @@ export class PiCompactionCoordinator { }); return { compacted: false, shouldRetry: false, errorMessage }; } - this.overflowRecoveryAttempted = true; + this.retryRecoveryAttempted = true; return this.run("overflow", true); } + if (isRetryableAssistantError(assistantMessage)) { + const abandoned = await this.abandonRetryableAssistant(assistantMessage); + if (!abandoned.ok) { + return { + compacted: false, + shouldRetry: false, + errorMessage: abandoned.errorMessage, + }; + } + const context = await this.options.session.buildContext(); + if (this.retryRecoveryAttempted) { + return { + compacted: false, + shouldRetry: false, + messages: context.messages, + errorMessage: + "The provider failed again after one automatic retry. Try again in a moment or switch models.", + }; + } + this.retryRecoveryAttempted = true; + return { + compacted: false, + shouldRetry: true, + messages: context.messages, + retryDelayMs: Math.max( + 0, + Math.min(5_000, this.options.retryDelayMs ?? 500), + ), + }; + } + const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0; @@ -154,24 +480,105 @@ export class PiCompactionCoordinator { const context = await this.options.session.buildContext(); const estimate = estimateContextTokens(context.messages); if (estimate.lastUsageIndex === null) { - return { compacted: false, shouldRetry: false }; - } - const usageMessage = context.messages[estimate.lastUsageIndex]; - if ( - compactionEntry && - usageMessage.role === "assistant" && - usageMessage.timestamp <= new Date(compactionEntry.timestamp).getTime() - ) { - return { compacted: false, shouldRetry: false }; + contextTokens = estimatedMessageTokens(context.messages); + } else { + const usageMessage = context.messages[estimate.lastUsageIndex]; + contextTokens = + compactionEntry && + usageMessage.role === "assistant" && + usageMessage.timestamp <= + new Date(compactionEntry.timestamp).getTime() + ? estimatedMessageTokens(context.messages) + : estimate.tokens; } - contextTokens = estimate.tokens; } - return shouldCompact(contextTokens, contextWindow, this.settings) + return options.forceThreshold || + shouldCompact(contextTokens, contextWindow, this.settings) ? this.run("threshold", false) : { compacted: false, shouldRetry: false }; } + private async abandonRetryableAssistant( + assistantMessage: AssistantMessage, + ): Promise<{ ok: true } | { ok: false; errorMessage: string }> { + try { + const branch = await this.options.session.getBranch(); + let entryIndex = branch.length - 1; + while (entryIndex >= 0) { + const candidate = branch[entryIndex]; + if ( + candidate?.type !== "custom" || + (candidate.customType !== AIDEN_PI_TRANSACTION && + candidate.customType !== AIDEN_CHAT_MESSAGE_MARKER) + ) { + break; + } + entryIndex -= 1; + } + const entry = branch[entryIndex]; + if ( + entry?.type !== "message" || + entry.message.role !== "assistant" || + entry.message.timestamp !== assistantMessage.timestamp || + entry.message.provider !== assistantMessage.provider || + entry.message.model !== assistantMessage.model || + entry.message.stopReason !== assistantMessage.stopReason + ) { + return { + ok: false, + errorMessage: + "Automatic retry could not isolate the failed model attempt safely.", + }; + } + let rollbackTarget = entry.parentId; + const transactionBegin = branch.find( + (candidate) => candidate.id === entry.parentId, + ); + if ( + transactionBegin?.type === "custom" && + transactionBegin.customType === AIDEN_PI_TRANSACTION && + transactionBegin.data && + typeof transactionBegin.data === "object" + ) { + const begin = transactionBegin.data as { + transactionId?: unknown; + phase?: unknown; + }; + const hasMatchingCommit = branch + .slice(entryIndex + 1) + .some((candidate) => { + if ( + candidate.type !== "custom" || + candidate.customType !== AIDEN_PI_TRANSACTION || + !candidate.data || + typeof candidate.data !== "object" + ) { + return false; + } + const marker = candidate.data as { + transactionId?: unknown; + phase?: unknown; + }; + return ( + begin.phase === "begin" && + typeof begin.transactionId === "string" && + marker.phase === "commit" && + marker.transactionId === begin.transactionId + ); + }); + if (hasMatchingCommit) rollbackTarget = transactionBegin.parentId; + } + await this.options.session.moveTo(rollbackTarget); + return { ok: true }; + } catch (error) { + return { + ok: false, + errorMessage: `Automatic retry could not roll back the failed model attempt: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + private async run( reason: PiCompactionReason, willRetry: boolean, @@ -182,8 +589,31 @@ export class PiCompactionCoordinator { const branch = await this.options.session.getBranch(); const preparationResult = prepareCompaction(branch, this.settings); if (!preparationResult.ok) throw preparationResult.error; - const preparation = preparationResult.value; + let preparation = preparationResult.value; if (!preparation) return { compacted: false, shouldRetry: false }; + if ( + preparation.messagesToSummarize.length === 0 && + preparation.turnPrefixMessages.length === 0 + ) { + // One oversized older turn can exceed the retained-tail budget by + // itself. Pi's normal cut then keeps everything. Retry its cut-point + // selection with the smallest tail so a newer turn can become the + // boundary and the oversized history is summarized instead of pruned + // on every outbound request. + const aggressiveResult = prepareCompaction(branch, { + ...this.settings, + keepRecentTokens: 1, + }); + if (!aggressiveResult.ok) throw aggressiveResult.error; + preparation = aggressiveResult.value; + if ( + !preparation || + (preparation.messagesToSummarize.length === 0 && + preparation.turnPrefixMessages.length === 0) + ) { + return { compacted: false, shouldRetry: false }; + } + } const abortController = new AbortController(); this.activeAbortController = abortController; @@ -191,13 +621,48 @@ export class PiCompactionCoordinator { else if (this.options.signal) { const abort = () => abortController.abort(); this.options.signal.addEventListener("abort", abort, { once: true }); - removeParentAbort = () => this.options.signal?.removeEventListener("abort", abort); + removeParentAbort = () => + this.options.signal?.removeEventListener("abort", abort); } started = true; this.options.onEvent?.({ type: "start", reason }); + // Binary images stay model-neutral in the durable journal, but summary + // generation needs only a continuity marker and must never resend large + // historical image payloads to the compaction model. + const imageProjected = { + ...preparation, + messagesToSummarize: projectMessagesForModel( + preparation.messagesToSummarize, + false, + ), + turnPrefixMessages: projectMessagesForModel( + preparation.turnPrefixMessages, + false, + ), + }; + const summaryModels = requireCompleteSummaryModels(this.options.models); + const boundedPreparation = { + ...imageProjected, + messagesToSummarize: await collapseOversizedCompactionInput({ + messages: imageProjected.messagesToSummarize, + models: summaryModels, + model: this.options.model, + reserveTokens: imageProjected.settings.reserveTokens, + signal: abortController.signal, + thinkingLevel: this.options.thinkingLevel, + }), + turnPrefixMessages: await collapseOversizedCompactionInput({ + messages: imageProjected.turnPrefixMessages, + models: summaryModels, + model: this.options.model, + reserveTokens: imageProjected.settings.reserveTokens, + signal: abortController.signal, + thinkingLevel: this.options.thinkingLevel, + }), + }; const compactResult = await compact( - preparation, - this.options.models, + boundedPreparation, + summaryModels, this.options.model, undefined, abortController.signal, @@ -215,6 +680,14 @@ export class PiCompactionCoordinator { } const result = compactResult.value; + if (!result.summary.trim()) { + throw new Error("The compaction model returned an empty summary."); + } + if (!validFinalCompactionSummary(result.summary, boundedPreparation)) { + throw new Error( + "The compaction model returned a malformed summary without the required continuity sections.", + ); + } await this.options.session.appendCompaction( result.summary, result.firstKeptEntryId, @@ -227,7 +700,9 @@ export class PiCompactionCoordinator { firstKeptEntryId: result.firstKeptEntryId, tokensBefore: result.tokensBefore, estimatedTokensAfter: estimatedMessageTokens(context.messages), - ...(result.details ? { details: result.details as PiCompactionDetails } : {}), + ...(result.details + ? { details: result.details as PiCompactionDetails } + : {}), }; this.options.onEvent?.({ type: "end", @@ -242,7 +717,8 @@ export class PiCompactionCoordinator { messages: context.messages, }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : "compaction failed"; + const errorMessage = + error instanceof Error ? error.message : "compaction failed"; if (started) { const aborted = this.activeAbortController?.signal.aborted === true; this.options.onEvent?.({ @@ -269,14 +745,36 @@ export class PiCompactionCoordinator { } /** Use Aiden's resolved, connection-bound transport for Pi's summary call. */ -export function createPiCompactionModels(runtime: ResolvedModelRuntime): Models { +export function createPiCompactionModels( + runtime: ResolvedModelRuntime, + onAssistantMessage?: (message: AssistantMessage) => void | Promise, +): Models { const models = createModels(); - const streamSimple: ProviderStreams["streamSimple"] = (model, context, options) => - runtime.streams.streamSimple(model, context, { + const streamSimple: ProviderStreams["streamSimple"] = ( + model, + context, + options, + ) => { + const stream = runtime.streams.streamSimple(model, context, { ...options, apiKey: options?.apiKey ?? runtime.apiKey, - headers: runtime.headers ? { ...options?.headers, ...runtime.headers } : options?.headers, + headers: runtime.headers + ? { ...options?.headers, ...runtime.headers } + : options?.headers, + }); + if (!onAssistantMessage) return stream; + const accountedResult = stream.result().then(async (message) => { + await onAssistantMessage(message); + return message; }); + return new Proxy(stream, { + get(target, property, receiver) { + if (property === "result") return () => accountedResult; + const value = Reflect.get(target, property, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }) as AssistantMessageEventStream; + }; const streams: ProviderStreams = { stream: streamSimple as ProviderStreams["stream"], streamSimple, diff --git a/main/services/pi-compaction-session-store.ts b/main/services/pi-compaction-session-store.ts index d3ee99bf..583a71e4 100644 --- a/main/services/pi-compaction-session-store.ts +++ b/main/services/pi-compaction-session-store.ts @@ -1,4 +1,13 @@ -import { chmod } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { + chmod, + open, + readFile, + readdir, + rename, + unlink, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import { JsonlSessionRepo, @@ -7,25 +16,156 @@ import { type Session, } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import type { Api, Model } from "@earendil-works/pi-ai"; +import { + cleanupSessionResources, + type Api, + type Model, +} from "@earendil-works/pi-ai"; import { ensureUserDataDir } from "./data-store.js"; import { chatMessageToPiMessage } from "./generation-messages.js"; import type { ChatMessage } from "./types.js"; export const AIDEN_CHAT_MESSAGE_MARKER = "aiden.chat-message.v1"; +export const AIDEN_PI_TRANSACTION = "aiden.pi-transaction.v1"; const SESSION_METADATA_KIND = "aiden-chat-compaction-v1"; const SAFE_SESSION_ID = /^[a-zA-Z0-9._-]{1,200}$/u; +const JOURNAL_INDEX_FILE = "aiden-journal-index.json"; +const JOURNAL_HEADER_SCAN_BYTES = 65_536; + +interface JournalIndex { + version: 1; + chats: Record; +} interface ChatMessageMarker { chatMessageId: string; } +interface PiTransactionMarker { + transactionId: string; + phase: "begin" | "commit"; +} + +function transactionMarker(data: unknown): PiTransactionMarker | undefined { + if (!data || typeof data !== "object") return undefined; + const candidate = data as Partial; + return typeof candidate.transactionId === "string" && + candidate.transactionId.length > 0 && + (candidate.phase === "begin" || candidate.phase === "commit") + ? { transactionId: candidate.transactionId, phase: candidate.phase } + : undefined; +} + +function assistantProjection(message: AgentMessage): + | { + text: string; + reasoning: string; + } + | undefined { + if (message.role !== "assistant") return undefined; + return { + text: message.content + .filter( + (part): part is { type: "text"; text: string } => part.type === "text", + ) + .map((part) => part.text) + .join(""), + reasoning: message.content + .filter( + ( + part, + ): part is { type: "thinking"; thinking: string; redacted?: boolean } => + part.type === "thinking" && part.redacted !== true, + ) + .map((part) => part.thinking) + .join("\n\n"), + }; +} + +function visibleAssistantAlreadyAtTail( + tail: AgentMessage | undefined, + visible: ChatMessage, + desired: AgentMessage, +): boolean { + if (visible.role !== "assistant" || !tail) return false; + const actual = assistantProjection(tail); + const expected = assistantProjection(desired); + return ( + actual !== undefined && + expected !== undefined && + actual.text === expected.text && + actual.reasoning === expected.reasoning + ); +} + function markerId(data: unknown): string | undefined { if (!data || typeof data !== "object") return undefined; const id = (data as Partial).chatMessageId; return typeof id === "string" && id.length > 0 ? id : undefined; } +async function readJournalPrefix(filePath: string): Promise { + const handle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(JOURNAL_HEADER_SCAN_BYTES); + const { bytesRead } = await handle.read( + buffer, + 0, + JOURNAL_HEADER_SCAN_BYTES, + 0, + ); + const prefix = buffer.subarray(0, bytesRead).toString("utf8"); + const newline = prefix.indexOf("\n"); + return newline >= 0 ? prefix.slice(0, newline) : prefix; + } finally { + await handle.close(); + } +} + +function journalHeaderOwnsChat(headerLine: string, chatId: string): boolean { + try { + const header = JSON.parse(headerLine) as { + type?: unknown; + version?: unknown; + id?: unknown; + metadata?: { kind?: unknown; chatId?: unknown }; + }; + return ( + header.type === "session" && + header.version === 3 && + header.id === chatId && + header.metadata?.kind === SESSION_METADATA_KIND && + header.metadata.chatId === chatId + ); + } catch { + return false; + } +} + +/** Preserve a valid durable prefix when only the final JSONL write was torn. */ +async function repairTornFinalLine(filePath: string): Promise { + const contents = await readFile(filePath, "utf8"); + if (!contents || contents.endsWith("\n")) return false; + const finalNewline = contents.lastIndexOf("\n"); + if (finalNewline < 0) return false; + const completePrefix = contents.slice(0, finalNewline + 1); + try { + for (const line of completePrefix.split("\n")) { + if (line.trim()) JSON.parse(line); + } + } catch { + return false; + } + const handle = await open(filePath, "r+"); + try { + await handle.truncate(Buffer.byteLength(completePrefix, "utf8")); + await handle.sync(); + } finally { + await handle.close(); + } + return true; +} + /** * Append visible messages that are not yet represented in Pi's journal. * Custom marker entries are ignored by Session.buildContext(), while making @@ -35,12 +175,16 @@ export async function syncChatMessagesToPiSession( session: Session, messages: readonly ChatMessage[], model: Model, - supportsImages: boolean, + _supportsImages: boolean, + contentOverrides: ReadonlyMap = new Map(), ): Promise { - const entries = await session.getEntries(); + const entries = await session.getBranch(); const synchronized = new Set( entries.flatMap((entry) => { - if (entry.type !== "custom" || entry.customType !== AIDEN_CHAT_MESSAGE_MARKER) { + if ( + entry.type !== "custom" || + entry.customType !== AIDEN_CHAT_MESSAGE_MARKER + ) { return []; } const id = markerId(entry.data); @@ -50,24 +194,118 @@ export async function syncChatMessagesToPiSession( for (const message of messages) { if (synchronized.has(message.id)) continue; - await session.appendMessage(chatMessageToPiMessage(message, model, supportsImages)); - await session.appendCustomEntry(AIDEN_CHAT_MESSAGE_MARKER, { - chatMessageId: message.id, - } satisfies ChatMessageMarker); + const desired = chatMessageToPiMessage( + message, + model, + // Journals are model-neutral. Request-time projection removes images for + // text-only models, so a later switch to a vision model remains faithful. + true, + contentOverrides.get(message.id), + ); + const context = await session.buildContext(); + const tail = context.messages[context.messages.length - 1]; + await appendPiTransaction(session, async () => { + // A process may have died after the Pi assistant was committed and the + // visible chat was saved, but before its marker transaction. Reconcile + // that exact terminal projection instead of duplicating the answer. + if (!visibleAssistantAlreadyAtTail(tail, message, desired)) { + await session.appendMessage(desired); + } + await session.appendCustomEntry(AIDEN_CHAT_MESSAGE_MARKER, { + chatMessageId: message.id, + } satisfies ChatMessageMarker); + }); + synchronized.add(message.id); + } +} + +async function appendPiTransaction( + session: Session, + operation: () => Promise, +): Promise { + const originalLeafId = await session.getLeafId(); + const transactionId = randomUUID(); + try { + await session.appendCustomEntry(AIDEN_PI_TRANSACTION, { + transactionId, + phase: "begin", + } satisfies PiTransactionMarker); + const result = await operation(); + await session.appendCustomEntry(AIDEN_PI_TRANSACTION, { + transactionId, + phase: "commit", + } satisfies PiTransactionMarker); + return result; + } catch (error) { + try { + await session.moveTo(originalLeafId); + } catch (rollbackError) { + throw new Error( + `The Pi journal write failed and its partial branch could not be rolled back: ${error instanceof Error ? error.message : String(error)}; rollback: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + throw error; } } +/** Hold a generation suffix open until its visible assistant is durable. */ +export async function beginPiGenerationTurn(session: Session): Promise { + const transactionId = randomUUID(); + await session.appendCustomEntry(AIDEN_PI_TRANSACTION, { + transactionId, + phase: "begin", + } satisfies PiTransactionMarker); + return transactionId; +} + +export async function commitPiGenerationTurn( + session: Session, + transactionId: string, +): Promise { + await session.appendCustomEntry(AIDEN_PI_TRANSACTION, { + transactionId, + phase: "commit", + } satisfies PiTransactionMarker); +} + +async function recoverUncommittedTransaction(session: Session): Promise { + const branch = await session.getBranch(); + const open = new Map(); + for (const entry of branch) { + if (entry.type !== "custom" || entry.customType !== AIDEN_PI_TRANSACTION) + continue; + const marker = transactionMarker(entry.data); + if (!marker) continue; + if (marker.phase === "begin") + open.set(marker.transactionId, entry.parentId); + else open.delete(marker.transactionId); + } + if (open.size === 0) return; + // Writes are serialized per Session. The earliest open envelope owns every + // later suffix record, including any partially appended message batch. + const earliest = branch.find( + (entry) => + entry.type === "custom" && + entry.customType === AIDEN_PI_TRANSACTION && + transactionMarker(entry.data)?.phase === "begin" && + open.has(transactionMarker(entry.data)?.transactionId ?? ""), + ); + if (earliest) await session.moveTo(earliest.parentId); +} + export async function appendPiMessages( session: Session, messages: readonly AgentMessage[], visibleChatMessageId?: string, ): Promise { - for (const message of messages) await session.appendMessage(message); - if (visibleChatMessageId) { - await session.appendCustomEntry(AIDEN_CHAT_MESSAGE_MARKER, { - chatMessageId: visibleChatMessageId, - } satisfies ChatMessageMarker); - } + await appendPiTransaction(session, async () => { + for (const message of messages) await session.appendMessage(message); + if (visibleChatMessageId) { + await session.appendCustomEntry(AIDEN_CHAT_MESSAGE_MARKER, { + chatMessageId: visibleChatMessageId, + } satisfies ChatMessageMarker); + } + }); } export interface PiCompactionSessionStoreOptions { @@ -81,10 +319,73 @@ export class PiCompactionSessionStore { root: string; }>; private readonly sessions = new Map>(); - private readonly opening = new Map>>(); + private readonly opening = new Map< + string, + Promise> + >(); + private indexMutation: Promise = Promise.resolve(); constructor(private readonly options: PiCompactionSessionStoreOptions) {} + private async readIndex(root: string): Promise { + try { + const parsed = JSON.parse( + await readFile(path.join(root, JOURNAL_INDEX_FILE), "utf8"), + ) as Partial; + return parsed.version === 1 && + parsed.chats && + typeof parsed.chats === "object" + ? { version: 1, chats: parsed.chats as Record } + : { version: 1, chats: {} }; + } catch { + return { version: 1, chats: {} }; + } + } + + private async mutateIndex( + root: string, + mutation: (index: JournalIndex) => void, + ): Promise { + const operation = this.indexMutation.then(async () => { + const index = await this.readIndex(root); + mutation(index); + const target = path.join(root, JOURNAL_INDEX_FILE); + const temporary = `${target}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify(index)}\n`, { mode: 0o600 }); + await rename(temporary, target); + await chmod(target, 0o600); + }); + this.indexMutation = operation.catch(() => undefined); + return operation; + } + + private async rememberPath( + root: string, + chatId: string, + filePath: string, + ): Promise { + const resolvedRoot = `${path.resolve(root)}${path.sep}`; + const resolvedPath = path.resolve(filePath); + if (!resolvedPath.startsWith(resolvedRoot)) { + throw new Error("Pi journal metadata escaped its private storage root."); + } + await this.mutateIndex(root, (index) => { + const paths = new Set(index.chats[chatId] ?? []); + paths.add(resolvedPath); + index.chats[chatId] = [...paths]; + }); + } + + private async quarantine( + root: string, + chatId: string, + filePath: string, + ): Promise { + const quarantined = `${filePath}.corrupt-${Date.now()}-${randomUUID()}`; + await rename(filePath, quarantined); + await this.rememberPath(root, chatId, quarantined); + } + private async repository(): Promise<{ repo: JsonlSessionRepo; root: string; @@ -115,19 +416,43 @@ export class PiCompactionSessionStore { const opening = (async () => { const { repo, root } = await this.repository(); const matches = (await repo.list()).filter( - (metadata) => metadata.id === chatId && metadata.metadata?.kind === SESSION_METADATA_KIND, + (metadata) => + metadata.id === chatId && + metadata.metadata?.kind === SESSION_METADATA_KIND, ); - const metadata = matches[matches.length - 1]; - const session = metadata - ? await repo.open(metadata) - : await repo.create({ - id: chatId, - cwd: root, - metadata: { kind: SESSION_METADATA_KIND, chatId }, - }); + // Pi lists newest sessions first. Validate the whole body and quarantine + // a malformed duplicate before falling back to the next valid journal. + let session: Session | undefined; + for (const metadata of matches) { + try { + const candidate = await repo.open(metadata); + await candidate.getBranch(); + session = candidate; + break; + } catch { + if (await repairTornFinalLine(metadata.path).catch(() => false)) { + try { + const repaired = await repo.open(metadata); + await repaired.getBranch(); + session = repaired; + break; + } catch { + // A complete-but-invalid prefix is not safe to guess at. + } + } + await this.quarantine(root, chatId, metadata.path); + } + } + session ??= await repo.create({ + id: chatId, + cwd: root, + metadata: { kind: SESSION_METADATA_KIND, chatId }, + }); + await recoverUncommittedTransaction(session); const persisted = await session.getMetadata(); await chmod(path.dirname(persisted.path), 0o700); await chmod(persisted.path, 0o600); + await this.rememberPath(root, chatId, persisted.path); this.sessions.set(chatId, session); return session; })(); @@ -144,13 +469,63 @@ export class PiCompactionSessionStore { throw new Error("Invalid chat identity for the Pi compaction journal."); } await this.opening.get(chatId); - const { repo } = await this.repository(); + const { repo, root } = await this.repository(); const matches = (await repo.list()).filter( - (metadata) => metadata.id === chatId && metadata.metadata?.kind === SESSION_METADATA_KIND, + (metadata) => + metadata.id === chatId && + metadata.metadata?.kind === SESSION_METADATA_KIND, ); for (const metadata of matches) await repo.delete(metadata); + const index = await this.readIndex(root); + for (const indexedPath of index.chats[chatId] ?? []) { + const resolved = path.resolve(indexedPath); + if (!resolved.startsWith(`${path.resolve(root)}${path.sep}`)) continue; + await unlink(resolved).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + // Legacy journals are deleted only when their first JSONL header line has + // an exact identity. Body text may mention a different chat in tool args. + const directories = [root]; + while (directories.length > 0) { + const directory = directories.pop()!; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(candidate); + continue; + } + if (!entry.name.includes(".jsonl")) continue; + const prefix = await readJournalPrefix(candidate).catch(() => ""); + if (journalHeaderOwnsChat(prefix, chatId)) { + await unlink(candidate); + } + } + } + await this.mutateIndex(root, (next) => { + delete next.chats[chatId]; + }); + cleanupSessionResources(chatId); this.sessions.delete(chatId); } + + /** Remove indexed journals whose visible chat no longer exists. */ + async reconcileChats(validChatIds: ReadonlySet): Promise { + const { repo, root } = await this.repository(); + const indexed = await this.readIndex(root); + const discovered = await repo.list(); + const candidates = new Set([ + ...Object.keys(indexed.chats), + ...discovered + .filter((metadata) => metadata.metadata?.kind === SESSION_METADATA_KIND) + .map((metadata) => metadata.id), + ]); + for (const chatId of candidates) { + if (SAFE_SESSION_ID.test(chatId) && !validChatIds.has(chatId)) { + await this.deleteChat(chatId); + } + } + } } export const piCompactionSessionStore = new PiCompactionSessionStore({ diff --git a/main/services/pi-message-storage.ts b/main/services/pi-message-storage.ts new file mode 100644 index 00000000..29161e29 --- /dev/null +++ b/main/services/pi-message-storage.ts @@ -0,0 +1,124 @@ +import type { AssistantMessage } from "@earendil-works/pi-ai"; + +export type StoredPiAssistantMessage = Omit; + +const STOP_REASONS = new Set(["stop", "length", "toolUse", "error", "aborted"]); + +function nonemptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function finiteNonnegative(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function validJsonValue( + value: unknown, + state: { nodes: number }, + depth = 0, +): boolean { + state.nodes += 1; + if (state.nodes > 100_000 || depth > 64) return false; + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) { + return value.every((item) => validJsonValue(item, state, depth + 1)); + } + if (!value || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + return Object.values(value).every((item) => + validJsonValue(item, state, depth + 1), + ); +} + +function validContentBlock(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const block = value as Record; + if (block.type === "text") { + return typeof block.text === "string" && optionalString(block.textSignature); + } + if (block.type === "thinking") { + return typeof block.thinking === "string" && + optionalString(block.thinkingSignature) && + (block.redacted === undefined || typeof block.redacted === "boolean"); + } + if (block.type === "toolCall") { + return nonemptyString(block.id) && + nonemptyString(block.name) && + block.arguments !== null && + typeof block.arguments === "object" && + !Array.isArray(block.arguments) && + validJsonValue(block.arguments, { nodes: 0 }) && + optionalString(block.thoughtSignature); + } + return false; +} + +function validUsage(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const usage = value as Record; + if ( + !["input", "output", "cacheRead", "cacheWrite", "totalTokens"].every( + (key) => finiteNonnegative(usage[key]), + ) || + (usage.cacheWrite1h !== undefined && !finiteNonnegative(usage.cacheWrite1h)) || + (usage.reasoning !== undefined && !finiteNonnegative(usage.reasoning)) || + !usage.cost || + typeof usage.cost !== "object" || + Array.isArray(usage.cost) + ) return false; + const cost = usage.cost as Record; + return ["input", "output", "cacheRead", "cacheWrite", "total"].every( + (key) => finiteNonnegative(cost[key]), + ); +} + +/** Persist the provider-authored Pi message without raw diagnostics. */ +export function storedPiAssistantMessage( + message: AssistantMessage, +): StoredPiAssistantMessage { + const { diagnostics: _diagnostics, ...stored } = message; + return structuredClone(stored); +} + +/** Fail closed when a device-local chat payload contains malformed Pi provenance. */ +export function parseStoredPiAssistantMessage( + value: unknown, +): StoredPiAssistantMessage | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) + return undefined; + const candidate = value as Partial; + if ( + candidate.role !== "assistant" || + !Array.isArray(candidate.content) || + !candidate.content.every(validContentBlock) || + !nonemptyString(candidate.api) || + !nonemptyString(candidate.provider) || + !nonemptyString(candidate.model) || + !validUsage(candidate.usage) || + !STOP_REASONS.has(String(candidate.stopReason)) || + !Number.isSafeInteger(candidate.timestamp) || + (candidate.timestamp as number) < 0 || + !optionalString(candidate.responseModel) || + !optionalString(candidate.responseId) || + !optionalString(candidate.errorMessage) + ) { + return undefined; + } + const { diagnostics: _diagnostics, ...stored } = + candidate as AssistantMessage; + try { + return structuredClone(stored); + } catch { + return undefined; + } +} diff --git a/main/services/schedule-service-core.test.ts b/main/services/schedule-service-core.test.ts index 05b61b98..94dee6c5 100644 --- a/main/services/schedule-service-core.test.ts +++ b/main/services/schedule-service-core.test.ts @@ -378,3 +378,40 @@ test("cancellation while a saved task is being scheduled rolls back persistence assert.deepEqual(testbed.broadcasts, []); testbed.service.stop(); }); + +test("aborted pause preserves an already-paused task", async () => { + const testbed = harness(); + const task = await addTask(testbed.store); + await testbed.store.setEnabled(task.id, false); + const original = testbed.store.setEnabled.bind(testbed.store); + const controller = new AbortController(); + let calls = 0; + Object.defineProperty(testbed.store, "setEnabled", { + configurable: true, + value: async (...args: Parameters) => { + const result = await original(...args); + if (++calls === 1) controller.abort(); + return result; + }, + }); + await assert.rejects(testbed.service.pause(task.id, { signal: controller.signal }), /cancelled/iu); + assert.equal((await testbed.store.get(task.id))?.enabled, false); +}); + +test("aborted resume preserves an already-enabled task", async () => { + const testbed = harness(); + const task = await addTask(testbed.store); + const original = testbed.store.setEnabled.bind(testbed.store); + const controller = new AbortController(); + let calls = 0; + Object.defineProperty(testbed.store, "setEnabled", { + configurable: true, + value: async (...args: Parameters) => { + const result = await original(...args); + if (++calls === 1) controller.abort(); + return result; + }, + }); + await assert.rejects(testbed.service.resume(task.id, { signal: controller.signal }), /cancelled/iu); + assert.equal((await testbed.store.get(task.id))?.enabled, true); +}); diff --git a/main/services/schedule-service-core.ts b/main/services/schedule-service-core.ts index 1eb929f2..b2c80498 100644 --- a/main/services/schedule-service-core.ts +++ b/main/services/schedule-service-core.ts @@ -1,6 +1,10 @@ import { Cron } from "croner"; import { nextScheduledRun, type ScheduleStore } from "./schedule-store.js"; -import type { ScheduledRun, ScheduledTask, ScheduledTaskInput } from "./types.js"; +import type { + ScheduledRun, + ScheduledTask, + ScheduledTaskInput, +} from "./types.js"; interface ScheduleExecutionLike { run(task: ScheduledTask): Promise; @@ -24,7 +28,9 @@ export interface ScheduleServiceDependencies { error(message: string, cause: unknown): void; } -export function createScheduleServiceCore(dependencies: ScheduleServiceDependencies) { +export function createScheduleServiceCore( + dependencies: ScheduleServiceDependencies, +) { const { store, execution } = dependencies; const jobs = new Map(); const runningTasks = new Map(); @@ -33,7 +39,15 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc let started = false; let globallyEnabled = true; - async function withTaskLifecycle(taskId: string, operation: () => Promise): Promise { + const throwIfAborted = (signal: AbortSignal | undefined, action: string) => { + if (signal?.aborted) + throw new Error(`Scheduled task ${action} was cancelled.`); + }; + + async function withTaskLifecycle( + taskId: string, + operation: () => Promise, + ): Promise { const previous = lifecycleTails.get(taskId) ?? Promise.resolve(); let release: () => void = () => {}; const current = new Promise((resolve) => { @@ -63,9 +77,15 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc }); } - async function recordUnexpectedFailure(task: ScheduledTask, cause: unknown): Promise { + async function recordUnexpectedFailure( + task: ScheduledTask, + cause: unknown, + ): Promise { const message = cause instanceof Error ? cause.message : String(cause); - dependencies.error(`Scheduled task ${task.id} failed outside its execution boundary.`, cause); + dependencies.error( + `Scheduled task ${task.id} failed outside its execution boundary.`, + cause, + ); try { await store.recordRun({ taskId: task.id, @@ -78,11 +98,17 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc }); dependencies.broadcast({ taskId: task.id }); } catch (recordError) { - dependencies.error(`Could not record failure for scheduled task ${task.id}.`, recordError); + dependencies.error( + `Could not record failure for scheduled task ${task.id}.`, + recordError, + ); } } - function dispatch(taskId: string, options: { automatic: boolean }): Promise { + function dispatch( + taskId: string, + options: { automatic: boolean }, + ): Promise { if (runningTasks.has(taskId)) { throw new Error("This scheduled task is already running."); } @@ -103,13 +129,19 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc workspaceResolved = true; resolveWorkspaceReady(); if (task.workspaceId && blockedWorkspaces.has(task.workspaceId)) { - throw new Error("This scheduled task's workspace is changing or unavailable."); + throw new Error( + "This scheduled task's workspace is changing or unavailable.", + ); } - if (options.automatic && (!started || !globallyEnabled || !task.enabled)) { + if ( + options.automatic && + (!started || !globallyEnabled || !task.enabled) + ) { throw new Error("This scheduled task is paused."); } const claimed = options.automatic ? await advanceBeforeRun(task) : task; - if (state.cancelRequested) throw new Error("This scheduled task was cancelled."); + if (state.cancelRequested) + throw new Error("This scheduled task was cancelled."); return execution.run(claimed); } finally { if (!workspaceResolved) { @@ -138,7 +170,9 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc async function cancelWorkspaceAndSettle(workspaceId: string): Promise { const snapshot = [...runningTasks.entries()]; await Promise.all(snapshot.map(([, state]) => state.workspaceReady)); - const selected = snapshot.filter(([, state]) => state.workspaceId === workspaceId); + const selected = snapshot.filter( + ([, state]) => state.workspaceId === workspaceId, + ); for (const [taskId, state] of selected) { state.cancelRequested = true; execution.cancel(taskId); @@ -152,7 +186,8 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc !started || !globallyEnabled || !task.enabled || - (task.workspaceId !== undefined && blockedWorkspaces.has(task.workspaceId)) + (task.workspaceId !== undefined && + blockedWorkspaces.has(task.workspaceId)) ) { return; } @@ -164,7 +199,9 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc timezone: task.timezone, mode: "5-or-6-parts", protect: () => { - dependencies.warn(`Skipped overlapping cron callback for task ${task.id}.`); + dependencies.warn( + `Skipped overlapping cron callback for task ${task.id}.`, + ); }, catch: (error) => { void store.get(task.id).then((latest) => { @@ -219,8 +256,12 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc return current; } catch (error) { stopJob(task.id); - const message = error instanceof Error ? error.message : String(error); - dependencies.error(`Could not schedule task ${task.id}; it was disabled.`, error); + const message = + error instanceof Error ? error.message : String(error); + dependencies.error( + `Could not schedule task ${task.id}; it was disabled.`, + error, + ); await store.updateRuntime(task.id, { enabled: false, nextRunAt: undefined, @@ -231,7 +272,8 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc } }); if (!latest) continue; - const missed = latest.nextRunAt !== undefined && latest.nextRunAt < now; + const missed = + latest.nextRunAt !== undefined && latest.nextRunAt < now; if (missed) { void dispatch(latest.id, { automatic: true }).catch((error) => recordUnexpectedFailure(latest, error), @@ -278,10 +320,13 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc options: { expectedUpdatedAt?: number; signal?: AbortSignal } = {}, ): Promise { if (options.expectedUpdatedAt !== undefined && !input.id) { - throw new Error("An expected task revision requires an existing task ID."); + throw new Error( + "An expected task revision requires an existing task ID.", + ); } const perform = async () => { - if (options.signal?.aborted) throw new Error("Scheduled task save was cancelled."); + if (options.signal?.aborted) + throw new Error("Scheduled task save was cancelled."); const rescheduleCurrent = async () => { if (!input.id) return; const current = await store.get(input.id); @@ -327,36 +372,96 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc return input.id ? withTaskLifecycle(input.id, perform) : perform(); }, - async remove(id: string): Promise { + async remove( + id: string, + options: { signal?: AbortSignal } = {}, + ): Promise { await withTaskLifecycle(id, async () => { + const current = await store.get(id); + throwIfAborted(options.signal, "removal"); stopJob(id); await cancelAndSettle(id); + if (options.signal?.aborted) { + if (current?.enabled) await schedule(current); + throwIfAborted(options.signal, "removal"); + } await store.remove(id); dependencies.broadcast({ taskId: id, removed: true }); }); }, - async pause(id: string): Promise { + async pause( + id: string, + options: { signal?: AbortSignal } = {}, + ): Promise { return withTaskLifecycle(id, async () => { + const current = await store.get(id); + throwIfAborted(options.signal, "pause"); stopJob(id); await cancelAndSettle(id); + if (options.signal?.aborted) { + if (current?.enabled) await schedule(current); + throwIfAborted(options.signal, "pause"); + } const task = await store.setEnabled(id, false); + if (options.signal?.aborted) { + if (current) { + const restored = await store.setEnabled(id, current.enabled); + if (current.enabled) await schedule(restored); + else stopJob(id); + } + throwIfAborted(options.signal, "pause"); + } dependencies.broadcast({ taskId: id }); return task; }); }, - async resume(id: string): Promise { + async resume( + id: string, + options: { signal?: AbortSignal } = {}, + ): Promise { return withTaskLifecycle(id, async () => { + const current = await store.get(id); + throwIfAborted(options.signal, "resume"); const task = await store.setEnabled(id, true); + const restore = async () => { + if (!current) return; + const restored = await store.setEnabled(id, current.enabled); + if (current.enabled) await schedule(restored); + else stopJob(id); + }; + if (options.signal?.aborted) { + await restore(); + throwIfAborted(options.signal, "resume"); + } await schedule(task); + if (options.signal?.aborted) { + stopJob(id); + await restore(); + throwIfAborted(options.signal, "resume"); + } dependencies.broadcast({ taskId: id }); return (await store.get(id)) ?? task; }); }, - runNow(id: string): Promise { - return dispatch(id, { automatic: false }); + runNow( + id: string, + options: { signal?: AbortSignal } = {}, + ): Promise { + throwIfAborted(options.signal, "run"); + const operation = dispatch(id, { automatic: false }); + if (!options.signal) return operation; + const cancel = () => { + const state = runningTasks.get(id); + if (state) state.cancelRequested = true; + execution.cancel(id); + }; + options.signal.addEventListener("abort", cancel, { once: true }); + return operation.finally(() => + options.signal?.removeEventListener("abort", cancel), + ); }, async setGlobalEnabled(enabled: boolean): Promise { @@ -392,7 +497,8 @@ export function createScheduleServiceCore(dependencies: ScheduleServiceDependenc if (task.workspaceId !== workspaceId || !task.enabled) continue; await withTaskLifecycle(task.id, async () => { const latest = await store.get(task.id); - if (latest?.enabled && latest.workspaceId === workspaceId) await schedule(latest); + if (latest?.enabled && latest.workspaceId === workspaceId) + await schedule(latest); }); } }, diff --git a/main/services/schedule-tool.test.ts b/main/services/schedule-tool.test.ts index bcabaeae..43ede138 100644 --- a/main/services/schedule-tool.test.ts +++ b/main/services/schedule-tool.test.ts @@ -67,7 +67,9 @@ function scheduledTask(input: ScheduledTaskInput, id: string): ScheduledTask { workspaceId: input.workspaceId, ...(input.providerId ? { providerId: input.providerId } : {}), ...(input.model ? { model: input.model } : {}), - ...(input.providerFingerprint ? { providerFingerprint: input.providerFingerprint } : {}), + ...(input.providerFingerprint + ? { providerFingerprint: input.providerFingerprint } + : {}), prompt: input.prompt, script: input.script, permission: input.permission ?? "read-only", @@ -84,7 +86,9 @@ function scheduledTask(input: ScheduledTaskInput, id: string): ScheduledTask { function fakeDependencies() { const tasks: ScheduledTask[] = []; - const calls = { validatedScripts: [] as Array<{ script: string; workspaceRoot?: string }> }; + const calls = { + validatedScripts: [] as Array<{ script: string; workspaceRoot?: string }>, + }; const workspace: Workspace = { id: "workspace-1", name: "Project", @@ -97,13 +101,17 @@ function fakeDependencies() { list: async () => structuredClone(tasks), get: async (id) => structuredClone(tasks.find((task) => task.id === id)), save: async (input, expectedUpdatedAt, signal) => { - if (signal?.aborted) throw new Error("Scheduled task save was cancelled."); + if (signal?.aborted) + throw new Error("Scheduled task save was cancelled."); const existingIndex = input.id ? tasks.findIndex((candidate) => candidate.id === input.id) : -1; const existing = existingIndex >= 0 ? tasks[existingIndex] : undefined; if (input.id && !existing) throw new Error("not found"); - if (expectedUpdatedAt !== undefined && existing?.updatedAt !== expectedUpdatedAt) { + if ( + expectedUpdatedAt !== undefined && + existing?.updatedAt !== expectedUpdatedAt + ) { throw new Error("stale revision"); } const task = { @@ -154,7 +162,8 @@ function fakeDependencies() { function jsonResult(value: AgentToolResult): Record { const block = value.content[0]; assert.equal(block?.type, "text"); - if (!block || block.type !== "text") throw new Error("Expected a text tool result."); + if (!block || block.type !== "text") + throw new Error("Expected a text tool result."); return JSON.parse(block.text) as Record; } @@ -190,23 +199,61 @@ test("schedule_task supports the full create/list/pause/resume/run/remove lifecy ); assert.equal( ( - jsonResult(await tool.execute("resume", { action: "resume", id: "task-1" })) - .task as ScheduledTask + jsonResult( + await tool.execute("resume", { action: "resume", id: "task-1" }), + ).task as ScheduledTask ).enabled, true, ); assert.equal( - (jsonResult(await tool.execute("run", { action: "run_now", id: "task-1" })).run as ScheduledRun) - .result, + ( + jsonResult(await tool.execute("run", { action: "run_now", id: "task-1" })) + .run as ScheduledRun + ).result, "success", ); assert.equal( - jsonResult(await tool.execute("remove", { action: "remove", id: "task-1" })).removed, + jsonResult(await tool.execute("remove", { action: "remove", id: "task-1" })) + .removed, "task-1", ); assert.equal(fake.tasks.length, 0); }); +test("schedule_task forwards cancellation to every standard mutation", async () => { + const fake = fakeDependencies(); + const observed: Array<[string, AbortSignal | undefined]> = []; + for (const action of ["pause", "resume", "remove", "runNow"] as const) { + const original = fake.dependencies[action]; + Object.assign(fake.dependencies, { + [action]: async (id: string, signal?: AbortSignal) => { + observed.push([action, signal]); + if (signal?.aborted) throw new Error("cancelled before mutation"); + return original(id, signal); + }, + }); + } + await fake.dependencies.save({ + name: "Cancelable", + cron: "0 9 * * *", + mode: "llm", + prompt: "Summarize.", + permission: "read-only", + }); + const tool = createScheduleTaskTool({ kind: "standard" }, fake.dependencies); + for (const action of ["pause", "resume", "run_now", "remove"] as const) { + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + tool.execute(action, { action, id: "task-1" }, controller.signal), + /cancel/iu, + ); + } + // Pre-aborted calls are rejected before a dependency can commit anything. + assert.deepEqual(observed, []); + assert.equal(fake.tasks.length, 1); +}); + test("schedule_task validates scripts in the bound workspace and rejects unsafe prompts", async () => { const fake = fakeDependencies(); const tool = createScheduleTaskTool( @@ -272,11 +319,17 @@ test("standard scheduled tasks reject combined project and MCP capability scope" test("scheduled generation contexts omit schedule_task to prevent recursion", () => { assert.deepEqual( - scheduleTaskToolsForContext({ workspaceId: "workspace-1", allowScheduling: false }), + scheduleTaskToolsForContext({ + workspaceId: "workspace-1", + allowScheduling: false, + }), [], ); assert.equal( - scheduleTaskToolsForContext({ workspaceId: "workspace-1", allowScheduling: true })[0]?.name, + scheduleTaskToolsForContext({ + workspaceId: "workspace-1", + allowScheduling: true, + })[0]?.name, SCHEDULE_TOOL_NAME, ); }); @@ -290,7 +343,11 @@ test("attended Assistant scheduling exposes separate list, create, and edit tool allowScheduling: true, assistantModelSelection: ASSISTANT_MODEL_SELECTION, }).map((candidate) => candidate.name), - [LIST_SCHEDULED_TASKS_TOOL_NAME, SCHEDULE_TOOL_NAME, EDIT_AUTOMATION_TOOL_NAME], + [ + LIST_SCHEDULED_TASKS_TOOL_NAME, + SCHEDULE_TOOL_NAME, + EDIT_AUTOMATION_TOOL_NAME, + ], ); const schema = tool.parameters as { properties?: Record; @@ -309,7 +366,12 @@ test("attended Assistant scheduling exposes separate list, create, and edit tool "timezone", "workspaceId", ]); - assert.deepEqual(schema.required?.slice().sort(), ["action", "cron", "name", "prompt"]); + assert.deepEqual(schema.required?.slice().sort(), [ + "action", + "cron", + "name", + "prompt", + ]); assert.equal(schema.additionalProperties, false); assert.doesNotMatch(JSON.stringify(schema), /run_now|pause|resume|remove/u); @@ -375,14 +437,20 @@ test("edit_automation updates one exact task without creating a duplicate", asyn }), ); - const editTool = createAssistantEditAutomationTool(ASSISTANT_MODEL_SELECTION, fake.dependencies); + const editTool = createAssistantEditAutomationTool( + ASSISTANT_MODEL_SELECTION, + fake.dependencies, + ); assert.equal(editTool.name, EDIT_AUTOMATION_TOOL_NAME); const schema = editTool.parameters as { properties?: Record; required?: string[]; additionalProperties?: boolean; }; - assert.deepEqual(schema.required?.slice().sort(), ["expectedUpdatedAt", "id"]); + assert.deepEqual(schema.required?.slice().sort(), [ + "expectedUpdatedAt", + "id", + ]); assert.equal(schema.additionalProperties, false); assert.equal(schema.properties?.action, undefined); assert.equal(schema.properties?.mode, undefined); @@ -440,7 +508,10 @@ test("edit_automation rejects stale, ambiguous, and non-Assistant edits", async createdAt: 1, updatedAt: 4, }); - const tool = createAssistantEditAutomationTool(ASSISTANT_MODEL_SELECTION, fake.dependencies); + const tool = createAssistantEditAutomationTool( + ASSISTANT_MODEL_SELECTION, + fake.dependencies, + ); await assert.rejects( tool.execute("stale", { id: "task-1", @@ -518,7 +589,10 @@ test("attended Assistant allows confirmed project access but rejects unbound Ful }, { action: "remove", id: "task-1" }, ]) { - await assert.rejects(tool.execute("blocked", params), /cannot|only|requires/iu); + await assert.rejects( + tool.execute("blocked", params), + /cannot|only|requires/iu, + ); } await assert.rejects( tool.execute("missing-project", { @@ -654,7 +728,10 @@ test("attended Assistant bounds every string copied into the confirmation", asyn prompt: "p".repeat(32 * 1024 + 1), }, ]) { - await assert.rejects(tool.execute("too-long", params), /characters or fewer/iu); + await assert.rejects( + tool.execute("too-long", params), + /characters or fewer/iu, + ); } assert.equal(fake.tasks.length, 0); }); @@ -700,7 +777,10 @@ test("Assistant approval resolution binds a trusted project name to the exact pr assert.equal(proposal.details.workspaceId, "workspace-1"); assert.equal(proposal.details.permission, "full"); assert.deepEqual( - await resolveAssistantScheduleProject(proposal, fake.dependencies.getWorkspace), + await resolveAssistantScheduleProject( + proposal, + fake.dependencies.getWorkspace, + ), { workspaceId: "workspace-1", workspaceName: "Project", @@ -723,7 +803,10 @@ test("Assistant approval resolution binds exact enabled MCP names", async () => ); assert.equal(proposal.details.permission, "full"); assert.deepEqual( - await resolveAssistantScheduleMcpServers(proposal, fake.dependencies.listMcpServers), + await resolveAssistantScheduleMcpServers( + proposal, + fake.dependencies.listMcpServers, + ), { mcpServerIds: ["gmail"], mcpServerNames: ["Gmail"], diff --git a/main/services/schedule-tool.ts b/main/services/schedule-tool.ts index eb4a7e0c..53580c8e 100644 --- a/main/services/schedule-tool.ts +++ b/main/services/schedule-tool.ts @@ -6,7 +6,11 @@ import { recommendedScheduledPermission, validateScheduledMcpServerIds, } from "./schedule-guard.js"; -import { nextScheduledRun, systemTimezone, validateTimezone } from "./schedule-store.js"; +import { + nextScheduledRun, + systemTimezone, + validateTimezone, +} from "./schedule-store.js"; import type { McpServer, ScheduledRun, @@ -44,7 +48,8 @@ export const SCHEDULE_TOOL_NAME = ASSISTANT_AUTOMATION_TOOL_NAME; export const EDIT_AUTOMATION_TOOL_NAME = ASSISTANT_AUTOMATION_EDIT_TOOL_NAME; export const LIST_SCHEDULED_TASKS_TOOL_NAME = "list_scheduled_tasks"; -type ScheduleToolAction = "create" | "list" | "pause" | "resume" | "remove" | "run_now"; +type ScheduleToolAction = + "create" | "list" | "pause" | "resume" | "remove" | "run_now"; interface ScheduleToolParams { action: ScheduleToolAction; @@ -85,14 +90,20 @@ export interface AssistantScheduleModelSelection { export type ScheduleToolAccess = | { kind: "standard"; defaultWorkspaceId?: string } - | { kind: "assistant-attended"; modelSelection: AssistantScheduleModelSelection }; + | { + kind: "assistant-attended"; + modelSelection: AssistantScheduleModelSelection; + }; export interface AssistantScheduleProposal { input: ScheduledTaskInput; expectedUpdatedAt?: number; details: Omit< AssistantAutomationApprovalDetails, - "schedulerEnabled" | "workspaceName" | "mcpServerNames" | keyof AssistantScheduleModelSelection + | "schedulerEnabled" + | "workspaceName" + | "mcpServerNames" + | keyof AssistantScheduleModelSelection >; } @@ -121,14 +132,18 @@ function approvedMcpBindings( const bindings = args && typeof args === "object" && !Array.isArray(args) ? validateScheduledMcpServerBindings( - (args as { [APPROVED_MCP_BINDINGS]?: unknown })[APPROVED_MCP_BINDINGS], + (args as { [APPROVED_MCP_BINDINGS]?: unknown })[ + APPROVED_MCP_BINDINGS + ], ) : undefined; if ( bindings?.length !== serverIds.length || serverIds.some((id, index) => bindings[index]?.id !== id) ) { - throw new Error("The exact MCP approval expired before this automation could be saved."); + throw new Error( + "The exact MCP approval expired before this automation could be saved.", + ); } return bindings; } @@ -212,30 +227,48 @@ export interface ScheduleToolDependencies { expectedUpdatedAt?: number, signal?: AbortSignal, ): Promise; - pause(id: string): Promise; - resume(id: string): Promise; - remove(id: string): Promise; - runNow(id: string): Promise; + pause(id: string, signal?: AbortSignal): Promise; + resume(id: string, signal?: AbortSignal): Promise; + remove(id: string, signal?: AbortSignal): Promise; + runNow(id: string, signal?: AbortSignal): Promise; getWorkspace(id: string): Promise; listMcpServers(): Promise; - validateScript(input: { script: string; workspaceRoot?: string }): Promise; + validateScript(input: { + script: string; + workspaceRoot?: string; + }): Promise; isSchedulingEnabled(): Promise; } const defaultDependencies: ScheduleToolDependencies = { list: async () => (await import("./schedule-store.js")).scheduleStore.list(), - get: async (id) => (await import("./schedule-store.js")).scheduleStore.get(id), + get: async (id) => + (await import("./schedule-store.js")).scheduleStore.get(id), save: async (input, expectedUpdatedAt, signal) => (await import("./schedule-service.js")).scheduleService.save(input, { expectedUpdatedAt, signal, }), - pause: async (id) => (await import("./schedule-service.js")).scheduleService.pause(id), - resume: async (id) => (await import("./schedule-service.js")).scheduleService.resume(id), - remove: async (id) => (await import("./schedule-service.js")).scheduleService.remove(id), - runNow: async (id) => (await import("./schedule-service.js")).scheduleService.runNow(id), - getWorkspace: async (id) => (await import("./config-store.js")).configStore.getWorkspace(id), - listMcpServers: async () => (await import("./config-store.js")).configStore.listMcpServers(), + pause: async (id, signal) => + (await import("./schedule-service.js")).scheduleService.pause(id, { + signal, + }), + resume: async (id, signal) => + (await import("./schedule-service.js")).scheduleService.resume(id, { + signal, + }), + remove: async (id, signal) => + (await import("./schedule-service.js")).scheduleService.remove(id, { + signal, + }), + runNow: async (id, signal) => + (await import("./schedule-service.js")).scheduleService.runNow(id, { + signal, + }), + getWorkspace: async (id) => + (await import("./config-store.js")).configStore.getWorkspace(id), + listMcpServers: async () => + (await import("./config-store.js")).configStore.listMcpServers(), validateScript: async (input) => (await import("./schedule-script.js")).resolveScheduledScript(input), isSchedulingEnabled: async () => @@ -251,6 +284,10 @@ function result(value: unknown): AgentToolResult { }; } +function throwIfScheduleToolAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new Error("Scheduled task action was cancelled."); +} + function required(value: string | undefined, label: string): string { const normalized = value?.trim(); if (!normalized) throw new Error(`${label} is required for this action.`); @@ -258,7 +295,8 @@ function required(value: string | undefined, label: string): string { } function bounded(value: string, label: string, limit: number): string { - if (value.length > limit) throw new Error(`${label} must be ${limit} characters or fewer.`); + if (value.length > limit) + throw new Error(`${label} must be ${limit} characters or fewer.`); return value; } @@ -288,11 +326,16 @@ const ASSISTANT_EDIT_KEYS = new Set([ "notify", ]); -function assertSafeDisplayText(value: string, label: string, multiline = false): void { +function assertSafeDisplayText( + value: string, + label: string, + multiline = false, +): void { for (const character of value) { const codePoint = character.codePointAt(0) ?? 0; const allowedWhitespace = - multiline && (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d); + multiline && + (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d); const unsafeControl = (!allowedWhitespace && codePoint <= 0x1f) || (codePoint >= 0x7f && codePoint <= 0x9f) || @@ -320,9 +363,13 @@ export function prepareAssistantScheduleProposal( if (action !== "create") { throw new Error("Aiden can only prepare new scheduled tasks here."); } - const unexpected = Object.keys(record).filter((key) => !ASSISTANT_CREATE_KEYS.has(key)); + const unexpected = Object.keys(record).filter( + (key) => !ASSISTANT_CREATE_KEYS.has(key), + ); if (unexpected.length > 0) { - throw new Error(`Aiden cannot set scheduled task field "${unexpected[0]}".`); + throw new Error( + `Aiden cannot set scheduled task field "${unexpected[0]}".`, + ); } if (record.notify !== undefined && typeof record.notify !== "boolean") { throw new Error("notify must be true or false."); @@ -341,7 +388,10 @@ export function prepareAssistantScheduleProposal( ASSISTANT_AUTOMATION_NAME_LIMIT, ); const prompt = bounded( - required(typeof record.prompt === "string" ? record.prompt : undefined, "prompt"), + required( + typeof record.prompt === "string" ? record.prompt : undefined, + "prompt", + ), "prompt", ASSISTANT_AUTOMATION_PROMPT_LIMIT, ); @@ -351,7 +401,9 @@ export function prepareAssistantScheduleProposal( ASSISTANT_AUTOMATION_CRON_LIMIT, ); const requestedTimezone = bounded( - typeof record.timezone === "string" ? record.timezone.trim() : systemTimezone(), + typeof record.timezone === "string" + ? record.timezone.trim() + : systemTimezone(), "timezone", ASSISTANT_AUTOMATION_TIMEZONE_LIMIT, ); @@ -366,7 +418,9 @@ export function prepareAssistantScheduleProposal( ? undefined : bounded( required( - typeof record.workspaceId === "string" ? record.workspaceId : undefined, + typeof record.workspaceId === "string" + ? record.workspaceId + : undefined, "workspaceId", ), "workspaceId", @@ -381,7 +435,8 @@ export function prepareAssistantScheduleProposal( const permission = mcpServerIds.length > 0 || record.permission === "full" || - (record.permission === undefined && recommendedScheduledPermission(prompt) === "full") + (record.permission === undefined && + recommendedScheduledPermission(prompt) === "full") ? "full" : "read-only"; if (permission === "full" && !workspaceId && mcpServerIds.length === 0) { @@ -463,7 +518,8 @@ export async function repairAssistantScheduleMcpTarget( ): Promise { const proposal = prepareAssistantScheduleProposal(value, from); const workspaceId = proposal.input.workspaceId; - if (!workspaceId || (proposal.input.mcpServerIds?.length ?? 0) > 0) return proposal; + if (!workspaceId || (proposal.input.mcpServerIds?.length ?? 0) > 0) + return proposal; if (await dependencies.getWorkspace(workspaceId)) return proposal; const exactEnabledServer = (await dependencies.listMcpServers()).some( (server) => server.id === workspaceId && server.enabled, @@ -484,9 +540,13 @@ export async function repairAssistantScheduleMcpTarget( ); } -function sameStringList(left: readonly string[] | undefined, right: readonly string[]): boolean { +function sameStringList( + left: readonly string[] | undefined, + right: readonly string[], +): boolean { return ( - (left?.length ?? 0) === right.length && right.every((value, index) => left?.[index] === value) + (left?.length ?? 0) === right.length && + right.every((value, index) => left?.[index] === value) ); } @@ -504,7 +564,9 @@ export async function prepareAssistantEditAutomationProposal( throw new Error("Automation edit arguments must be an object."); } const record = value as Record; - const unexpected = Object.keys(record).filter((key) => !ASSISTANT_EDIT_KEYS.has(key)); + const unexpected = Object.keys(record).filter( + (key) => !ASSISTANT_EDIT_KEYS.has(key), + ); if (unexpected.length > 0) { throw new Error(`Aiden cannot edit automation field "${unexpected[0]}".`); } @@ -522,16 +584,22 @@ export async function prepareAssistantEditAutomationProposal( ? record.expectedUpdatedAt : undefined; if (expectedUpdatedAt === undefined) { - throw new Error("expectedUpdatedAt is required and must come from list_scheduled_tasks."); + throw new Error( + "expectedUpdatedAt is required and must come from list_scheduled_tasks.", + ); } const patchKeys = [...ASSISTANT_EDIT_KEYS].filter( - (key) => key !== "id" && key !== "expectedUpdatedAt" && record[key] !== undefined, + (key) => + key !== "id" && key !== "expectedUpdatedAt" && record[key] !== undefined, ); if (patchKeys.length === 0) { throw new Error("Include at least one automation field to change."); } - if (record.clearWorkspace !== undefined && typeof record.clearWorkspace !== "boolean") { + if ( + record.clearWorkspace !== undefined && + typeof record.clearWorkspace !== "boolean" + ) { throw new Error("clearWorkspace must be true or false."); } if (record.workspaceId !== undefined && record.clearWorkspace === true) { @@ -559,14 +627,19 @@ export async function prepareAssistantEditAutomationProposal( existing.mode !== "llm" || existing.executionProfile !== ASSISTANT_SCHEDULE_EXECUTION_PROFILE ) { - throw new Error("Aiden can edit only automations previously created with Aiden Assistant."); + throw new Error( + "Aiden can edit only automations previously created with Aiden Assistant.", + ); } - const valueOrExisting = (key: "name" | "cron" | "timezone" | "prompt"): string => { + const valueOrExisting = ( + key: "name" | "cron" | "timezone" | "prompt", + ): string => { const candidate = record[key]; if (candidate === undefined) { const current = existing[key]; - if (typeof current !== "string") throw new Error(`Existing automation has no ${key}.`); + if (typeof current !== "string") + throw new Error(`Existing automation has no ${key}.`); return current; } return required(typeof candidate === "string" ? candidate : undefined, key); @@ -577,7 +650,9 @@ export async function prepareAssistantEditAutomationProposal( : record.workspaceId === undefined ? existing.workspaceId : required( - typeof record.workspaceId === "string" ? record.workspaceId : undefined, + typeof record.workspaceId === "string" + ? record.workspaceId + : undefined, "workspaceId", ); const mcpServerIds = @@ -607,7 +682,8 @@ export async function prepareAssistantEditAutomationProposal( merged.input.permission !== existing.permission || !sameStringList(existing.mcpServerIds, merged.input.mcpServerIds ?? []) || merged.input.notify !== existing.notify; - if (!changed) throw new Error("The requested values already match this automation."); + if (!changed) + throw new Error("The requested values already match this automation."); return { input: { @@ -665,7 +741,9 @@ function assistantTaskSummary(task: ScheduledTask) { mcpServerIds: task.mcpServerIds ?? [], notify: task.notify, updatedAt: task.updatedAt, - editable: task.mode === "llm" && task.executionProfile === ASSISTANT_SCHEDULE_EXECUTION_PROFILE, + editable: + task.mode === "llm" && + task.executionProfile === ASSISTANT_SCHEDULE_EXECUTION_PROFILE, nextRunAt: task.nextRunAt, lastRunAt: task.lastRunAt, lastResult: task.lastResult, @@ -688,7 +766,9 @@ async function workspaceFor( export async function resolveAssistantScheduleProject( proposal: AssistantScheduleProposal, getWorkspace: ScheduleToolDependencies["getWorkspace"] = defaultDependencies.getWorkspace, -): Promise> { +): Promise< + Pick +> { const workspaceId = proposal.input.workspaceId; if (!workspaceId) return { workspaceId: null, workspaceName: null }; const workspace = await getWorkspace(workspaceId); @@ -701,7 +781,9 @@ export async function resolveAssistantScheduleProject( throw new Error(`Project ${workspaceId} has No Access.`); } if (!workspace.folderPath) { - throw new Error("The selected project does not have a folder for this automation."); + throw new Error( + "The selected project does not have a folder for this automation.", + ); } const workspaceName = bounded( required(workspace.name, "Project name"), @@ -717,7 +799,10 @@ export async function resolveAssistantScheduleMcpServers( listMcpServers: ScheduleToolDependencies["listMcpServers"] = defaultDependencies.listMcpServers, expectedBindings?: readonly ScheduledMcpServerBinding[], ): Promise< - Pick & { + Pick< + AssistantAutomationApprovalDetails, + "mcpServerIds" | "mcpServerNames" + > & { mcpServerBindings: ScheduledMcpServerBinding[]; } > { @@ -730,7 +815,8 @@ export async function resolveAssistantScheduleMcpServers( const servers = mcpServerIds.map((id) => { const server = byId.get(id); if (!server) throw new Error(`MCP server ${id} was not found.`); - if (!server.enabled) throw new Error(`MCP server "${server.name}" is disabled.`); + if (!server.enabled) + throw new Error(`MCP server "${server.name}" is disabled.`); const name = bounded( required(server.name, "MCP server name"), "MCP server name", @@ -739,7 +825,9 @@ export async function resolveAssistantScheduleMcpServers( assertSafeDisplayText(name, "MCP server name"); return { server, name }; }); - const mcpServerBindings = servers.map(({ server }) => scheduledMcpServerBinding(server)); + const mcpServerBindings = servers.map(({ server }) => + scheduledMcpServerBinding(server), + ); if (expectedBindings) { assertScheduledMcpServerBindings( servers.map(({ server }) => server), @@ -799,15 +887,21 @@ export function createAssistantEditAutomationTool( description: "Exact updatedAt revision from the same list_scheduled_tasks result. Prevents stale overwrites.", }), - name: Type.Optional(Type.String({ description: "Replacement task name." })), + name: Type.Optional( + Type.String({ description: "Replacement task name." }), + ), cron: Type.Optional( Type.String({ description: 'Replacement five- or six-part cron expression. For every day at 9 AM use "0 9 * * *".', }), ), - timezone: Type.Optional(Type.String({ description: "Replacement IANA timezone." })), - prompt: Type.Optional(Type.String({ description: "Replacement automation instruction." })), + timezone: Type.Optional( + Type.String({ description: "Replacement IANA timezone." }), + ), + prompt: Type.Optional( + Type.String({ description: "Replacement automation instruction." }), + ), workspaceId: Type.Optional( Type.String({ description: @@ -816,12 +910,14 @@ export function createAssistantEditAutomationTool( ), clearWorkspace: Type.Optional( Type.Boolean({ - description: "Set true to remove the current project. Do not combine with workspaceId.", + description: + "Set true to remove the current project. Do not combine with workspaceId.", }), ), permission: Type.Optional( Type.Union([Type.Literal("read-only"), Type.Literal("full")], { - description: "Replacement access level. Omit to preserve current access.", + description: + "Replacement access level. Omit to preserve current access.", }), ), mcpServerIds: Type.Optional( @@ -838,7 +934,9 @@ export function createAssistantEditAutomationTool( ), ), notify: Type.Optional( - Type.Boolean({ description: "Replacement macOS notification preference." }), + Type.Boolean({ + description: "Replacement macOS notification preference.", + }), ), }, { @@ -847,14 +945,23 @@ export function createAssistantEditAutomationTool( "Edit one exact automation revision. id and expectedUpdatedAt are required, plus at least one field to change.", }, ), - execute: async (_toolCallId, rawParams, signal): Promise> => { + execute: async ( + _toolCallId, + rawParams, + signal, + ): Promise> => { const proposal = await prepareAssistantEditAutomationProposal(rawParams, { get: (id) => dependencies.get(id), }); if (signal?.aborted) throw new Error("Automation edit was cancelled."); - const mcpServerBindings = approvedMcpBindings(rawParams, proposal.input.mcpServerIds ?? []); + const mcpServerBindings = approvedMcpBindings( + rawParams, + proposal.input.mcpServerIds ?? [], + ); await Promise.all([ - resolveAssistantScheduleProject(proposal, (id) => dependencies.getWorkspace(id)), + resolveAssistantScheduleProject(proposal, (id) => + dependencies.getWorkspace(id), + ), resolveAssistantScheduleMcpServers( proposal, () => dependencies.listMcpServers(), @@ -893,7 +1000,9 @@ export function createScheduleTaskTool( dependencies: ScheduleToolDependencies = defaultDependencies, ): AgentTool { if (access.kind === "assistant-attended") { - const approvedModel = validateAssistantScheduleModelSelection(access.modelSelection); + const approvedModel = validateAssistantScheduleModelSelection( + access.modelSelection, + ); return { name: SCHEDULE_TOOL_NAME, label: "Scheduled Tasks", @@ -907,9 +1016,13 @@ export function createScheduleTaskTool( description: 'Required five- or six-part cron expression. For every day at 9 AM use "0 9 * * *". The field name is cron, not schedule.', }), - prompt: Type.String({ description: "Required automation instruction." }), + prompt: Type.String({ + description: "Required automation instruction.", + }), timezone: Type.Optional( - Type.String({ description: "IANA timezone. Defaults to the device timezone." }), + Type.String({ + description: "IANA timezone. Defaults to the device timezone.", + }), ), workspaceId: Type.Optional( Type.String({ @@ -927,7 +1040,8 @@ export function createScheduleTaskTool( Type.Array( Type.String({ maxLength: ASSISTANT_AUTOMATION_MCP_SERVER_ID_LIMIT, - description: "Exact enabled MCP server ID from list_mcp_servers.", + description: + "Exact enabled MCP server ID from list_mcp_servers.", }), { maxItems: 16, @@ -937,7 +1051,9 @@ export function createScheduleTaskTool( ), ), notify: Type.Optional( - Type.Boolean({ description: "Show a macOS notification after non-silent runs." }), + Type.Boolean({ + description: "Show a macOS notification after non-silent runs.", + }), ), }, { @@ -946,22 +1062,38 @@ export function createScheduleTaskTool( "Propose one approval-gated automation. action, name, cron, and prompt are all required.", }, ), - prepareArguments: (rawParams) => canonicalizeAssistantScheduleToolArguments(rawParams), - execute: async (_toolCallId, rawParams, signal): Promise> => { - const proposal = await repairAssistantScheduleMcpTarget(rawParams, dependencies); - const mcpServerBindings = approvedMcpBindings(rawParams, proposal.input.mcpServerIds ?? []); - if (signal?.aborted) throw new Error("Scheduled task creation was cancelled."); + prepareArguments: (rawParams) => + canonicalizeAssistantScheduleToolArguments(rawParams), + execute: async ( + _toolCallId, + rawParams, + signal, + ): Promise> => { + const proposal = await repairAssistantScheduleMcpTarget( + rawParams, + dependencies, + ); + const mcpServerBindings = approvedMcpBindings( + rawParams, + proposal.input.mcpServerIds ?? [], + ); + if (signal?.aborted) + throw new Error("Scheduled task creation was cancelled."); await Promise.all([ - resolveAssistantScheduleProject(proposal, (id) => dependencies.getWorkspace(id)), + resolveAssistantScheduleProject(proposal, (id) => + dependencies.getWorkspace(id), + ), resolveAssistantScheduleMcpServers( proposal, () => dependencies.listMcpServers(), mcpServerBindings, ), ]); - if (signal?.aborted) throw new Error("Scheduled task creation was cancelled."); + if (signal?.aborted) + throw new Error("Scheduled task creation was cancelled."); const schedulerEnabled = await dependencies.isSchedulingEnabled(); - if (signal?.aborted) throw new Error("Scheduled task creation was cancelled."); + if (signal?.aborted) + throw new Error("Scheduled task creation was cancelled."); const task = await dependencies.save( { ...proposal.input, @@ -1001,14 +1133,23 @@ export function createScheduleTaskTool( Type.Literal("run_now"), ]), id: Type.Optional( - Type.String({ description: "Exact task ID for pause, resume, remove, or run_now." }), + Type.String({ + description: "Exact task ID for pause, resume, remove, or run_now.", + }), + ), + name: Type.Optional( + Type.String({ description: "Task name. Required for create." }), ), - name: Type.Optional(Type.String({ description: "Task name. Required for create." })), cron: Type.Optional( - Type.String({ description: "Five- or six-part cron expression. Required for create." }), + Type.String({ + description: + "Five- or six-part cron expression. Required for create.", + }), ), timezone: Type.Optional( - Type.String({ description: "IANA timezone. Defaults to the device timezone." }), + Type.String({ + description: "IANA timezone. Defaults to the device timezone.", + }), ), mode: Type.Optional( Type.Union([Type.Literal("llm"), Type.Literal("script")], { @@ -1020,11 +1161,14 @@ export function createScheduleTaskTool( ), script: Type.Optional( Type.String({ - description: "File name from the workspace or global .aiden/scripts folder.", + description: + "File name from the workspace or global .aiden/scripts folder.", }), ), workspaceId: Type.Optional( - Type.String({ description: "Workspace ID. Defaults to the current chat workspace." }), + Type.String({ + description: "Workspace ID. Defaults to the current chat workspace.", + }), ), permission: Type.Optional( Type.Union([Type.Literal("read-only"), Type.Literal("full")], { @@ -1034,22 +1178,31 @@ export function createScheduleTaskTool( mcpServerIds: Type.Optional( Type.Array(Type.String(), { maxItems: 16, - description: "Exact configured MCP server IDs approved for unattended use.", + description: + "Exact configured MCP server IDs approved for unattended use.", }), ), notify: Type.Optional( - Type.Boolean({ description: "Show a macOS notification after non-silent runs." }), + Type.Boolean({ + description: "Show a macOS notification after non-silent runs.", + }), ), }), - execute: async (_toolCallId, rawParams): Promise> => { + execute: async ( + _toolCallId, + rawParams, + signal, + ): Promise> => { const params = rawParams as ScheduleToolParams; + throwIfScheduleToolAborted(signal); if (params.action === "list") { return result({ tasks: (await dependencies.list()).map(taskSummary) }); } if (params.action === "create") { const mode = params.mode ?? "llm"; - const workspaceId = params.workspaceId?.trim() || access.defaultWorkspaceId; + const workspaceId = + params.workspaceId?.trim() || access.defaultWorkspaceId; const workspace = await workspaceFor(workspaceId, dependencies); let prompt: string | undefined; let script: string | undefined; @@ -1060,10 +1213,15 @@ export function createScheduleTaskTool( recommendation = recommendedScheduledPermission(prompt); } else { script = required(params.script, "script"); - await dependencies.validateScript({ script, workspaceRoot: workspace?.folderPath }); + await dependencies.validateScript({ + script, + workspaceRoot: workspace?.folderPath, + }); } const mcpServerIds = - mode === "llm" ? (validateScheduledMcpServerIds(params.mcpServerIds) ?? []) : []; + mode === "llm" + ? (validateScheduledMcpServerIds(params.mcpServerIds) ?? []) + : []; if (workspaceId && mcpServerIds.length > 0) { throw new Error( "Scheduled tasks must choose either one project or MCP servers, not both.", @@ -1075,25 +1233,31 @@ export function createScheduleTaskTool( for (const id of mcpServerIds) { const server = byId.get(id); if (!server) throw new Error(`MCP server ${id} was not found.`); - if (!server.enabled) throw new Error(`MCP server "${server.name}" is disabled.`); + if (!server.enabled) + throw new Error(`MCP server "${server.name}" is disabled.`); } } - const task = await dependencies.save({ - name: required(params.name, "name"), - cron: required(params.cron, "cron"), - timezone: params.timezone, - mode, - prompt, - script, - workspaceId, - permission: - mode === "script" || mcpServerIds.length > 0 - ? "full" - : (params.permission ?? "read-only"), - mcpServerIds: mode === "llm" ? mcpServerIds : undefined, - notify: params.notify, - enabled: true, - }); + throwIfScheduleToolAborted(signal); + const task = await dependencies.save( + { + name: required(params.name, "name"), + cron: required(params.cron, "cron"), + timezone: params.timezone, + mode, + prompt, + script, + workspaceId, + permission: + mode === "script" || mcpServerIds.length > 0 + ? "full" + : (params.permission ?? "read-only"), + mcpServerIds: mode === "llm" ? mcpServerIds : undefined, + notify: params.notify, + enabled: true, + }, + undefined, + signal, + ); return result({ task: taskSummary(task), permissionRecommendation: @@ -1108,14 +1272,19 @@ export function createScheduleTaskTool( const id = required(params.id, "id"); if (params.action === "pause") - return result({ task: taskSummary(await dependencies.pause(id)) }); + return result({ + task: taskSummary(await dependencies.pause(id, signal)), + }); if (params.action === "resume") - return result({ task: taskSummary(await dependencies.resume(id)) }); + return result({ + task: taskSummary(await dependencies.resume(id, signal)), + }); if (params.action === "remove") { - await dependencies.remove(id); + await dependencies.remove(id, signal); return result({ removed: id }); } - if (params.action === "run_now") return result({ run: await dependencies.runNow(id) }); + if (params.action === "run_now") + return result({ run: await dependencies.runNow(id, signal) }); throw new Error(`Unsupported schedule action: ${String(params.action)}.`); }, }; @@ -1130,7 +1299,9 @@ export function scheduleTaskToolsForContext(context: { if (context.allowScheduling === false) return []; if (context.mode === "assistant-attended") { if (!context.assistantModelSelection) { - throw new Error("Assistant scheduling requires an exact provider and model selection."); + throw new Error( + "Assistant scheduling requires an exact provider and model selection.", + ); } return [ createAssistantScheduleListTool(), @@ -1141,5 +1312,10 @@ export function scheduleTaskToolsForContext(context: { createAssistantEditAutomationTool(context.assistantModelSelection), ]; } - return [createScheduleTaskTool({ kind: "standard", defaultWorkspaceId: context.workspaceId })]; + return [ + createScheduleTaskTool({ + kind: "standard", + defaultWorkspaceId: context.workspaceId, + }), + ]; } diff --git a/main/services/subagents/agent-compatibility.test.ts b/main/services/subagents/agent-compatibility.test.ts index 39e86603..35f8b9bd 100644 --- a/main/services/subagents/agent-compatibility.test.ts +++ b/main/services/subagents/agent-compatibility.test.ts @@ -48,6 +48,10 @@ const EMPTY_USAGE = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; +function semanticCheckpointSummary(label: string): string { + return `## Goal\n${label}\n\n## Constraints & Preferences\n- none\n\n## Progress\n### Done\n- [x] preserved state\n\n### In Progress\n- [ ] continue\n\n### Blocked\n- none\n\n## Key Decisions\n- preserve continuity\n\n## Next Steps\n1. Continue\n\n## Critical Context\n- ${label}`; +} + function deferred() { let resolve = (_value: T | PromiseLike): void => undefined; const promise = new Promise((resolvePromise) => { @@ -435,6 +439,19 @@ test("Aiden child factory shares its resolved transport while generating isolate }); }); +test("child runner resets retry failures and treats length stops as terminal failures", async () => { + const source = await readFile( + new URL("./subagent-child-runner.ts", import.meta.url), + "utf8", + ); + assert.match( + source, + /message_start[\s\S]{0,220}terminalError = null;[\s\S]{0,80}terminalAborted = false;/u, + ); + assert.match(source, /terminalGenerationLengthError\(message\)/u); + assert.match(source, /const exactOutput = terminalAssistantText\(message\)/u); +}); + test("real Agent approval hook authorizes and consumes one exact outbound effect", async () => { const core = createFauxCore({ provider: "aiden-compat-approval", @@ -547,27 +564,37 @@ test("real Agent approval hook authorizes and consumes one exact outbound effect assert.equal(registry.activeCount, 0); }); -test("child context compaction bounds oversized tool output before the next provider call", async () => { +test("child semantically compacts oversized tool output before the next provider call", async () => { const core = createFauxCore({ provider: "aiden-compat-context", models: [{ id: "compat-context", contextWindow: 8_192 }], }); let secondContext = ""; let continuationContext = ""; + const respondAfterTool = async (context: unknown) => { + const serialized = JSON.stringify(context); + if (/context summarization assistant/u.test(serialized)) { + if (/PREFIX of a turn/u.test(serialized)) { + return fauxAssistantMessage( + "## Original Request\nRead the oversized payload.\n\n## Early Progress\n- Read completed into a semantic history checkpoint.\n\n## Context for Suffix\n- Continue from bounded evidence.", + ); + } + return fauxAssistantMessage( + semanticCheckpointSummary("semantic history checkpoint"), + ); + } + if (/Continue from the compacted checkpoint/u.test(serialized)) { + continuationContext = serialized; + return fauxAssistantMessage("continued from checkpoint"); + } + secondContext = serialized; + return fauxAssistantMessage("bounded"); + }; core.setResponses([ fauxAssistantMessage(fauxToolCall("oversized_read", {}), { stopReason: "toolUse", }), - async (context) => { - secondContext = JSON.stringify(context); - return fauxAssistantMessage("bounded"); - }, - fauxAssistantMessage("semantic history checkpoint"), - fauxAssistantMessage("semantic turn-prefix checkpoint"), - async (context) => { - continuationContext = JSON.stringify(context); - return fauxAssistantMessage("continued from checkpoint"); - }, + ...Array.from({ length: 64 }, () => respondAfterTool), ]); const oversizedRead: AgentTool = { name: "oversized_read", @@ -585,18 +612,31 @@ test("child context compaction bounds oversized tool output before the next prov runtimeFrom(core.getModel() as Model, core.streamSimple), [oversizedRead], ); + let preparationCalls = 0; + let preparationCompacted = false; + const prepare = runningChild.agent.prepareNextTurnWithContext; + runningChild.agent.prepareNextTurnWithContext = async (value, signal) => { + preparationCalls += 1; + const prepared = await prepare?.(value, signal); + preparationCompacted ||= + prepared?.context?.messages[0]?.role === "compactionSummary"; + return prepared; + }; await runningChild.prompt("Read the oversized payload, then conclude."); await runningChild.agent.prompt("Continue from the compacted checkpoint."); - assert.equal(core.state.callCount, 5); - assert.match( - secondContext, - /context window|characters compacted|payload omitted/u, - ); + assert.ok(preparationCalls > 0); + assert.equal(preparationCompacted, true); + assert.ok(core.state.callCount >= 4); + assert.match(secondContext, /semantic history checkpoint/u); + assert.doesNotMatch(secondContext, /START-x{1000}/u); assert.ok(secondContext.length < 100_000); assert.equal(runningChild.agent.state.messages[0]?.role, "compactionSummary"); - assert.match(continuationContext, /conversation history.*compacted.*summary/isu); + assert.match( + continuationContext, + /conversation history.*compacted.*summary/isu, + ); assert.match(continuationContext, /semantic history checkpoint/u); assert.equal(registry.activeCount, 0); }); @@ -606,7 +646,9 @@ test("child completion survives a Pi journal append failure", async () => { provider: "aiden-compat-journal-resilience", models: [{ id: "compat-journal-resilience", contextWindow: 8_192 }], }); - core.setResponses([fauxAssistantMessage("completed despite journal failure")]); + core.setResponses([ + fauxAssistantMessage("completed despite journal failure"), + ]); const journalErrors: unknown[] = []; let failedAssistantBatch = false; const registry = new SubagentRuntimeRegistry(undefined, undefined, { @@ -628,7 +670,9 @@ test("child completion survives a Pi journal append failure", async () => { ); await assert.doesNotReject( - runningChild.prompt("Complete even if the in-memory journal cannot append."), + runningChild.prompt( + "Complete even if the in-memory journal cannot append.", + ), ); assert.equal(core.state.callCount, 1); @@ -647,13 +691,17 @@ test("forked initial context is compacted before the first provider request", as models: [{ id: "compat-initial-fork", contextWindow: 8_192 }], }); let firstContext = ""; - core.setResponses([ - async (context) => { - firstContext = JSON.stringify(context); - return fauxAssistantMessage("bounded"); - }, - fauxAssistantMessage("semantic checkpoint"), - ]); + const respond = async (context: unknown) => { + const serialized = JSON.stringify(context); + if (/context summarization assistant/u.test(serialized)) { + return fauxAssistantMessage( + semanticCheckpointSummary("semantic checkpoint"), + ); + } + firstContext = JSON.stringify(context); + return fauxAssistantMessage("bounded"); + }; + core.setResponses(Array.from({ length: 64 }, () => respond)); const registry = new SubagentRuntimeRegistry(); const modelRuntime = runtimeFrom( core.getModel() as Model, @@ -681,7 +729,7 @@ test("forked initial context is compacted before the first provider request", as await runningChild.prompt("Conclude from the forked conversation."); - assert.equal(core.state.callCount, 2); + assert.ok(core.state.callCount > 2); assert.doesNotMatch(firstContext, /FORK-START|FORK-END/u); assert.match(firstContext, /Conclude from the forked conversation/u); assert.ok(firstContext.length < 100_000); diff --git a/main/services/subagents/capability-profile.test.ts b/main/services/subagents/capability-profile.test.ts index c1999200..02d1d958 100644 --- a/main/services/subagents/capability-profile.test.ts +++ b/main/services/subagents/capability-profile.test.ts @@ -14,7 +14,10 @@ import { test("every V1 role resolves through the same positive read/search ceiling", () => { for (const role of SUBAGENT_ROLES) { - const profile = resolveCapabilityProfile({ kind: "subagent", role }, "full"); + const profile = resolveCapabilityProfile( + { kind: "subagent", role }, + "full", + ); assert.deepEqual(profile.tools, SUBAGENT_READ_TOOL_NAMES); } }); @@ -32,20 +35,29 @@ test("capabilities are the parent, role, feature, and inherited intersection", ( ).tools, ["grep"], ); - assert.deepEqual(resolveCapabilityProfile({ kind: "subagent", role: "scout" }, "none").tools, []); + assert.deepEqual( + resolveCapabilityProfile({ kind: "subagent", role: "scout" }, "none").tools, + [], + ); }); test("parent tool exclusions become a positive child read-tool ceiling", () => { assert.deepEqual( - inheritedSubagentReadToolCeiling(new Set(["read_file", "grep", "run_command"])), + inheritedSubagentReadToolCeiling( + new Set(["read_file", "grep", "run_command"]), + ), ["list_dir", "glob"], ); - assert.deepEqual(inheritedSubagentReadToolCeiling(undefined), SUBAGENT_READ_TOOL_NAMES); + assert.deepEqual( + inheritedSubagentReadToolCeiling(undefined), + SUBAGENT_READ_TOOL_NAMES, + ); }); test("unknown roles fail closed before workspace tool construction", () => { assert.throws( - () => resolveCapabilityProfile({ kind: "subagent", role: "worker" }, "full"), + () => + resolveCapabilityProfile({ kind: "subagent", role: "worker" }, "full"), /Unknown subagent role/, ); assert.throws( @@ -77,10 +89,17 @@ test("malformed capability profiles and permissions fail closed", () => { { kind: "subagent", role: "scout", unexpected: true }, { kind: "subagent", role: "scout", featurePolicy: "read_file" }, ]) { - assert.throws(() => parseSubagentCapabilityRequest(input), /Invalid subagent capability/); + assert.throws( + () => parseSubagentCapabilityRequest(input), + /Invalid subagent capability/, + ); } assert.throws( - () => resolveCapabilityProfile({ kind: "subagent", role: "scout" }, "owner" as never), + () => + resolveCapabilityProfile( + { kind: "subagent", role: "scout" }, + "owner" as never, + ), /Invalid parent workspace permission/, ); }); @@ -114,14 +133,20 @@ test("the child builder constructs only the four permitted tool objects", () => }); test("production assembly resolves capability tools before ambient settings or factories", async () => { - const source = await readFile(new URL("../tools.ts", import.meta.url), "utf-8"); + const source = await readFile( + new URL("../tools.ts", import.meta.url), + "utf-8", + ); const builder = source.indexOf("export async function buildAgentTools"); const capabilityBranch = source.indexOf( 'if (ctx.mode === "subagent" || hasCapabilityProfile)', builder, ); const settingsRead = source.indexOf("configStore.getSettings()", builder); - const normalCodingTools = source.indexOf("buildCodingTools(ctx.workspaceRoot)", builder); + const normalCodingTools = source.indexOf( + "buildCodingTools(ctx.workspaceRoot)", + builder, + ); assert.ok(builder >= 0); assert.ok(capabilityBranch > builder); @@ -133,7 +158,7 @@ test("production assembly resolves capability tools before ambient settings or f ); assert.match( source.slice(builder, capabilityBranch), - /hasOwnProperty\.call\(ctx, "capabilityProfile"\)/, + /hasOwnProperty\.call\(\s*ctx,\s*"capabilityProfile",?\s*\)/, ); assert.match( source.slice(capabilityBranch, settingsRead), diff --git a/main/services/subagents/child-agent-runtime.ts b/main/services/subagents/child-agent-runtime.ts index 5af0131f..ca90e376 100644 --- a/main/services/subagents/child-agent-runtime.ts +++ b/main/services/subagents/child-agent-runtime.ts @@ -13,7 +13,10 @@ import type { } from "@earendil-works/pi-agent-core"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import { writeDevLog } from "../dev-log.js"; -import { buildAgentRuntimeOptions } from "../generation-runtime.js"; +import { + buildAgentRuntimeOptions, + waitForAbortableDelay, +} from "../generation-runtime.js"; import { assertGenerationContextCapacity, createGenerationContextTransform, @@ -27,6 +30,7 @@ import { import type { SubagentHealthMetricsSink } from "./subagent-health-metrics-core.js"; import { createPiCompactionModels, + needsImmediatePiCompaction, PiCompactionCoordinator, } from "../pi-compaction-core.js"; import { appendPiMessages } from "../pi-compaction-session-store.js"; @@ -83,6 +87,10 @@ interface RegisteredSubagentChild { export interface SubagentRuntimeRegistryOptions { appendPiMessages?: typeof appendPiMessages; onPiJournalError?: (error: unknown) => void; + recordCompactionUsage?: ( + message: AssistantMessage, + runtime: ResolvedModelRuntime, + ) => void | Promise; } function childIdentity( @@ -123,6 +131,7 @@ export class SubagentRuntimeRegistry { private readonly concurrency: SubagentConcurrencyGate; private readonly appendSessionMessages: typeof appendPiMessages; private readonly onPiJournalError: (error: unknown) => void; + private readonly recordCompactionUsage?: SubagentRuntimeRegistryOptions["recordCompactionUsage"]; private shuttingDown = false; constructor( @@ -147,6 +156,7 @@ export class SubagentRuntimeRegistry { error, ]); }); + this.recordCompactionUsage = options.recordCompactionUsage; } setHealthMetrics(healthMetrics: SubagentHealthMetricsSink): void { @@ -178,12 +188,16 @@ export class SubagentRuntimeRegistry { contextWindow: spec.runtime.model.contextWindow, systemPrompt: spec.systemPrompt, tools: spec.tools, + supportsImages: spec.runtime.model.input.includes("image"), }; assertGenerationContextCapacity(contextOptions); + let emergencyContextReduction = false; const agent = new Agent({ ...buildAgentRuntimeOptions(sessionId, spec.runtime), convertToLlm, - transformContext: createGenerationContextTransform(contextOptions), + transformContext: createGenerationContextTransform(contextOptions, () => { + emergencyContextReduction = true; + }), // This is the first point at which Pi has received an actual provider // response for this child. It deliberately carries no response content, // headers, model metadata, or identity beyond the already-owned entry. @@ -210,7 +224,9 @@ export class SubagentRuntimeRegistry { (session) => new PiCompactionCoordinator({ session, - models: createPiCompactionModels(spec.runtime), + models: createPiCompactionModels(spec.runtime, (message) => + this.recordCompactionUsage?.(message, spec.runtime), + ), model: spec.runtime.model, thinkingLevel: spec.thinkingLevel, signal: cancellation.signal, @@ -220,6 +236,10 @@ export class SubagentRuntimeRegistry { let lastAssistantMessage: AssistantMessage | undefined; agent.subscribe((event) => { if (event.type !== "message_end") return; + // The child prompt is appended to the session before agent.prompt(). Pi + // emits it again as a lifecycle event, so journaling user events here + // would duplicate the request and distort later compaction. + if (event.message.role === "user") return; pendingPiMessages.push(event.message); if (event.message.role === "assistant") { lastAssistantMessage = event.message; @@ -240,6 +260,27 @@ export class SubagentRuntimeRegistry { return false; } }; + agent.prepareNextTurnWithContext = async ({ context, toolResults }) => { + const session = await sessionPromise; + const coordinator = await compaction; + if (!(await flushPiMessages(session))) { + return undefined; + } + // Include the just-flushed tool results in pressure estimation before + // the next provider request. + const immediate = needsImmediatePiCompaction( + toolResults, + spec.runtime.model.contextWindow, + ); + const result = await coordinator.checkContextPressure({ + forceThreshold: immediate, + sealCurrentTurnIfNeeded: immediate, + }); + emergencyContextReduction = false; + if (!result.messages) return undefined; + agent.state.messages = [...result.messages]; + return { context: { ...context, messages: [...result.messages] } }; + }; const deployment: SubagentDeployment = isLocalProviderDeployment( spec.runtime.provider, ) @@ -260,7 +301,9 @@ export class SubagentRuntimeRegistry { const cancel = (reason = new Error("Subagent task cancelled.")) => { if (!entry.cancellation.signal.aborted) entry.cancellation.abort(reason); - void entry.compaction.then((coordinator) => coordinator.abort()).catch(() => {}); + void entry.compaction + .then((coordinator) => coordinator.abort()) + .catch(() => {}); agent.abort(); if (!entry.completion) this.children.delete(childId); }; @@ -292,6 +335,19 @@ export class SubagentRuntimeRegistry { }; await session.appendMessage(userMessage); coordinator.beginPrompt(); + const prePromptCompaction = + await coordinator.checkContextPressure(); + if (prePromptCompaction.messages) { + const rebuilt = [...prePromptCompaction.messages]; + const trailing = rebuilt[rebuilt.length - 1]; + if ( + trailing?.role === "user" && + trailing.timestamp === userMessage.timestamp + ) { + rebuilt.pop(); + } + agent.state.messages = rebuilt; + } entry.releaseInference = await this.concurrency.acquire( deployment, entry.cancellation.signal, @@ -323,19 +379,17 @@ export class SubagentRuntimeRegistry { const journalFlushed = await flushPiMessages(session); if (!journalFlushed) break; if (!lastAssistantMessage) break; - const result = await coordinator.check(lastAssistantMessage); + const result = await coordinator.check(lastAssistantMessage, { + forceThreshold: emergencyContextReduction, + }); + emergencyContextReduction = false; if (!result.messages) break; - const rebuiltMessages = [...result.messages]; - const trailing = rebuiltMessages[rebuiltMessages.length - 1]; - if ( - result.shouldRetry && - trailing?.role === "assistant" && - trailing.stopReason === "error" - ) { - rebuiltMessages.pop(); - } - agent.state.messages = rebuiltMessages; + agent.state.messages = [...result.messages]; if (!result.shouldRetry) break; + await waitForAbortableDelay( + result.retryDelayMs ?? 0, + entry.cancellation.signal, + ); } } finally { entry.releaseInference?.(); @@ -394,7 +448,9 @@ export class SubagentRuntimeRegistry { if (!entry.cancellation.signal.aborted) { entry.cancellation.abort(new Error("Subagent task cancelled.")); } - void entry.compaction.then((coordinator) => coordinator.abort()).catch(() => {}); + void entry.compaction + .then((coordinator) => coordinator.abort()) + .catch(() => {}); entry.agent.abort(); if (!entry.completion) this.children.delete(childId); } @@ -407,7 +463,9 @@ export class SubagentRuntimeRegistry { for (const [childId, entry] of this.children) { if (!matches(entry.authority)) continue; if (!entry.cancellation.signal.aborted) entry.cancellation.abort(reason); - void entry.compaction.then((coordinator) => coordinator.abort()).catch(() => {}); + void entry.compaction + .then((coordinator) => coordinator.abort()) + .catch(() => {}); entry.agent.abort(); if (!entry.completion) this.children.delete(childId); } @@ -477,7 +535,9 @@ export class SubagentRuntimeRegistry { new Error("Subagent runtime is shutting down."), ); } - void entry.compaction.then((coordinator) => coordinator.abort()).catch(() => {}); + void entry.compaction + .then((coordinator) => coordinator.abort()) + .catch(() => {}); entry.agent.abort(); } const settled = await boundedSettlement( @@ -498,4 +558,23 @@ export class SubagentRuntimeRegistry { } } -export const subagentRuntimeRegistry = new SubagentRuntimeRegistry(); +export const subagentRuntimeRegistry = new SubagentRuntimeRegistry( + undefined, + undefined, + { + recordCompactionUsage: async (message, runtime) => { + const [{ assistantUsageRecord }, { usageStore }] = await Promise.all([ + import("../usage-accounting.js"), + import("../usage-store.js"), + ]); + await usageStore.record( + assistantUsageRecord({ + message, + provider: runtime.provider, + model: runtime.model, + source: "compaction", + }), + ); + }, + }, +); diff --git a/main/services/subagents/subagent-child-runner.ts b/main/services/subagents/subagent-child-runner.ts index 0e88087e..9f0e974e 100644 --- a/main/services/subagents/subagent-child-runner.ts +++ b/main/services/subagents/subagent-child-runner.ts @@ -9,8 +9,9 @@ import type { } from "@earendil-works/pi-agent-core"; import { performance } from "node:perf_hooks"; import { - terminalAssistantTextFallback, + terminalAssistantText, terminalGenerationError, + terminalGenerationLengthError, terminalGenerationWasAborted, } from "../generation-runtime.js"; import type { ResolvedModelRuntime } from "../model-runtime-core.js"; @@ -638,6 +639,8 @@ export async function runSubagentChild( ) { currentTurnHadTextDelta = false; currentTurnOutput = ""; + terminalError = null; + terminalAborted = false; } else if (event.type === "message_update") { const update = event.assistantMessageEvent; if (update.type === "text_delta") { @@ -681,23 +684,22 @@ export async function runSubagentChild( if (message) { input.telemetry?.usage(message); await recordUsage(message, input.runtime); - const error = terminalGenerationError(message); + const error = + terminalGenerationError(message) ?? + terminalGenerationLengthError(message); if (error) terminalError = error; if (terminalGenerationWasAborted(message)) terminalAborted = true; - const fallback = terminalAssistantTextFallback( - message, - currentTurnHadTextDelta, - ); - if (fallback) { - observedOutputChars += fallback.length; - const remaining = policy.maxOutputChars - currentTurnOutput.length; - currentTurnOutput += fallback.slice(0, Math.max(0, remaining)); - if ( - observedOutputChars > policy.maxOutputChars || - fallback.length > remaining - ) { - stopForLimit("The child reached its output limit."); - } + const exactOutput = terminalAssistantText(message); + const additionalObserved = currentTurnHadTextDelta + ? Math.max(0, exactOutput.length - currentTurnOutput.length) + : exactOutput.length; + observedOutputChars += additionalObserved; + currentTurnOutput = exactOutput.slice(0, policy.maxOutputChars); + if ( + observedOutputChars > policy.maxOutputChars || + exactOutput.length > policy.maxOutputChars + ) { + stopForLimit("The child reached its output limit."); } terminalOutput = currentTurnOutput; } @@ -735,7 +737,7 @@ export async function runSubagentChild( if (outcome.kind === "failed") { return safeFailure(input.request); } - if (terminalError) return safeFailure(input.request); + if (terminalError) return safeFailure(input.request, terminalError); if (terminalAborted) { return { role: input.request.role, diff --git a/main/services/subagents/subagent-packaged-soak-main.test.ts b/main/services/subagents/subagent-packaged-soak-main.test.ts index 96b7b90d..3a25a2fe 100644 --- a/main/services/subagents/subagent-packaged-soak-main.test.ts +++ b/main/services/subagents/subagent-packaged-soak-main.test.ts @@ -4,7 +4,10 @@ import * as path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const REPO_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); async function source(relativePath: string): Promise { return fs.readFile(path.join(REPO_ROOT, relativePath), "utf-8"); @@ -15,8 +18,13 @@ test("the packaged soak has fixed UI actions, aggregate receipt timing, and no I const soakStart = main.indexOf("async function runPackagedSubagentSoak("); const soakEnd = main.indexOf("\nregisterAppPathOpener", soakStart); const soak = main.slice(soakStart, soakEnd); - const settlementStart = main.indexOf("async function settlePackagedSubagentSoak("); - const settlementEnd = main.indexOf("\n/**\n * Drives exactly one", settlementStart); + const settlementStart = main.indexOf( + "async function settlePackagedSubagentSoak(", + ); + const settlementEnd = main.indexOf( + "\n/**\n * Drives exactly one", + settlementStart, + ); const settlement = main.slice(settlementStart, settlementEnd); const startup = main.slice(main.indexOf("app\n .whenReady()")); @@ -24,15 +32,30 @@ test("the packaged soak has fixed UI actions, aggregate receipt timing, and no I assert.ok(soakEnd > soakStart); assert.ok(settlementStart >= 0); assert.ok(settlementEnd > settlementStart); - assert.match(main, /loadSubagentPackagedSoakSession\([\s\S]*isPackaged: isPackagedRuntime\(\)/u); + assert.match( + main, + /loadSubagentPackagedSoakSession\([\s\S]*isPackaged: isPackagedRuntime\(\)/u, + ); assert.match(main, /packagedSubagentSoak && !subagentsEnabled\(\)/u); assert.match(main, /button\[aria-label="Send message"\]/u); const inputFill = main.indexOf("setter.call(input, prompt)"); - const sendEnabled = main.indexOf("if (send.disabled) return false;", inputFill); + const sendEnabled = main.indexOf( + "if (send.disabled) return false;", + inputFill, + ); const sendClick = main.indexOf("send.click();", sendEnabled); - assert.ok(inputFill >= 0, "the fixed driver fills the empty composer before waiting to send"); - assert.ok(sendEnabled > inputFill, "the Send readiness check follows the input event"); - assert.ok(sendClick > sendEnabled, "the fixed driver clicks only after Send becomes enabled"); + assert.ok( + inputFill >= 0, + "the fixed driver fills the empty composer before waiting to send", + ); + assert.ok( + sendEnabled > inputFill, + "the Send readiness check follows the input event", + ); + assert.ok( + sendClick > sendEnabled, + "the fixed driver clicks only after Send becomes enabled", + ); assert.match(main, /button\[aria-label="Stop generating"\]/u); // Source formatting may render the selector quotes literally or escape them inside a string. assert.match(main, /nav\[aria-label=\\?"Settings\\?"\]/u); @@ -43,25 +66,54 @@ test("the packaged soak has fixed UI actions, aggregate receipt timing, and no I assert.match(soak, /SUBAGENT_PACKAGED_SOAK_STOP_SCRIPT/u); assert.match(soak, /SUBAGENT_PACKAGED_SOAK_SETTINGS_VISIBLE_SCRIPT/u); assert.match(soak, /path: action\.path/u); - assert.match(soak, /subagentRuntimeRegistry\.hasChatChildren\(SUBAGENT_PACKAGED_SOAK_CHAT_ID\)/u); - assert.match(soak, /subagentRuntimeRegistry\.hasChatProviderResponse\(SUBAGENT_PACKAGED_SOAK_CHAT_ID\)/u); - assert.match(soak, /\(await subagentHealthMetrics\.snapshotForPackagedSoak\(\)\)\.starts === 1/u); + assert.match( + soak, + /subagentRuntimeRegistry\.hasChatChildren\(\s*SUBAGENT_PACKAGED_SOAK_CHAT_ID\s*,?\s*\)/u, + ); + assert.match( + soak, + /subagentRuntimeRegistry\.hasChatProviderResponse\(\s*SUBAGENT_PACKAGED_SOAK_CHAT_ID\s*,?\s*\)/u, + ); + assert.match( + soak, + /\(await subagentHealthMetrics\.snapshotForPackagedSoak\(\)\)\.starts === 1/u, + ); assert.match(soak, /await settlePackagedSubagentSoak\(session\)/u); - assert.match(settlement, /llmClient\.waitForChatIdle\(SUBAGENT_PACKAGED_SOAK_CHAT_ID\)/u); - assert.match(settlement, /subagentRuntimeRegistry\.hasChatChildren\(SUBAGENT_PACKAGED_SOAK_CHAT_ID\)/u); - assert.match(settlement, /subagentHealthMetrics\.snapshotForPackagedSoak\(\)/u); + assert.match( + settlement, + /llmClient\.waitForChatIdle\(\s*SUBAGENT_PACKAGED_SOAK_CHAT_ID\s*,?\s*\)/u, + ); + assert.match( + settlement, + /subagentRuntimeRegistry\.hasChatChildren\(\s*SUBAGENT_PACKAGED_SOAK_CHAT_ID\s*,?\s*\)/u, + ); + assert.match( + settlement, + /subagentHealthMetrics\.snapshotForPackagedSoak\(\)/u, + ); assert.match(settlement, /writeSubagentPackagedSoakReceipt\(/u); - assert.ok(settlement.lastIndexOf("app.quit()") > settlement.lastIndexOf("writeSubagentPackagedSoakReceipt(")); + assert.ok( + settlement.lastIndexOf("app.quit()") > + settlement.lastIndexOf("writeSubagentPackagedSoakReceipt("), + ); const shutdownStart = main.indexOf("async function shutdownAndQuit"); - const shutdownEnd = main.indexOf("\nasync function refreshCloseGuardFromRenderer", shutdownStart); + const shutdownEnd = main.indexOf( + "\nasync function refreshCloseGuardFromRenderer", + shutdownStart, + ); const shutdown = main.slice(shutdownStart, shutdownEnd); const parentShutdown = shutdown.indexOf("await llmClient.shutdown()"); - const registryShutdown = shutdown.indexOf("await subagentRuntimeRegistry.shutdown()"); + const registryShutdown = shutdown.indexOf( + "await subagentRuntimeRegistry.shutdown()", + ); const quitReceiptFinalization = shutdown.indexOf( "await tryFinalizeSubagentPackagedSoakQuitReceipt(", registryShutdown, ); - const cleanup = shutdown.indexOf("cleanupApplication()", quitReceiptFinalization); + const cleanup = shutdown.indexOf( + "cleanupApplication()", + quitReceiptFinalization, + ); const failureExit = shutdown.indexOf("app.exit(1);", quitReceiptFinalization); const forcedQuit = shutdown.indexOf("forceAppQuit = true;", cleanup); const quitAction = soak.indexOf('case "normal_quit"'); @@ -73,22 +125,36 @@ test("the packaged soak has fixed UI actions, aggregate receipt timing, and no I assert.ok(failureExit < cleanup); assert.ok(cleanup > quitReceiptFinalization); assert.ok(forcedQuit > cleanup); - assert.match( - shutdown, - /parentSettled\s*=\s*await llmClient\.shutdown\(\)/u, - ); + assert.match(shutdown, /parentSettled\s*=\s*await llmClient\.shutdown\(\)/u); assert.match(shutdown, /quitReceiptFinalization\.status === "failed"/u); assert.match(shutdown, /continuing shutdown without a receipt/u); assert.match(shutdown, /quitReceiptFinalization\.status === "timed_out"/u); - assert.match(shutdown, /exceeded its shutdown budget; continuing without a receipt/u); + assert.match( + shutdown, + /exceeded its shutdown budget; continuing without a receipt/u, + ); assert.match( shutdown, /requiresSubagentPackagedSoakFailureExit\(session, quitReceiptFinalization\)[\s\S]*app\.exit\(1\);[\s\S]*return;/u, ); - assert.match(soak.slice(quitAction), /pendingPackagedSubagentSoakReceipt = session;[\s\S]*app\.quit\(\)/u); - assert.doesNotMatch(soak.slice(quitAction), /writeSubagentPackagedSoakReceipt\(/u); - assert.ok(startup.indexOf("await runPackagedSubagentSoak(packagedSubagentSoak);") >= 0); - assert.ok(startup.indexOf("return;", startup.indexOf("await runPackagedSubagentSoak")) >= 0); + assert.match( + soak.slice(quitAction), + /pendingPackagedSubagentSoakReceipt = session;[\s\S]*app\.quit\(\)/u, + ); + assert.doesNotMatch( + soak.slice(quitAction), + /writeSubagentPackagedSoakReceipt\(/u, + ); + assert.ok( + startup.indexOf("await runPackagedSubagentSoak(packagedSubagentSoak);") >= + 0, + ); + assert.ok( + startup.indexOf( + "return;", + startup.indexOf("await runPackagedSubagentSoak"), + ) >= 0, + ); assert.doesNotMatch(main, /ipcMain\.(?:handle|on)\([^\n]*subagent.*soak/iu); }); @@ -100,6 +166,9 @@ test("parent shutdown gives a child cleanup drain time to report before its oute /SHUTDOWN_GENERATION_GRACE_MS\s*=\s*DEFAULT_SUBAGENT_CANCELLATION_GRACE_MS\s*\+\s*1_000/u, ); assert.match(llmClient, /async shutdown\(\): Promise/u); - assert.match(llmClient, /const activeSettled = await settleGenerationCleanup\(/u); + assert.match( + llmClient, + /const activeSettled = await settleGenerationCleanup\(/u, + ); assert.match(llmClient, /return activeSettled && parentStateCleared;/u); }); diff --git a/main/services/subagents/subagent-phase3-contract.test.ts b/main/services/subagents/subagent-phase3-contract.test.ts index b6ff3daa..bb337b7b 100644 --- a/main/services/subagents/subagent-phase3-contract.test.ts +++ b/main/services/subagents/subagent-phase3-contract.test.ts @@ -256,7 +256,7 @@ test("empty-chat workspace moves serialize against generation authority and term ); assert.match( llm, - /chatStore\.appendMessage\([\s\S]{0,500}expectedWorkspaceId: initialization\.workspaceId/u, + /chatStore\.appendMessage\([\s\S]{0,900}expectedWorkspaceId: initialization\.workspaceId/u, ); assert.match( chatStore, diff --git a/main/services/terminal-history.test.ts b/main/services/terminal-history.test.ts index 8f87d575..09812fb4 100644 --- a/main/services/terminal-history.test.ts +++ b/main/services/terminal-history.test.ts @@ -31,7 +31,10 @@ const SGR_RESET = "\u001b[0m"; test("sanitizer strips CSI cursor-position, device-attributes, and device-status queries", () => { const input = `hello ${CSI_CPR}${CSI_DA}world${CSI_DSR}!`; - const { visibleText, pendingControlSequence } = sanitizeTerminalHistoryChunk("", input); + const { visibleText, pendingControlSequence } = sanitizeTerminalHistoryChunk( + "", + input, + ); assert.equal(pendingControlSequence, ""); assert.equal(visibleText, "hello world!"); }); @@ -48,7 +51,10 @@ test("sanitizer carries an incomplete escape sequence across chunk boundaries", assert.equal(first.visibleText, "a"); assert.equal(first.pendingControlSequence, "\u001b[6"); - const second = sanitizeTerminalHistoryChunk(first.pendingControlSequence, "nb"); + const second = sanitizeTerminalHistoryChunk( + first.pendingControlSequence, + "nb", + ); assert.equal(second.pendingControlSequence, ""); // The full CSI 6 n was recognized and stripped; "b" is the only new visible text. assert.equal(second.visibleText, "b"); @@ -64,7 +70,10 @@ test("sanitizer strips OSC color queries (10;?) and rgb: replies", () => { test("sanitizer strips DCS DECRQSS ($q) queries", () => { // DCS $ q m ST — a DECRQSS query for SGR. const dcsQuery = "\u001bP$qm\u001b\\"; - const { visibleText } = sanitizeTerminalHistoryChunk("", `pre${dcsQuery}post`); + const { visibleText } = sanitizeTerminalHistoryChunk( + "", + `pre${dcsQuery}post`, + ); assert.equal(visibleText, "prepost"); }); @@ -75,7 +84,8 @@ test("capHistory keeps only the most recent N lines", () => { }); test("capHistory preserves a trailing newline", () => { - const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + "\n"; + const lines = + Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + "\n"; const capped = capHistory(lines, 2); assert.equal(capped, "line8\nline9\n"); }); @@ -112,7 +122,10 @@ test("TerminalHistoryStore preserves restored history when appending after resta await restarted.flush("ws-1"); const verified = new TerminalHistoryStore({ logsDir: dir }); - assert.equal(await verified.read("ws-1"), "before restart\nafter restart\n"); + assert.equal( + await verified.read("ws-1"), + "before restart\nafter restart\n", + ); } finally { await rm(dir, { recursive: true, force: true }); } @@ -245,7 +258,7 @@ test("production startup installs and flushes persisted terminal history", async assert.match(main, /TerminalHistoryStore/u); assert.match( main, - /terminalService\.installHistoryStore\(await TerminalHistoryStore\.create\(\)\)/u, + /terminalService\.installHistoryStore\(\s*await TerminalHistoryStore\.create\(\),?\s*\)/u, ); assert.match(main, /terminalService\.flushHistory\(\)/u); }); diff --git a/main/services/tools.ts b/main/services/tools.ts index 6cf15cf2..0e905591 100644 --- a/main/services/tools.ts +++ b/main/services/tools.ts @@ -11,7 +11,10 @@ import { configStore } from "./config-store.js"; import { secrets } from "./secrets.js"; import { collectMcpAgentTools } from "./mcp.js"; import { buildCodingTools } from "./coding-tools.js"; -import type { ScheduledMcpServerBinding, WorkspacePermission } from "./types.js"; +import type { + ScheduledMcpServerBinding, + WorkspacePermission, +} from "./types.js"; import type { SkillRegistrySnapshot } from "./skill-registry.js"; import { skillRegistry } from "./skill-registry-main.js"; import { buildSkillTools } from "./skill-tools.js"; @@ -53,8 +56,11 @@ function makeExaTool(apiKey: string): AgentTool { }), ), }), - execute: async (_id, params): Promise> => { - const { query, numResults } = params as { query: string; numResults?: number }; + execute: async (_id, params, signal): Promise> => { + const { query, numResults } = params as { + query: string; + numResults?: number; + }; const response = await fetch(EXA_ENDPOINT, { method: "POST", headers: { "content-type": "application/json", "x-api-key": apiKey }, @@ -63,6 +69,7 @@ function makeExaTool(apiKey: string): AgentTool { numResults: numResults ?? 5, contents: { text: { maxCharacters: 1200 } }, }), + signal, }); if (!response.ok) { const body = await response.text().catch(() => ""); @@ -136,16 +143,27 @@ export function buildSchedulingTools( } async function configuredMcpTools(ctx: ToolContext): Promise { - const servers = selectedMcpServers(await configStore.listMcpServers(), ctx.mcpServerIds); - if (ctx.mcpServerBindings) assertScheduledMcpServerBindings(servers, ctx.mcpServerBindings); - return collectMcpAgentTools(servers, { strict: ctx.mcpServerIds !== undefined }); + const servers = selectedMcpServers( + await configStore.listMcpServers(), + ctx.mcpServerIds, + ); + if (ctx.mcpServerBindings) + assertScheduledMcpServerBindings(servers, ctx.mcpServerBindings); + return collectMcpAgentTools(servers, { + strict: ctx.mcpServerIds !== undefined, + }); } export async function buildAgentTools(ctx: ToolContext): Promise { - const hasCapabilityProfile = Object.prototype.hasOwnProperty.call(ctx, "capabilityProfile"); + const hasCapabilityProfile = Object.prototype.hasOwnProperty.call( + ctx, + "capabilityProfile", + ); if (ctx.mode === "subagent" || hasCapabilityProfile) { if (ctx.mode !== "subagent") { - throw new Error("Subagent capabilities require the explicit subagent tool mode."); + throw new Error( + "Subagent capabilities require the explicit subagent tool mode.", + ); } return buildSubagentCapabilityTools({ workspaceRoot: ctx.workspaceRoot, @@ -153,7 +171,11 @@ export async function buildAgentTools(ctx: ToolContext): Promise { capabilityProfile: ctx.capabilityProfile, }).tools; } - if (ctx.mode !== undefined && ctx.mode !== "assistant" && ctx.mode !== "assistant-automation") { + if ( + ctx.mode !== undefined && + ctx.mode !== "assistant" && + ctx.mode !== "assistant-automation" + ) { throw new Error(`Unknown agent tool mode: ${JSON.stringify(ctx.mode)}.`); } @@ -170,7 +192,8 @@ export async function buildAgentTools(ctx: ToolContext): Promise { createAssistantMcpServerTool(), ...buildSchedulingTools(ctx), ]; - if (ctx.allowMcpTools === true) tools.push(...(await configuredMcpTools(ctx))); + if (ctx.allowMcpTools === true) + tools.push(...(await configuredMcpTools(ctx))); return tools; } @@ -179,7 +202,9 @@ export async function buildAgentTools(ctx: ToolContext): Promise { // from an external service cannot flow into project mutation tools. if (ctx.mode === "assistant-automation") { if (ctx.allowMcpTools === true || (ctx.mcpServerIds?.length ?? 0) > 0) { - throw new Error("Assistant project automations cannot use MCP connectors."); + throw new Error( + "Assistant project automations cannot use MCP connectors.", + ); } return ctx.workspaceRoot && ctx.permission !== "none" ? buildCodingTools(ctx.workspaceRoot) @@ -210,7 +235,9 @@ export async function buildAgentTools(ctx: ToolContext): Promise { // Every skill consumer uses this exact authoritative snapshot. const skillSnapshot = ctx.skillSnapshot ?? - (ctx.workspaceId ? await skillRegistry.snapshot(ctx.workspaceId) : undefined); + (ctx.workspaceId + ? await skillRegistry.snapshot(ctx.workspaceId) + : undefined); if (skillSnapshot) { tools.push(...buildSkillTools(skillSnapshot, ctx.permission !== "none")); } diff --git a/main/services/types.ts b/main/services/types.ts index ec5e0d21..e0d39d94 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -9,6 +9,7 @@ import type { GoogleThinkingLevel } from "../../renderer/shared/google-thinking. import type { KeybindingOverridesV1 } from "../../renderer/shared/keybindings.js"; import type { SubagentMessageReferenceV1 } from "../../renderer/shared/subagent-runs.js"; import type { SkillProvenanceV1 } from "../../renderer/shared/slash-commands.js"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; export type ProviderKind = "openai" | "anthropic"; @@ -188,6 +189,8 @@ export interface ChatMessage { model?: string; /** Deliberately exposed provider reasoning retained on an assistant message. */ reasoning?: string; + /** Canonical Pi assistant protocol payload; diagnostics are deliberately excluded. */ + pi?: Omit; /** Files attached to a user message. */ attachments?: Attachment[]; /** Safe display-only provenance for an explicitly invoked skill. */ @@ -209,11 +212,7 @@ export interface ModelRanking { } export type ModelMetadataSource = - | "local" - | "provider" - | "artificial-analysis" - | "models-dev" - | "fallback"; + "local" | "provider" | "artificial-analysis" | "models-dev" | "fallback"; /** Normalized model metadata after applying local and bundled-source precedence. */ export interface ModelInfo { @@ -392,7 +391,8 @@ export interface DiscoveredSkill { export type VoiceProvider = "openai" | "gemini" | "local"; -export type ChatTitleProviderId = "automatic" | "apple-foundation-models" | "chat-model"; +export type ChatTitleProviderId = + "automatic" | "apple-foundation-models" | "chat-model"; export type FoundationModelsConnectionState = | "ready" @@ -531,6 +531,8 @@ export interface UsageTokenBreakdown { output: number; cacheRead: number; cacheWrite: number; + /** Anthropic's one-hour cache writes, already included in cacheWrite. */ + cacheWrite1h?: number; reasoning: number; total: number; } diff --git a/main/services/usage-accounting.ts b/main/services/usage-accounting.ts index ddf117ed..d4e2889b 100644 --- a/main/services/usage-accounting.ts +++ b/main/services/usage-accounting.ts @@ -1,4 +1,9 @@ -import type { Api, AssistantMessage, Model, Usage } from "@earendil-works/pi-ai"; +import type { + Api, + AssistantMessage, + Model, + Usage, +} from "@earendil-works/pi-ai"; import { isLocalProviderDeployment } from "../../renderer/shared/provider-deployment.js"; import type { StoredProvider, UsageTokenBreakdown } from "./types.js"; import type { @@ -8,10 +13,14 @@ import type { } from "./usage-store-core.js"; function positiveInteger(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 0; } -export function openAITranscriptionTokens(value: unknown): UsageTokenBreakdown | null { +export function openAITranscriptionTokens( + value: unknown, +): UsageTokenBreakdown | null { if (!value || typeof value !== "object") return null; const usage = value as Record; if (usage.type === "duration") return null; @@ -22,7 +31,9 @@ export function openAITranscriptionTokens(value: unknown): UsageTokenBreakdown | return { input, output, cacheRead: 0, cacheWrite: 0, reasoning: 0, total }; } -export function geminiTranscriptionTokens(value: unknown): UsageTokenBreakdown | null { +export function geminiTranscriptionTokens( + value: unknown, +): UsageTokenBreakdown | null { if (!value || typeof value !== "object") return null; const usage = value as Record; // Gemini's prompt count already includes cached content. Keep the same @@ -33,7 +44,8 @@ export function geminiTranscriptionTokens(value: unknown): UsageTokenBreakdown | const reasoning = positiveInteger(usage.thoughtsTokenCount); const output = positiveInteger(usage.candidatesTokenCount) + reasoning; const total = positiveInteger(usage.totalTokenCount) || prompt + output; - if (total === 0 && prompt === 0 && output === 0 && cacheRead === 0) return null; + if (total === 0 && prompt === 0 && output === 0 && cacheRead === 0) + return null; return { input, output, cacheRead, cacheWrite: 0, reasoning, total }; } @@ -41,11 +53,13 @@ export function reportedTokens( usage: Partial | null | undefined, ): UsageTokenBreakdown | null { if (!usage) return null; + const cacheWrite1h = positiveInteger(usage.cacheWrite1h); const tokens: UsageTokenBreakdown = { input: positiveInteger(usage.input), output: positiveInteger(usage.output), cacheRead: positiveInteger(usage.cacheRead), cacheWrite: positiveInteger(usage.cacheWrite), + ...(cacheWrite1h > 0 ? { cacheWrite1h } : {}), reasoning: positiveInteger(usage.reasoning), total: positiveInteger(usage.totalTokens), }; @@ -58,13 +72,17 @@ export function reportedTokens( tokens.reasoning > 0; if (!reported) return null; if (tokens.total === 0) { - tokens.total = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite; + tokens.total = + tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite; } return tokens; } export function isLocalModelProvider( - provider: Pick, + provider: Pick< + StoredProvider, + "id" | "label" | "baseUrl" | "needsKey" | "deployment" + >, ): boolean { return isLocalProviderDeployment(provider); } @@ -72,14 +90,16 @@ export function isLocalModelProvider( function modelHasPricing(model: Model): boolean { return [model.cost, ...(model.cost.tiers ?? [])].some((rates) => [rates.input, rates.output, rates.cacheRead, rates.cacheWrite].some( - (value) => typeof value === "number" && Number.isFinite(value) && value > 0, + (value) => + typeof value === "number" && Number.isFinite(value) && value > 0, ), ); } function statusFor(message: AssistantMessage): UsageRequestStatus { if (message.stopReason === "aborted") return "cancelled"; - if (message.stopReason === "error") return "failed"; + if (message.stopReason === "error" || message.stopReason === "length") + return "failed"; return "completed"; } diff --git a/main/services/usage-store-core.test.ts b/main/services/usage-store-core.test.ts index fbfc264e..a97e9d2c 100644 --- a/main/services/usage-store-core.test.ts +++ b/main/services/usage-store-core.test.ts @@ -34,7 +34,8 @@ function memoryPersistence(): UsagePersistence & { read(): UsageDatabase } { const NOW = new Date(2026, 6, 21, 12).getTime(); function record( - patch: Partial & Pick, + patch: Partial & + Pick, ): UsageRequestRecord { return { timestamp: NOW, @@ -57,7 +58,14 @@ test("aggregates reported tokens while keeping unmetered and local requests visi providerId: "openai", providerLabel: "OpenAI", modelId: "gpt-test", - tokens: { input: 80, output: 20, cacheRead: 10, cacheWrite: 0, reasoning: 5, total: 110 }, + tokens: { + input: 80, + output: 20, + cacheRead: 10, + cacheWrite: 0, + reasoning: 5, + total: 110, + }, costStatus: "reported", costUsd: 0.025, }), @@ -76,7 +84,14 @@ test("aggregates reported tokens while keeping unmetered and local requests visi providerId: "anthropic", providerLabel: "Anthropic", modelId: "claude-test", - tokens: { input: 40, output: 10, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 50 }, + tokens: { + input: 40, + output: 10, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 50, + }, }), ); @@ -93,6 +108,7 @@ test("aggregates reported tokens while keeping unmetered and local requests visi output: 30, cacheRead: 10, cacheWrite: 0, + cacheWrite1h: 0, reasoning: 5, total: 160, }); @@ -100,7 +116,10 @@ test("aggregates reported tokens while keeping unmetered and local requests visi summary.models.map((model) => model.modelId), ["gpt-test", "claude-test", "qwen-local"], ); - assert.equal(summary.models.find((model) => model.local)?.unmeteredRequests, 1); + assert.equal( + summary.models.find((model) => model.local)?.unmeteredRequests, + 1, + ); }); test("persists subagent requests as a first-class privacy-safe usage source", async () => { @@ -185,12 +204,42 @@ test("persists scheduled model calls as a separate privacy-safe usage source", a test("normalizes provider usage without inventing unavailable token counts or local cost", () => { assert.equal( - reportedTokens({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }), + reportedTokens({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + }), null, ); assert.deepEqual( - reportedTokens({ input: 4, output: 2, cacheRead: 1, cacheWrite: 0, reasoning: 1 }), - { input: 4, output: 2, cacheRead: 1, cacheWrite: 0, reasoning: 1, total: 7 }, + reportedTokens({ + input: 4, + output: 2, + cacheRead: 1, + cacheWrite: 0, + reasoning: 1, + }), + { + input: 4, + output: 2, + cacheRead: 1, + cacheWrite: 0, + reasoning: 1, + total: 7, + }, + ); + assert.equal( + reportedTokens({ + input: 4, + output: 2, + cacheRead: 0, + cacheWrite: 3, + cacheWrite1h: 2, + totalTokens: 9, + })?.cacheWrite1h, + 2, ); assert.equal( isLocalModelProvider({ @@ -303,9 +352,19 @@ test("maps exact OpenAI and Gemini transcription usage without double-counting c output_tokens: 31, total_tokens: 45, }), - { input: 14, output: 31, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 45 }, + { + input: 14, + output: 31, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 45, + }, + ); + assert.equal( + openAITranscriptionTokens({ type: "duration", seconds: 20 }), + null, ); - assert.equal(openAITranscriptionTokens({ type: "duration", seconds: 20 }), null); assert.deepEqual( geminiTranscriptionTokens({ promptTokenCount: 100, @@ -314,7 +373,14 @@ test("maps exact OpenAI and Gemini transcription usage without double-counting c thoughtsTokenCount: 10, totalTokenCount: 130, }), - { input: 70, output: 30, cacheRead: 30, cacheWrite: 0, reasoning: 10, total: 130 }, + { + input: 70, + output: 30, + cacheRead: 30, + cacheWrite: 0, + reasoning: 10, + total: 130, + }, ); }); @@ -360,7 +426,9 @@ test("counts every assistant turn outcome, including tool loops, failures, and a stopReason, timestamp: NOW, }; - await store.record(assistantUsageRecord({ message, provider, model, source: "chat" })); + await store.record( + assistantUsageRecord({ message, provider, model, source: "chat" }), + ); } const summary = await store.summary("7d"); diff --git a/main/services/usage-store-core.ts b/main/services/usage-store-core.ts index 97e15710..736ead24 100644 --- a/main/services/usage-store-core.ts +++ b/main/services/usage-store-core.ts @@ -10,7 +10,8 @@ export type UsageRequestSource = | "chat-title" | "voice-transcription" | "scheduled" - | "subagent"; + | "subagent" + | "compaction"; export type UsageRequestStatus = "completed" | "failed" | "cancelled"; export type UsageCostStatus = "reported" | "unavailable" | "not-applicable"; @@ -75,10 +76,19 @@ const REQUEST_SOURCES = new Set([ "voice-transcription", "scheduled", "subagent", + "compaction", ]); export function emptyUsageTokens(): UsageTokenBreakdown { - return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 }; + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cacheWrite1h: 0, + reasoning: 0, + total: 0, + }; } export function createEmptyUsageDatabase(): UsageDatabase { @@ -86,7 +96,9 @@ export function createEmptyUsageDatabase(): UsageDatabase { } function nonNegative(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : 0; } function nonNegativeInteger(value: unknown): number { @@ -94,30 +106,41 @@ function nonNegativeInteger(value: unknown): number { } function normalizeTokens(value: unknown): UsageTokenBreakdown { - const tokens = value && typeof value === "object" ? (value as Record) : {}; + const tokens = + value && typeof value === "object" + ? (value as Record) + : {}; return { input: nonNegativeInteger(tokens.input), output: nonNegativeInteger(tokens.output), cacheRead: nonNegativeInteger(tokens.cacheRead), cacheWrite: nonNegativeInteger(tokens.cacheWrite), + cacheWrite1h: nonNegativeInteger(tokens.cacheWrite1h), reasoning: nonNegativeInteger(tokens.reasoning), total: nonNegativeInteger(tokens.total), }; } -function addTokens(target: UsageTokenBreakdown, value: UsageTokenBreakdown): void { +function addTokens( + target: UsageTokenBreakdown, + value: UsageTokenBreakdown, +): void { target.input += value.input; target.output += value.output; target.cacheRead += value.cacheRead; target.cacheWrite += value.cacheWrite; + target.cacheWrite1h = (target.cacheWrite1h ?? 0) + (value.cacheWrite1h ?? 0); target.reasoning += value.reasoning; target.total += value.total; } function isDateKey(value: unknown): value is string { - if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) return false; + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) + return false; const date = new Date(`${value}T00:00:00.000Z`); - return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; + return ( + Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value + ); } function stringOr(value: unknown, fallback: string): string { @@ -127,7 +150,10 @@ function stringOr(value: unknown, fallback: string): string { function normalizeBucket(value: unknown): DailyUsageBucket | null { if (!value || typeof value !== "object") return null; const bucket = value as Record; - if (!isDateKey(bucket.date) || !REQUEST_SOURCES.has(bucket.source as UsageRequestSource)) { + if ( + !isDateKey(bucket.date) || + !REQUEST_SOURCES.has(bucket.source as UsageRequestSource) + ) { return null; } const local = bucket.local === true; @@ -135,9 +161,15 @@ function normalizeBucket(value: unknown): DailyUsageBucket | null { date: bucket.date, source: bucket.source as UsageRequestSource, providerId: stringOr(bucket.providerId, "unknown"), - providerLabel: stringOr(bucket.providerLabel, stringOr(bucket.providerId, "Unknown")), + providerLabel: stringOr( + bucket.providerLabel, + stringOr(bucket.providerId, "Unknown"), + ), modelId: stringOr(bucket.modelId, "unknown"), - modelLabel: stringOr(bucket.modelLabel, stringOr(bucket.modelId, "Unknown model")), + modelLabel: stringOr( + bucket.modelLabel, + stringOr(bucket.modelId, "Unknown model"), + ), local, requests: nonNegativeInteger(bucket.requests), completedRequests: nonNegativeInteger(bucket.completedRequests), @@ -146,7 +178,9 @@ function normalizeBucket(value: unknown): DailyUsageBucket | null { reportedTokenRequests: nonNegativeInteger(bucket.reportedTokenRequests), unmeteredRequests: nonNegativeInteger(bucket.unmeteredRequests), costedRequests: local ? 0 : nonNegativeInteger(bucket.costedRequests), - unpricedHostedRequests: local ? 0 : nonNegativeInteger(bucket.unpricedHostedRequests), + unpricedHostedRequests: local + ? 0 + : nonNegativeInteger(bucket.unpricedHostedRequests), tokens: normalizeTokens(bucket.tokens), hostedCostUsd: local ? 0 : nonNegative(bucket.hostedCostUsd), }; @@ -185,7 +219,10 @@ function dateOrdinal(value: string): number { } function bucketKey( - bucket: Pick, + bucket: Pick< + DailyUsageBucket, + "date" | "source" | "providerId" | "modelId" | "local" + >, ): string { return JSON.stringify([ bucket.date, @@ -196,18 +233,28 @@ function bucketKey( ]); } -function modelKey(bucket: Pick): string { - return JSON.stringify([bucket.providerId, bucket.modelId, bucket.local ? "local" : "hosted"]); +function modelKey( + bucket: Pick, +): string { + return JSON.stringify([ + bucket.providerId, + bucket.modelId, + bucket.local ? "local" : "hosted", + ]); } -function streaks(activeDates: string[], endDate: string): { current: number; longest: number } { +function streaks( + activeDates: string[], + endDate: string, +): { current: number; longest: number } { const ordered = [...new Set(activeDates)].sort(); if (ordered.length === 0) return { current: 0, longest: 0 }; let longest = 1; let run = 1; for (let index = 1; index < ordered.length; index += 1) { - if (dateOrdinal(ordered[index]!) - dateOrdinal(ordered[index - 1]!) === 1) run += 1; + if (dateOrdinal(ordered[index]!) - dateOrdinal(ordered[index - 1]!) === 1) + run += 1; else run = 1; longest = Math.max(longest, run); } @@ -233,11 +280,14 @@ function summaryFromDatabase( ): UsageSummary { const endDate = localDateKey(now); const earliest = database.buckets.reduce( - (value, bucket) => (value === null || bucket.date < value ? bucket.date : value), + (value, bucket) => + value === null || bucket.date < value ? bucket.date : value, null, ); const startDate = - range === "all" ? (earliest ?? endDate) : shiftDateKey(endDate, -(RANGE_DAYS[range] - 1)); + range === "all" + ? (earliest ?? endDate) + : shiftDateKey(endDate, -(RANGE_DAYS[range] - 1)); const buckets = database.buckets.filter( (bucket) => bucket.date >= startDate && bucket.date <= endDate, ); @@ -312,7 +362,9 @@ function summaryFromDatabase( models.set(key, model); } - const days = [...dayMap.values()].sort((left, right) => left.date.localeCompare(right.date)); + const days = [...dayMap.values()].sort((left, right) => + left.date.localeCompare(right.date), + ); const streak = streaks( days.filter((day) => day.requests > 0).map((day) => day.date), endDate, @@ -361,20 +413,29 @@ export function createUsageStore( const task = mutationQueue.then(async () => { const database = await loadDatabase(); const timestamp = - typeof record.timestamp === "number" && Number.isFinite(record.timestamp) + typeof record.timestamp === "number" && + Number.isFinite(record.timestamp) ? record.timestamp : now(); const descriptor = { date: localDateKey(timestamp), source: record.source, providerId: stringOr(record.providerId, "unknown"), - providerLabel: stringOr(record.providerLabel, stringOr(record.providerId, "Unknown")), + providerLabel: stringOr( + record.providerLabel, + stringOr(record.providerId, "Unknown"), + ), modelId: stringOr(record.modelId, "unknown"), - modelLabel: stringOr(record.modelLabel, stringOr(record.modelId, "Unknown model")), + modelLabel: stringOr( + record.modelLabel, + stringOr(record.modelId, "Unknown model"), + ), local: record.local, }; const key = bucketKey(descriptor); - let bucket = database.buckets.find((candidate) => bucketKey(candidate) === key); + let bucket = database.buckets.find( + (candidate) => bucketKey(candidate) === key, + ); if (!bucket) { bucket = { ...descriptor, diff --git a/main/services/visible-chat-projection.ts b/main/services/visible-chat-projection.ts index 2ba6b426..fa62e731 100644 --- a/main/services/visible-chat-projection.ts +++ b/main/services/visible-chat-projection.ts @@ -1,4 +1,4 @@ -import type { Attachment } from "./types.js"; +import type { Attachment, Chat } from "./types.js"; import type { SkillProvenanceV1 } from "../../renderer/shared/slash-commands.js"; import { safeStoredAttachments } from "./attachment-contract.js"; import { parseSkillProvenanceV1 } from "../../renderer/shared/slash-commands.js"; @@ -27,6 +27,18 @@ export interface VisibleChatMessage { skill?: SkillProvenanceV1; } +/** Strip private provider protocol before a Chat crosses into the renderer. */ +export function chatForRenderer(chat: Chat | null): Chat | null { + if (!chat) return null; + return { + ...chat, + messages: chat.messages.map((message) => { + const { pi: _privatePiProtocol, ...visible } = message; + return visible; + }), + }; +} + function boundedString( value: unknown, label: string, diff --git a/package.json b/package.json index c03b6110..c41e4315 100644 --- a/package.json +++ b/package.json @@ -66,13 +66,13 @@ "test:subagents": "tsx --test main/services/subagents/agent-compatibility.test.ts main/services/subagents/approval-v2.test.ts main/services/subagents/authority-v2.test.ts main/services/subagents/network-budget-v2.test.ts main/services/subagents/outbound-approval-v2.test.ts main/services/subagents/request-capabilities-v2.test.ts main/services/subagents/subagent-child-runtime.test.ts main/services/subagents/capability-profile.test.ts main/services/subagents/forked-context.test.ts main/services/subagents/forked-context-supervisor.test.ts main/services/subagents/management-v2.test.ts main/services/subagents/subagent-control-ipc-core.test.ts main/services/subagents/subagent-control-main.test.ts main/services/subagents/subagent-control-v2.test.ts main/services/subagents/subagent-event-projector.test.ts main/services/subagents/subagent-file-mutation-core.test.ts main/services/subagents/subagent-file-mutator-io.test.ts main/services/subagents/subagent-foreground-persistence-v2.test.ts main/services/subagents/subagent-health-metrics-core.test.ts main/services/subagents/subagent-history-read-core.test.ts main/services/subagents/subagent-identifier-privacy.test.ts main/services/subagents/subagent-nesting-core.test.ts main/services/subagents/subagent-phase3-contract.test.ts main/services/subagents/subagent-mcp-client-core.test.ts main/services/subagents/subagent-mcp-credential-core.test.ts main/services/subagents/subagent-mcp-inventory-core.test.ts main/services/subagents/subagent-mcp-bounded-fetch.test.ts main/services/subagents/subagent-mcp-read.test.ts main/services/subagents/subagent-run-store-core.test.ts main/services/subagents/subagent-run-store-dispatcher.test.ts main/services/subagents/subagent-run-store-v2-core.test.ts main/services/subagents/subagent-run-store-v2-migration.test.ts main/services/subagents/subagent-run-store-production.test.ts main/services/subagents/subagent-supervisor.test.ts main/services/subagents/subagent-tool-schema.test.ts main/services/subagents/subagent-web-proxy.test.ts main/services/chat-deletion-gate.test.ts main/services/chat-generation-start.test.ts main/services/chat-workspace-authority.test.ts main/services/chat-workspace-mutation-gate.test.ts main/services/managed-worktree-admission.test.ts main/services/managed-worktree-creation-core.test.ts main/services/managed-worktree-deletion-recovery.test.ts main/services/managed-worktree-removal-core.test.ts main/services/managed-worktree-remover.test.ts main/services/workspace-mutation-gate.test.ts main/services/workspace-operation-registry.test.ts main/services/workspace-record-removal.test.ts main/services/workspace-schedule-restoration.test.ts main/services/terminal.test.ts renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/subagent-tree.test.ts renderer/shared/subagent-management-v2.test.ts renderer/shared/subagent-runs-v2.test.ts && node --test scripts/subagent-run-store.test.mjs && npm run test:subagent-file-mutator:native", "test:subagents:soak:contracts": "tsx --test main/services/subagents/subagent-packaged-soak-core.test.ts main/services/subagents/subagent-packaged-soak-main.test.ts && node --test scripts/subagent-packaged-soak.test.mjs", "test:subagents:packaged": "node scripts/subagent-packaged-soak.mjs", - "test:assistant-automations": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/automation-runtime-contract.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/project-tool.test.ts main/services/assistant/system-prompt.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-store.test.ts main/services/schedule-tool.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/lib/scheduled-mcp-access-contract.test.ts renderer/shared/assistant.test.ts", + "test:assistant-automations": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/automation-runtime-contract.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/project-tool.test.ts main/services/assistant/system-prompt.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-service-core.test.ts main/services/schedule-store.test.ts main/services/schedule-tool.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/lib/scheduled-mcp-access-contract.test.ts renderer/shared/assistant.test.ts", "test:onboarding": "tsx --test main/services/onboarding-reset-core.test.ts main/services/onboarding-reset-lifecycle.test.ts renderer/components/onboarding-flow.test.tsx renderer/lib/onboarding-state.test.ts", "test:e2e": "npm run type-check:e2e && npm run build && playwright test --config=playwright.config.ts --fail-on-flaky-tests", "test:e2e:list": "playwright test --config=playwright.config.ts --list", "test:e2e:live:lmstudio": "npm run type-check:e2e && npm run build && AIDEN_E2E_LIVE_LMSTUDIO=1 playwright test --config=playwright.config.ts", "test:terminal:coverage": "tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal-spawn-helper.ts --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 main/services/terminal.test.ts && tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal.ts --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-functions=90 main/services/terminal.test.ts", - "test:compaction": "tsx --test main/services/pi-compaction-core.test.ts", + "test:compaction": "tsx --test main/services/pi-compaction-core.test.ts main/services/generation-context.test.ts main/services/subagents/agent-compatibility.test.ts renderer/lib/ipc-stream.test.ts renderer/main/chat-transition.test.tsx", "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:worktree-remover:native && npm run test:computer-use:native", "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", diff --git a/renderer/components/assistant/use-assistant-chat.ts b/renderer/components/assistant/use-assistant-chat.ts index eb90dadd..53edee8c 100644 --- a/renderer/components/assistant/use-assistant-chat.ts +++ b/renderer/components/assistant/use-assistant-chat.ts @@ -519,6 +519,19 @@ export function useAssistantChat(): AssistantChat { if (!isCurrent()) return; const callbacks: StreamCallbacks = { onDelta: appendDelta, + onReset: () => { + if (!isCurrent() || stoppingTurnRef.current === turn) return; + clearPendingDelta(); + setMessages((existing) => { + const next = [...existing]; + const last = next[next.length - 1]; + if (last?.role === "assistant") { + next[next.length - 1] = { role: "assistant", content: "" }; + } + return next; + }); + setStreamComplete(false); + }, onDone: (fullContent) => { if (!isCurrent()) return; clearPendingDelta(); diff --git a/renderer/lib/ipc-stream.test.ts b/renderer/lib/ipc-stream.test.ts index d118d3d7..f104eba4 100644 --- a/renderer/lib/ipc-stream.test.ts +++ b/renderer/lib/ipc-stream.test.ts @@ -148,7 +148,11 @@ test("generation exposes a non-rejecting authoritative start result", async () = test("a post-handoff setup failure reports the error but keeps the committed send accepted", async () => { const failure = installFakeBridge({ - startResponse: { accepted: true, started: false, error: "Provider setup failed." }, + startResponse: { + accepted: true, + started: false, + error: "Provider setup failed.", + }, }); const errors: string[] = []; try { @@ -214,6 +218,43 @@ test("user Stop retains terminal delivery before releasing subscriptions", () => } }); +test("overflow retry reset is routed separately from text deltas", () => { + const { bridge, restore } = installFakeBridge(); + const deltas: string[] = []; + let resets = 0; + try { + const handle = startGeneration( + { + chatId: "chat-reset", + workspaceId: "workspace-1", + providerId: "provider-1", + model: "model-1", + }, + { + ...callbacks(), + onDelta: (delta) => deltas.push(delta), + onReset: () => { + resets += 1; + }, + }, + "turn-reset", + ); + + for (const handler of bridge.listeners.get("chat:delta") ?? []) { + handler({ streamId: "other", delta: "ignored" }); + handler({ streamId: handle.streamId, delta: "failed-attempt" }); + handler({ streamId: handle.streamId, delta: "", reset: true }); + handler({ streamId: handle.streamId, delta: "retry" }); + } + + assert.deepEqual(deltas, ["failed-attempt", "retry"]); + assert.equal(resets, 1); + handle.cancel("lifecycle"); + } finally { + restore(); + } +}); + test("live subagent notifications are subscribed only for enabled callbacks", () => { const { bridge, restore } = installFakeBridge(); try { diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 3c4d9800..7d39b7d5 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -651,6 +651,8 @@ export const subagentsApi = { interface ChatDelta { streamId: string; delta: string; + /** Discard deltas from a failed overflow attempt before its retry starts. */ + reset?: boolean; } interface ChatReasoningDelta { streamId: string; @@ -709,6 +711,7 @@ export type GenerationStartResult = { ok: true } | { ok: false; error: Error }; export interface StreamCallbacks { onDelta: (delta: string) => void; + onReset?: () => void; onReasoningDelta?: (delta: string) => void; onDone: ( fullContent: string, @@ -753,7 +756,9 @@ export function startGeneration( unsubs.push( onNotification("chat:delta", (p) => { - if (p.streamId === streamId) callbacks.onDelta(p.delta); + if (p.streamId !== streamId) return; + if (p.reset) callbacks.onReset?.(); + else callbacks.onDelta(p.delta); }), ); unsubs.push( diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx index 8c5f9625..8da2b775 100644 --- a/renderer/main/chat-pane.tsx +++ b/renderer/main/chat-pane.tsx @@ -549,6 +549,20 @@ export function ChatPane({ chatId }: { chatId: string }) { scheduleStreamFlush(); } }, + onReset: () => { + if (!mountedRef.current || generationIntentRef.current !== generationIntent) return; + if (deltaFrameRef.current !== null) { + window.cancelAnimationFrame(deltaFrameRef.current); + } + deltaFrameRef.current = null; + pendingDeltaRef.current = ""; + pendingReasoningDeltaRef.current = ""; + streamedTextRef.current = ""; + streamedReasoningRef.current = ""; + setStreamingText(""); + setStreamingReasoning(null); + setStreamComplete(false); + }, onReasoningDelta: (delta) => { if (mountedRef.current && generationIntentRef.current === generationIntent) { setIsModelLoading(false); @@ -610,12 +624,12 @@ export function ChatPane({ chatId }: { chatId: string }) { generationTimelineRef.current = finalTimeline; setGenerationTimeline(finalTimeline); } - await waitForStreamHandoff(Boolean(full.trim())); - if (generationIntentRef.current !== generationIntent) return; if (updatedChat) { qc.setQueryData(queryKeys.chat(chatId), updatedChat); void qc.invalidateQueries({ queryKey: queryKeys.chats }); } + await waitForStreamHandoff(Boolean(full.trim())); + if (generationIntentRef.current !== generationIntent) return; if (mountedRef.current) { setLiveSubagents([]); setStreamingText(null); @@ -658,16 +672,16 @@ export function ChatPane({ chatId }: { chatId: string }) { void refreshCodexProviderState(qc); } const partial = resolvedPartialContent.trim(); + if (updatedChat) { + qc.setQueryData(queryKeys.chat(chatId), updatedChat); + void qc.invalidateQueries({ queryKey: queryKeys.chats }); + } if (partial) { setStreamingText(resolvedPartialContent); setStreamComplete(true); await waitForStreamHandoff(true); if (generationIntentRef.current !== generationIntent) return; } - if (updatedChat) { - qc.setQueryData(queryKeys.chat(chatId), updatedChat); - void qc.invalidateQueries({ queryKey: queryKeys.chats }); - } if (mountedRef.current) { if (updatedChat || !partial) setLiveSubagents([]); if (!partial || updatedChat) { @@ -734,7 +748,13 @@ export function ChatPane({ chatId }: { chatId: string }) { content: text, attachments: attachments.length ? attachments : undefined, }, - { providerId, model, autoTitle: true, turnId: messageTurnId, skillInvocation }, + { + providerId, + model, + autoTitle: true, + turnId: messageTurnId, + skillInvocation, + }, ); } catch (appendError) { if (isAppendReconciliationRequired(appendError)) { @@ -862,7 +882,9 @@ export function ChatPane({ chatId }: { chatId: string }) { await workspacesApi.update(effectiveWorkspace.id, { permission }); await Promise.all([ qc.invalidateQueries({ queryKey: queryKeys.workspaces }), - qc.invalidateQueries({ queryKey: queryKeys.skillCatalog(effectiveWorkspace.id) }), + qc.invalidateQueries({ + queryKey: queryKeys.skillCatalog(effectiveWorkspace.id), + }), ]); }, [ diff --git a/renderer/main/chat-transition.test.tsx b/renderer/main/chat-transition.test.tsx index fd895a92..8590f49b 100644 --- a/renderer/main/chat-transition.test.tsx +++ b/renderer/main/chat-transition.test.tsx @@ -134,6 +134,25 @@ test("a committed append is not presented as unsent when generation start later assert.doesNotMatch(handleSend, /if \(!started\.ok\) throw/u); }); +test("terminal chat snapshots reach cache before visual stream handoff awaits", () => { + const pane = source("./chat-pane.tsx"); + const generation = between( + pane, + "const runGeneration = React.useCallback(", + "const handleSend = React.useCallback(", + ); + const done = between(generation, "onDone: async", "onError:"); + assert.ok( + done.indexOf("qc.setQueryData(queryKeys.chat(chatId), updatedChat)") < + done.indexOf("await waitForStreamHandoff"), + ); + const error = generation.slice(generation.indexOf("onError:")); + assert.ok( + error.indexOf("qc.setQueryData(queryKeys.chat(chatId), updatedChat)") < + error.indexOf("await waitForStreamHandoff"), + ); +}); + test("an indeterminate append blocks retries until an application reload reconciles storage", () => { const pane = source("./chat-pane.tsx"); assert.match(pane, /isAppendReconciliationRequired\(appendError\)/u); @@ -172,8 +191,14 @@ test("append reconciliation is surfaced across route remounts and chat creation ); const scratchMutation = pane.indexOf("workspacesApi.createScratch(", scratchGuard); assert.ok(scratchGuard >= 0 && scratchMutation > scratchGuard); - assert.match(pane, /workspaceChangeBlockedReason=\{[\s\S]{0,180}documentAppendReconciliationRequired/u); - assert.match(pane, /documentAppendReconciliationRequired \|\| appendReconciliationRequiredChats/u); + assert.match( + pane, + /workspaceChangeBlockedReason=\{[\s\S]{0,180}documentAppendReconciliationRequired/u, + ); + assert.match( + pane, + /documentAppendReconciliationRequired \|\| appendReconciliationRequiredChats/u, + ); }); test("composer stays keyed so drafts and attachments do not leak between chats", () => {