mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(sessions): remove file-era transcript runtime (#113233)
* refactor(sessions): keep helper transcripts in memory * refactor(sessions): remove file-era transcript storage * test(sessions): use SQLite identity in attempt persistence * test(codex): isolate legacy transcript fixtures * fix(sessions): preserve SQLite transcript identity * fix(sessions): harden transcript lifecycle invariants * fix(sessions): validate transcript identities * fix(sessions): close identity compatibility gaps * fix(sessions): preserve leaf and plugin identities * fix(sessions): retain dispatch transcript targets * fix(sessions): preserve active transcript context * fix(sessions): isolate artifact accounting * fix(sessions): bound SQLite usage accounting * fix(sessions): retain bounded latest usage * fix(sessions): align rebased transcript targets * test(sessions): align accessor scope fixture * fix(telegram): derive SQLite transcript identity * refactor(sessions): remove file-era compaction residue * chore(sessions): lower max-lines baseline * fix(sessions): preserve structured transcript identity * test(sessions): align doctor identity assertions * fix(sessions): isolate default SDK database * refactor(sessions): remove dead file-era exports * fix(sessions): reconcile SQLite transcript identity * fix(sessions): pass checkpoint identity explicitly * test(sessions): make entry field probe explicit * test(sessions): satisfy transcript cleanup lint * test(sessions): align diagnostics identity proof * fix(sessions): finish transcript runtime teardown * fix(sessions): preserve transcript identity invariants * fix(sessions): harden transcript compatibility edges * fix(sessions): preserve checkpoint transcript anchors * fix(sessions): preserve SQLite lifecycle invariants * fix(sessions): retarget compaction successors * test(sessions): preserve transcript fixture semantics * feat(plugin-sdk): add command transcript targets * fix(sessions): serialize transcript rewrites * fix(sessions): validate legacy successor identity * fix(sessions): normalize compaction ownership * fix(sessions): validate successor identity before adoption * fix(sessions): preserve plugin transcript ownership * fix(sessions): carry transcript identity through commands * fix(sessions): import legacy checkpoint artifacts into SQLite * fix(sessions): preserve successor transcript ownership * fix(sessions): align transcript consumers with target identity * fix(sessions): scope transcript token estimates * fix(sessions): retain agent identity across lifecycle hooks * fix(sessions): resolve scoped SQLite targets * fix(sessions): isolate lifecycle transcript targets * fix(sessions): validate compaction agent ownership * fix(sessions): preserve reset and cleanup lifecycle * fix(sessions): serialize prompt cleanup lifecycle * fix(sessions): remove stale lock import * fix(sessions): preserve reset target context * fix(sessions): fence prompt reload takeover * fix(sessions): unblock abort and default lifecycle reads * fix(sessions): validate legacy successor scope * fix(sessions): reject metadata-only runtime rows * fix(sessions): propagate custom transcript stores * fix(sessions): preserve adopted retry targets * fix(sessions): allow unkeyed usage reads * fix(sessions): harden runtime target boundaries * fix(sessions): serialize retry transcript writes * fix(sessions): bound prompt reload disposal * fix(sessions): complete retry marker identity * fix(sessions): keep legacy marker identity minimal * test(sessions): tighten teardown fixture types * fix(sessions): preserve compatibility target identity * test(sessions): persist post-checkpoint boundary turn * test(sessions): align runtime store mock contracts * style(sessions): simplify persisted identity guard * fix(sessions): prefer complete typed targets * fix(sessions): recover legacy marker targets * test(sessions): align marker lookup fixture scope * fix(sessions): validate partial transcript targets * fix(sessions): reconcile partial transcript identities * fix(sessions): canonicalize compatibility identities * test(sessions): cover compatibility aliases * fix(sessions): adopt legacy successor identity * fix(sessions): preserve usage read identity * fix(sessions): preserve partial marker compatibility * fix(sessions): validate legacy successor mappings * fix(sessions): reconcile marker store mappings * fix(sessions): preserve legacy fallback identity * fix(sessions): harden marker alias resolution * fix(sessions): prefer verified successor aliases * fix(sessions): resolve preferred marker aliases * fix(sessions): serialize cleanup admission * fix(sessions): align marker lookup scopes * fix(codex): type marker alias summaries * style(sessions): satisfy changed lint * test(sessions): align structured target assertions * fix(sessions): reconcile latest identity contracts * fix(sessions): validate transcript identity boundaries * docs(sessions): explain stable registry keys * fix(sessions): harden compatibility target round trips * fix(sessions): port usage identity to split modules * test(sessions): align subagent transcript identity * fix(sessions): finish transcript identity migration * fix(agents): route subagent completion capture through transcript targets * fix(agents): settle SQLite prompt handoff during cleanup * chore: shrink max-lines baseline after teardown * fix(sessions): port teardown across split runtime owners * fix(sessions): carry transcript targets through split owners * test(agents): use SQLite compaction target in abort coverage * chore: retain unrelated max-lines suppressions * chore: shrink max-lines baseline after main splits * style(agents): const compaction checkpoint locals * fix(sessions): harden SQLite teardown boundaries * test(sessions): use typed metadata in predicate isolation fixture * test(agents): cover malformed settlement rejections lint-safely * fix(sessions): close remaining SQLite identity races * fix(agents): fail closed on incomplete successor targets * fix(sessions): preserve transcript identity fallbacks * fix(agents): preserve session-key abort admission * fix(trajectory): validate incomplete export targets * test(sessions): drop retired pricing cache imports * fix(sessions): validate partial transcript identities * fix(sessions): close transcript identity edge cases * fix(plugins): reserve retired transcript locator slot * fix(sessions): scope transcript locks by target * style(sessions): simplify SDK initialization error * fix(sessions): preserve initialized transcript state * fix(codex): verify mirrored history session keys * fix(sessions): reject stale transcript ownership * fix(sessions): anchor asynchronous transcript ownership * fix(sessions): measure active transcript state * fix(sessions): preserve scoped transcript compaction * fix(sessions): harden transcript identity and lifecycle * fix(sessions): resolve scoped command transcript stores * fix(sessions): make transcript appends failure-atomic * fix(sessions): enforce scoped transcript ownership * fix(sessions): reject cross-owner transcript handoffs * fix(sessions): fence cleanup transcript ownership * fix(sessions): retire stale write ownership contexts * fix(sessions): preserve pending session migration state * fix(sessions): validate migrated transcript ownership * fix(sessions): validate usage transcript targets * fix(sessions): clear predecessor transcript metadata * fix(sessions): align durable session event targets * fix(sessions): fence late prompt handoffs * fix(sessions): fence lifecycle transcript fallbacks * fix(sessions): bound zero-length memory capture * fix(sessions): preserve transcript teardown ownership * fix(sessions): reject duplicate cleanup ownership * fix(sessions): serialize runtime writes with sqlite leases * fix(sessions): close sqlite teardown concurrency gaps * fix(sessions): preserve nested lifecycle failures * fix(sessions): canonicalize sqlite transcript ownership * fix(sessions): settle disposed prompt handoffs * fix(sessions): resolve canonical attempt lock targets * test(sessions): align canonical target fixtures * test(sessions): retire redundant jsonl parser coverage * refactor(sessions): split active transcript cursors * test(memory): retire legacy marker fixture * fix(sessions): preserve canonical transcript access after rebase * fix(sessions): fence prompt lease and return transcript targets * fix(sessions): colocate transcript leases with target store * fix(sessions): canonicalize transcript lease and worker targets * fix(sessions): preserve plugin and fork identity markers * fix(sessions): complete sqlite transcript target migration * fix(sessions): integrate canonical followup identity * fix(sessions): preserve bounded transcript topology * fix(sessions): validate transcript identity boundaries * fix(context): separate caller and successor targets * test(sessions): split persistence compatibility coverage * test(sessions): preserve fixture topology efficiently * chore(sdk): refresh plugin api baseline * test(agents): align compaction lock target mocks * test(sessions): seed malformed transcript fixtures directly * fix(agents): canonicalize transcript compatibility inputs * fix(agents): type optional tool result ids * test(ci): stabilize loaded process timing * test(tui): wait for collect queue admission
This commit is contained in:
committed by
GitHub
parent
6559e3f3b9
commit
4273ca9dbd
@@ -414,7 +414,6 @@ src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts
|
||||
src/agents/embedded-agent-runner/run.overflow-compaction.test.ts
|
||||
src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts
|
||||
src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts
|
||||
src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts
|
||||
src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts
|
||||
src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts
|
||||
src/agents/embedded-agent-runner/run/attempt.test.ts
|
||||
@@ -430,7 +429,6 @@ src/agents/embedded-agent-runner/tool-result-context-guard.test.ts
|
||||
src/agents/embedded-agent-runner/tool-result-truncation.test.ts
|
||||
src/agents/embedded-agent-runner/tool-result-truncation.ts
|
||||
src/agents/embedded-agent-runner/transcript-file-state.test.ts
|
||||
src/agents/embedded-agent-runner/transcript-file-state.ts
|
||||
src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts
|
||||
src/agents/embedded-agent-subscribe.handlers.messages.test.ts
|
||||
src/agents/embedded-agent-subscribe.handlers.messages.ts
|
||||
@@ -469,8 +467,6 @@ src/agents/provider-transport-fetch.test.ts
|
||||
src/agents/provider-transport-fetch.ts
|
||||
src/agents/runtime-plan/prepare-auth.test.ts
|
||||
src/agents/sandbox/ssh.ts
|
||||
src/agents/session-file-repair.test.ts
|
||||
src/agents/session-file-repair.ts
|
||||
src/agents/session-tool-result-guard.ts
|
||||
src/agents/session-transcript-repair.test.ts
|
||||
src/agents/session-write-lock.test.ts
|
||||
@@ -480,7 +476,6 @@ src/agents/sessions/extensions/types.ts
|
||||
src/agents/sessions/model-registry.ts
|
||||
src/agents/sessions/package-manager.ts
|
||||
src/agents/sessions/resource-loader.ts
|
||||
src/agents/sessions/session-manager.test.ts
|
||||
src/agents/sessions/settings-manager.ts
|
||||
src/agents/subagent-announce-delivery.test.ts
|
||||
src/agents/subagent-announce-delivery.ts
|
||||
|
||||
@@ -4,7 +4,7 @@ cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account
|
||||
8e985f345f21a1c9a2b0e94304aaaad6a326bec1c1ce3b26027d2862804a366e module/account-resolution
|
||||
e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives
|
||||
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
|
||||
060517a859a6354894bad581ca4a3e138b9a69c8cf8bae4656e0893c549df77b module/agent-harness-runtime
|
||||
23461d43c31d8c143e9d45aa91e27a22938d3549093d3d637b3bb88cf400c8b8 module/agent-harness-runtime
|
||||
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
|
||||
2dcb4d62d90e5d71594f6b843c97534509a154784e378fb1c75bd86b5122b710 module/agent-runtime
|
||||
56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from
|
||||
@@ -97,7 +97,7 @@ bebd2931dc51d67c063ff19fa1c278f8dcfe00ab23cfbd480d47329ea8e5088e module/meeting
|
||||
8d8c0c4ebfc6e0c6125df3ae64ec66c10cd797a325de5ea7869fcd84068ab290 module/persistent-dedupe
|
||||
987648ebe317cc4d6c0505a52d344b12ce9c3a8261a5bd969cee3423c0c53529 module/plugin-config-runtime
|
||||
61727825f684f3c82a22f4dc68d1f36d94b20fd1e53c24981263bc289850002f module/plugin-entry
|
||||
8097910898d08166f3e2e3676de5dac43f3442cfb1faa1b894ded353d973d09d module/plugin-runtime
|
||||
8bc85c9f7df434c87cd7d35cc33080e2577a1893353f92bff8a0cc63d2c4eb0e module/plugin-runtime
|
||||
9c9670596f6dee29ec7cdcb0616cefbcd50facbf71413bec4f7c63de8f4d5469 module/provider-auth
|
||||
71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime
|
||||
56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture
|
||||
@@ -119,7 +119,7 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime
|
||||
e576b537880f63b3a91f3608f7e84c873bce6c6a3d9a0ba98c247f46de788d25 module/secret-ref-runtime
|
||||
c81b9702c192d574413fc1df9a57c73128652a25de205eec55d6b47549349283 module/security-runtime
|
||||
31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion
|
||||
32fb6d253abf22440bc76c7a68d1f35fc0ef369b0ad738aedba9c3054a76e48e module/session-store-runtime
|
||||
f112bdabc51ba8659b37d0a6f6a32a2b1d471e5b49b56e108bf750ec55a7ea71 module/session-store-runtime
|
||||
36affbe151431a6141664b6838e20f2d121ff210d57a3c1b4b41a8818b5c81d8 module/setup
|
||||
21071e8c2ef020685aba09b5661e37e7415938ff6602053fa831ac9d58673248 module/setup-runtime
|
||||
cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools
|
||||
|
||||
@@ -578,11 +578,11 @@ promptOverride: "You are a memory search agent. Return NONE or one compact user
|
||||
|
||||
## Transcript persistence
|
||||
|
||||
Blocking sub-agent runs create a real `session.jsonl` transcript during the
|
||||
call. By default it is written to a temp directory and deleted immediately
|
||||
after the run finishes.
|
||||
Blocking sub-agent runs keep their runtime transcript in the agent's SQLite
|
||||
store. By default, OpenClaw removes the temporary sub-agent session rows after
|
||||
the run finishes and does not create a JSONL file.
|
||||
|
||||
To keep those transcripts on disk for debugging:
|
||||
To export those transcripts as JSONL artifacts for debugging:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -601,17 +601,17 @@ To keep those transcripts on disk for debugging:
|
||||
}
|
||||
```
|
||||
|
||||
Persisted transcripts go under the target agent's sessions folder, in a
|
||||
separate directory from the main user conversation transcript:
|
||||
Exported transcript artifacts go under the target agent's sessions folder, in
|
||||
a separate directory from active runtime state:
|
||||
|
||||
```text
|
||||
agents/<agent>/sessions/active-memory/<blocking-memory-sub-agent-session-id>.jsonl
|
||||
```
|
||||
|
||||
Change the relative subdirectory with `config.transcriptDir`. Use this
|
||||
carefully: transcripts can accumulate quickly on busy sessions, `full` query
|
||||
mode duplicates a lot of conversation context, and these transcripts contain
|
||||
hidden prompt context plus recalled memories.
|
||||
Change the relative artifact subdirectory with `config.transcriptDir`. Use this
|
||||
carefully: exports can accumulate quickly on busy sessions, `full` query mode
|
||||
duplicates a lot of conversation context, and these artifacts contain hidden
|
||||
prompt context plus recalled memories.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -636,8 +636,8 @@ All active memory configuration lives under `plugins.entries.active-memory`.
|
||||
| `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; range 0-30000 ms, default 0. See [Cold-start grace](#cold-start-grace) for v2026.4.x upgrade guidance |
|
||||
| `config.maxSummaryChars` | `number` | Maximum characters in the active-memory summary (range 40-1000; default 220) |
|
||||
| `config.logging` | `boolean` | Emits active memory logs while tuning |
|
||||
| `config.persistTranscripts` | `boolean` | Keeps blocking sub-agent transcripts on disk instead of deleting temp files |
|
||||
| `config.transcriptDir` | `string` | Relative blocking sub-agent transcript directory under the agent sessions folder (default `"active-memory"`) |
|
||||
| `config.persistTranscripts` | `boolean` | Exports blocking sub-agent transcripts as JSONL artifacts before removing their temporary SQLite session rows |
|
||||
| `config.transcriptDir` | `string` | Relative transcript-artifact directory under the agent sessions folder (default `"active-memory"`) |
|
||||
| `config.modelFallback` | `string` | Optional model used only as the last step in the [model fallback chain](#model-fallback-policy) |
|
||||
| `config.qmd.searchMode` | `"inherit" \| "search" \| "vsearch" \| "query"` | Overrides the QMD search mode used by the blocking sub-agent; default `"search"` (fast lexical search) — use `"inherit"` to match the main memory backend setting |
|
||||
|
||||
|
||||
@@ -27,9 +27,7 @@ execution, streaming, persistence.
|
||||
|
||||
Runs are serialized per session key (session lane) and optionally through a global lane, preventing tool/session races. Messaging channels choose a queue mode (steer/followup/collect/interrupt) that feeds this lane system; see [Command Queue](/concepts/queue).
|
||||
|
||||
Transcript writes are additionally protected by a session write lock on the session file. The lock is process-aware and file-based, so it catches writers that bypass the in-process queue or come from another process. Writers wait up to 60 seconds by default (env override `OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS`) before reporting the session as busy.
|
||||
|
||||
Session write locks are non-reentrant by default. A helper that intentionally nests acquisition of the same lock while preserving one logical writer must opt in with `allowReentrant: true`.
|
||||
Transcript writes are serialized by the per-session lane and the SQLite writer queue. Each append or rewrite validates the current session identity inside its synchronous commit transaction, so a stale run cannot overwrite a newer session generation.
|
||||
|
||||
## Session and workspace preparation
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Type `/compact` in any chat to force a compaction. Add instructions to guide the
|
||||
/compact Focus on the API design decisions
|
||||
```
|
||||
|
||||
When `agents.defaults.compaction.keepRecentTokens` is set (default: 20,000), manual compaction honors that cut-point and keeps the recent tail in rebuilt context. Without an explicit keep budget, manual compaction behaves as a hard checkpoint and continues from the new summary alone.
|
||||
Manual compaction uses `agents.defaults.compaction.keepRecentTokens` (default: 20,000) as its cut-point budget and keeps that recent tail in rebuilt context.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -120,10 +120,7 @@ checkpoint artifacts are not the active compaction target.
|
||||
|
||||
### Successor transcripts
|
||||
|
||||
When `agents.defaults.compaction.truncateAfterCompaction` is enabled, OpenClaw does not rewrite the existing transcript in place. It creates a new active successor transcript from the compaction summary, preserved state, and unsummarized tail, then records checkpoint metadata that points branch/restore flows at that compacted successor.
|
||||
Successor transcripts also drop exact duplicate long user turns that arrive
|
||||
inside a short retry window, so channel retry storms are not carried into the
|
||||
next active transcript after compaction.
|
||||
When `agents.defaults.compaction.truncateAfterCompaction` is enabled, a context engine may return an explicit compacted successor session identity. OpenClaw adopts that successor and records checkpoint metadata against it. The built-in SQLite compactor keeps the current session identity and does not create a second runtime transcript.
|
||||
|
||||
OpenClaw no longer writes separate `.checkpoint.*.jsonl` copies for new
|
||||
compactions. Existing legacy checkpoint files can still be used while referenced
|
||||
|
||||
@@ -11,7 +11,7 @@ OpenClaw serializes inbound auto-reply runs (all channels) through a tiny in-pro
|
||||
## Why
|
||||
|
||||
- Auto-reply runs can be expensive (LLM calls) and can collide when multiple inbound messages arrive close together.
|
||||
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
|
||||
- Serializing avoids competing for shared resources (session state, logs, CLI stdin) and reduces the chance of upstream rate limits.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -610,7 +610,7 @@ Delegated consults with a requesting agent keep that requester as their owner. W
|
||||
postIndexSync: "async", // off | async | await
|
||||
postCompactionSections: ["Session Startup", "Red Lines"],
|
||||
model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override
|
||||
truncateAfterCompaction: true, // rotate to a smaller successor JSONL after compaction
|
||||
truncateAfterCompaction: true, // adopt a context-engine successor when one is returned
|
||||
maxActiveTranscriptBytes: "20mb", // optional preflight local compaction trigger
|
||||
notifyUser: true, // notices when compaction starts/completes and on memory-flush degradation (default: false)
|
||||
memoryFlush: {
|
||||
@@ -630,7 +630,7 @@ Delegated consults with a requesting agent keep that requester as their owner. W
|
||||
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
|
||||
- `thinkingLevel`: optional thinking level used only for embedded OpenClaw compaction summaries (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `adaptive`, `max`, or `ultra`). It overrides the session's current thinking level and is clamped to the selected compaction model/runtime. Leave unset to inherit the session level. Native Codex app-server compaction ignores this setting because the native compact request has no per-operation thinking override; OpenClaw logs a warning when configured.
|
||||
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `180`.
|
||||
- `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint.
|
||||
- `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Default: `20000`.
|
||||
- `recentTurnsPreserve`: number of most recent user/assistant turns kept verbatim outside safeguard summarization. Default: `3`.
|
||||
- `identifierPolicy`: `strict` (default) or `off`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
|
||||
@@ -638,8 +638,8 @@ Delegated consults with a requesting agent keep that requester as their owner. W
|
||||
- `postIndexSync`: post-compaction session-memory reindex mode. Default: `"async"`. Use `"await"` for strongest freshness, `"async"` for lower compaction latency, or `"off"` only when session-memory sync is handled elsewhere.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Leave unset or use `[]` to disable.
|
||||
- `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
|
||||
- `truncateAfterCompaction`: rotates the active session transcript after compaction so future turns load only the summary and unsummarized tail, while the previous full transcript remains archived. Prevents unbounded active transcript growth in long-running sessions. Default: `false`.
|
||||
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when transcript history grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
|
||||
- `truncateAfterCompaction`: allows a context engine to advance the session to an explicit compacted successor identity. The built-in SQLite compactor keeps the current identity. Default: `false`.
|
||||
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when transcript history grows past the threshold. Requires `truncateAfterCompaction`; durable size reduction also requires a context engine that returns a successor identity. Disabled when unset or `0`.
|
||||
- `notifyUser`: when `true`, sends brief context-maintenance notices to the user: when compaction starts and completes (for example, "Compacting context..." and "Compaction complete"), and when a pre-compaction memory flush is exhausted so the reply continues in a degraded state (for example, "Memory maintenance temporarily failed; continuing your reply."). Disabled by default to keep these notices silent.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. `forceFlushTranscriptBytes` forces the flush when transcript size reaches the threshold even if token counters are stale. Skipped when workspace is read-only.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
title: "Session management deep dive"
|
||||
---
|
||||
|
||||
A single **Gateway process** owns session state end-to-end. UIs (macOS app, web Control UI, TUI) query the Gateway for session lists and token counts. In remote mode, session files live on the remote host, so checking your local Mac's files will not reflect what the Gateway is using.
|
||||
A single **Gateway process** owns session state end-to-end. UIs (macOS app, web Control UI, TUI) query the Gateway for session lists and token counts. In remote mode, the per-agent SQLite database lives on the remote host, so checking your local Mac's state will not reflect what the Gateway is using.
|
||||
|
||||
Overview docs first: [Session management](/concepts/session), [Compaction](/concepts/compaction), [Memory overview](/concepts/memory), [Memory search](/concepts/memory-search), [Session pruning](/concepts/session-pruning), [Transcript hygiene](/reference/transcript-hygiene), full config reference at [Agent config](/gateway/config-agents).
|
||||
|
||||
@@ -217,7 +217,7 @@ Two triggers in the embedded OpenClaw agent:
|
||||
|
||||
Two additional guards run outside these two triggers:
|
||||
|
||||
- **Preflight local compaction**: set `agents.defaults.compaction.maxActiveTranscriptBytes` (bytes or a string like `"20mb"`) to trigger local compaction before opening the next run once the active transcript reaches that size. This is a size guard for local reopen cost, not raw archival - normal semantic compaction still runs, and it requires `truncateAfterCompaction` so the compacted summary becomes a new successor transcript.
|
||||
- **Preflight local compaction**: set `agents.defaults.compaction.maxActiveTranscriptBytes` (bytes or a string like `"20mb"`) to trigger local compaction before opening the next run once the active transcript reaches that size. Normal semantic compaction still runs. Durable size reduction requires `truncateAfterCompaction` plus a context engine that returns an explicit successor identity.
|
||||
- **Mid-turn precheck**: set `agents.defaults.compaction.midTurnPrecheck.enabled: true` (default `false`) to add a tool-loop guard. After a tool result is appended and before the next model call, OpenClaw estimates prompt pressure using the same preflight budget logic used at turn start. If context no longer fits, the guard does not compact inline - it raises a structured mid-turn precheck signal, stops the current prompt submission, and lets the outer run loop use the existing recovery path (truncate oversized tool results when that is enough, or trigger the configured compaction mode and retry). Works with both `default` and `safeguard` compaction modes, including provider-backed safeguard compaction. Independent of `maxActiveTranscriptBytes`: the byte-size guard runs before a turn opens, mid-turn precheck runs later, after new tool results are appended.
|
||||
|
||||
## Compaction settings
|
||||
@@ -239,9 +239,9 @@ OpenClaw enforces a built-in reserve for embedded runs and caps it against the a
|
||||
|
||||
Set `enabled: false` to disable threshold-driven auto-compaction inside the embedded agent runtime. OpenClaw's preflight and overflow-recovery compaction paths remain available, and manual `/compact` continues to work.
|
||||
|
||||
Manual `/compact` honors an explicit `agents.defaults.compaction.keepRecentTokens` and keeps the runtime's recent-tail cut point. Without an explicit keep budget, manual compaction is a hard checkpoint and rebuilt context starts from the new summary.
|
||||
Manual `/compact` uses `agents.defaults.compaction.keepRecentTokens` (default: `20000`) and keeps that recent-tail cut point.
|
||||
|
||||
When `truncateAfterCompaction` is enabled, OpenClaw rotates the active transcript to a compacted successor after compaction. Branch/restore checkpoint actions use that compacted successor; legacy pre-compaction checkpoint files remain readable while referenced.
|
||||
When `truncateAfterCompaction` is enabled, OpenClaw adopts an explicit successor identity returned by a context engine. The built-in SQLite compactor keeps the current session identity. Branch/restore checkpoint actions use a returned successor when present; legacy pre-compaction checkpoint files remain readable while referenced.
|
||||
|
||||
## Pluggable compaction providers
|
||||
|
||||
@@ -278,12 +278,12 @@ Before auto-compaction happens, OpenClaw can run a silent agentic turn that writ
|
||||
|
||||
Config (`agents.defaults.compaction.memoryFlush`), full reference at [/gateway/config-agents](/gateway/config-agents#agentsdefaultscompaction):
|
||||
|
||||
| Key | Default | Notes |
|
||||
| --------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `true` | |
|
||||
| `model` | unset | exact provider/model override for the flush turn only, for example `ollama/qwen3:8b` |
|
||||
| `softThresholdTokens` | `4000` | gap below the compaction threshold that triggers a flush |
|
||||
| `forceFlushTranscriptBytes` | unset (disabled) | force a flush once the transcript file reaches this byte size (or string like `"2mb"`), even if token counters are stale; `0` disables |
|
||||
| Key | Default | Notes |
|
||||
| --------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `enabled` | `true` | |
|
||||
| `model` | unset | exact provider/model override for the flush turn only, for example `ollama/qwen3:8b` |
|
||||
| `softThresholdTokens` | `4000` | gap below the compaction threshold that triggers a flush |
|
||||
| `forceFlushTranscriptBytes` | unset (disabled) | force a flush once active transcript history reaches this estimated byte size (or string like `"2mb"`), even if token counters are stale; `0` disables |
|
||||
|
||||
Notes:
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ Other behavior: the runner preflights Docker by default, cleans stale OpenClaw E
|
||||
| `pnpm test:docker:live-cli-backend:claude`, `:claude:resume`, `:claude:mcp` | Focused CLI backend live probes; Gemini has matching `:resume` and `:mcp` aliases. |
|
||||
| `pnpm test:docker:openwebui` | Dockerized OpenClaw + Open WebUI: sign in, check `/api/models`, run a real proxied chat through `/api/chat/completions`. Requires a usable live model key and pulls an external image; not expected to be CI-stable like the unit/e2e suites. |
|
||||
| `pnpm test:docker:mcp-channels` | Seeded Gateway container plus a client container spawning `openclaw mcp serve`: routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge (assertion reads raw stdio MCP frames directly). |
|
||||
| `pnpm test:docker:upgrade-survivor` | Installs the packed tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider/channel keys, starts a loopback Gateway, checks agents/channel config/plugin allowlists/workspace/session files/stale legacy plugin dependency state/startup/RPC status survive. |
|
||||
| `pnpm test:docker:upgrade-survivor` | Installs the packed tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider/channel keys, starts a loopback Gateway, checks agents/channel config/plugin allowlists/workspace/session state/stale legacy plugin dependency state/startup/RPC status survive. |
|
||||
| `pnpm test:docker:published-upgrade-survivor` | Installs `openclaw@latest` by default, seeds realistic existing-user files, configures via a baked `openclaw config set` recipe, updates to the packed tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, checks `/healthz`, `/readyz`, RPC status. Override with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand a matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues` (includes `configured-plugin-installs` and `stale-source-plugin-shadow`). Package Acceptance exposes these as `published_upgrade_survivor_baseline(s)` / `_scenarios` and resolves meta tokens like `last-stable-4` or `all-since-2026.4.23`. |
|
||||
| `pnpm test:docker:update-migration` | Published-upgrade survivor harness in the `plugin-deps-cleanup` scenario, starting at `openclaw@2026.4.23` by default. The `Update Migration` workflow expands this with `baselines=all-since-2026.4.23` to prove configured-plugin dependency cleanup outside Full Release CI. |
|
||||
| `pnpm test:docker:plugins` | Install/update smoke for local path, `file:`, npm registry packages with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. |
|
||||
@@ -270,7 +270,7 @@ Optional; only needed for containerized onboarding smoke tests. Full cold-start
|
||||
scripts/e2e/onboard-docker.sh
|
||||
```
|
||||
|
||||
Drives the interactive wizard via a pseudo-tty, verifies config/workspace/session files, then starts the gateway and runs `openclaw health`.
|
||||
Drives the interactive wizard via a pseudo-tty, verifies config/workspace/session state, then starts the gateway and runs `openclaw health`.
|
||||
|
||||
## QR import smoke (Docker)
|
||||
|
||||
|
||||
@@ -8,19 +8,12 @@ title: "Transcript hygiene"
|
||||
---
|
||||
|
||||
OpenClaw applies **provider-specific fixes** to transcripts before a run
|
||||
(building model context). Most of these are **in-memory** adjustments used to
|
||||
satisfy strict provider requirements. A separate session-file repair pass may
|
||||
also rewrite stored JSONL before the session is loaded, but only for
|
||||
malformed lines or persisted turns that are invalid durable records.
|
||||
Delivered assistant replies are preserved on disk; provider-specific
|
||||
(building model context). These are **in-memory** adjustments used to satisfy
|
||||
strict provider requirements. Runtime transcript state stays in SQLite;
|
||||
provider-specific
|
||||
assistant-prefill stripping happens only while constructing outbound
|
||||
payloads.
|
||||
|
||||
When a repair occurs, the original file is written to a transient
|
||||
`*.bak-<pid>-<ts>` sibling before the atomic replace, then removed once the
|
||||
replace succeeds. The backup is retained only if cleanup itself fails, in
|
||||
which case the path is reported back.
|
||||
|
||||
Scope includes:
|
||||
|
||||
- Runtime-only prompt context staying out of user-visible transcript turns
|
||||
@@ -64,12 +57,8 @@ All transcript hygiene is centralized in the embedded runner:
|
||||
- Sanitization/repair application: `sanitizeSessionHistory` in
|
||||
`src/agents/embedded-agent-runner/replay-history.ts`
|
||||
|
||||
Separate from transcript hygiene, session files are repaired (if needed)
|
||||
before load:
|
||||
|
||||
- `repairSessionFileIfNeeded` in `src/agents/session-file-repair.ts`
|
||||
- Called from `src/agents/embedded-agent-runner/run/attempt.ts` and
|
||||
`src/agents/embedded-agent-runner/compact.ts`
|
||||
Legacy JSONL validation and import belong to `openclaw doctor --fix`; the
|
||||
embedded runner does not repair or reopen file-backed runtime transcripts.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("importClaudeHistory", () => {
|
||||
},
|
||||
],
|
||||
threadId: "thread-1",
|
||||
sessionFile: "/tmp/unused.jsonl",
|
||||
storePath: "/tmp/sessions.json",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:catalog-adopt",
|
||||
agentId: "main",
|
||||
@@ -60,7 +60,7 @@ describe("importClaudeHistory", () => {
|
||||
{ type: "assistantMessage", text: "done", uuid: "a-1" },
|
||||
],
|
||||
threadId: "thread-1",
|
||||
sessionFile: "/tmp/unused.jsonl",
|
||||
storePath: "/tmp/sessions.json",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:catalog-adopt",
|
||||
agentId: "main",
|
||||
@@ -84,7 +84,7 @@ describe("importClaudeHistory", () => {
|
||||
{ type: "assistantMessage", text: "AUTH_OK", uuid: "a-1" },
|
||||
],
|
||||
threadId: "thread-1",
|
||||
sessionFile: "/tmp/unused.jsonl",
|
||||
storePath: "/tmp/sessions.json",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:catalog-adopt",
|
||||
agentId: "main",
|
||||
@@ -101,7 +101,7 @@ describe("importClaudeHistory", () => {
|
||||
await importClaudeHistory({
|
||||
items: [{ type: "toolCall", content: [{ type: "tool_use" }], uuid: "t-1" }],
|
||||
threadId: "thread-1",
|
||||
sessionFile: "/tmp/unused.jsonl",
|
||||
storePath: "/tmp/sessions.json",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:catalog-adopt",
|
||||
agentId: "main",
|
||||
|
||||
@@ -55,10 +55,10 @@ function importedClaudeMessage(
|
||||
export async function importClaudeHistory(params: {
|
||||
items: ClaudeTranscriptItem[];
|
||||
threadId: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
storePath: string;
|
||||
cwd?: string;
|
||||
config: OpenClawConfig;
|
||||
}): Promise<void> {
|
||||
|
||||
@@ -1592,16 +1592,15 @@ async function continueClaudeSession(
|
||||
pluginExtensions: { anthropic: { sessionCatalog: marker } },
|
||||
},
|
||||
afterCreate: async (entry) => {
|
||||
if (!entry.entry.sessionFile) {
|
||||
throw new Error("Claude session creation did not produce a transcript file");
|
||||
}
|
||||
await importClaudeHistory({
|
||||
items: history,
|
||||
threadId,
|
||||
sessionFile: entry.entry.sessionFile,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: entry.key,
|
||||
agentId: entry.agentId,
|
||||
storePath: api.runtime.agent.session.resolveStorePath(config.session?.store, {
|
||||
agentId: entry.agentId,
|
||||
}),
|
||||
...(record.cwd ? { cwd: record.cwd } : {}),
|
||||
config,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
|
||||
import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import {
|
||||
DELIVERY_NO_REPLY_RUNTIME_CONTRACT,
|
||||
installSessionManagerFileCompat,
|
||||
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { isSilentReplyPayloadText } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -14,13 +17,15 @@ const THREAD_ID = "thread-delivery-contract";
|
||||
const TURN_ID = "turn-delivery-contract";
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
|
||||
|
||||
async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-delivery-contract-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.open(sessionFile);
|
||||
SessionManager.openFile(sessionFile);
|
||||
return {
|
||||
prompt: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.prompt,
|
||||
sessionId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
inferToolMetaFromArgs,
|
||||
resetAgentEventsForTest,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
@@ -24,6 +25,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CodexAppServerEventProjector } from "./event-projector.js";
|
||||
import { createCodexTestModel, createCodexTestToolTerminalObserver } from "./test-support.js";
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
export { readAttemptTerminal } from "./attempt-terminal.test-helper.js";
|
||||
|
||||
export {
|
||||
@@ -90,7 +93,7 @@ export async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-projector-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.open(sessionFile).appendMessage(assistantMessage("history", Date.now()));
|
||||
SessionManager.openFile(sessionFile).appendMessage(assistantMessage("history", Date.now()));
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness
|
||||
import { classifyEmbeddedAgentRunResultForModelFallback } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
createContractRunResult,
|
||||
installSessionManagerFileCompat,
|
||||
OUTCOME_FALLBACK_RUNTIME_CONTRACT,
|
||||
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
@@ -20,6 +21,8 @@ const THREAD_ID = "thread-outcome-contract";
|
||||
const TURN_ID = "turn-outcome-contract";
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
|
||||
type ProjectedAttemptResult = ReturnType<CodexAppServerEventProjector["buildResult"]>;
|
||||
type CodexAppServerToolTelemetry = Parameters<CodexAppServerEventProjector["buildResult"]>[0];
|
||||
@@ -29,7 +32,7 @@ async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-outcome-contract-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.open(sessionFile);
|
||||
SessionManager.openFile(sessionFile);
|
||||
return {
|
||||
prompt: OUTCOME_FALLBACK_RUNTIME_CONTRACT.prompt,
|
||||
sessionId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
supportsModelTools,
|
||||
type HarnessContextEngine as ContextEngine,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
|
||||
const CODEX_TURN_START_TEXT_INPUT_MAX_CHARS = 1 << 20;
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
let tempDir: string;
|
||||
let codexAppServerClientFactoryForTest: CodexTestAppServerClientFactory | undefined;
|
||||
|
||||
@@ -457,7 +460,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("bootstraps and assembles non-legacy context before the Codex turn starts", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("existing context", Date.now()) as never,
|
||||
);
|
||||
const openSpy = vi.spyOn(SessionManager, "open");
|
||||
@@ -535,7 +538,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("keeps context-engine history bound to the run session when sandbox key differs", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("canonical main context", Date.now()) as never,
|
||||
);
|
||||
const contextEngine = createContextEngine();
|
||||
@@ -726,7 +729,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("bootstrap-only context", Date.now()) as never,
|
||||
);
|
||||
const contextEngine = createContextEngine({
|
||||
@@ -979,7 +982,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("previous stale-bootstrap request", Date.now()) as never,
|
||||
);
|
||||
@@ -1069,7 +1072,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("keeps mirrored history when an inactive per-turn context-engine binding starts fresh", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(userMessage("previous per-turn request", 10) as never);
|
||||
sessionManager.appendMessage(assistantMessage("previous per-turn answer", 11) as never);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1447,7 +1450,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("retries a resumed context-engine thread on a fresh Codex thread without plugin compaction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1549,7 +1552,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("returns a replay-safe recovery result when the executable owner changes during overflow retry", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1616,7 +1619,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1688,7 +1691,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("clears a resumed context-engine binding when a turn terminally overflows", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1766,7 +1769,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("does not pre-compact over-budget rendered context-engine prompts before Codex turn/start", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
const hugePayload = {
|
||||
@@ -1848,7 +1851,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("does not call hung owning context-engine compaction during Codex overflow recovery", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
@@ -1929,7 +1932,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("keeps current inbound context at the front of the Codex context-engine prompt", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("older context", Date.now()) as never,
|
||||
);
|
||||
const contextEngine = createContextEngine();
|
||||
@@ -2028,7 +2031,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("reloads mirrored history after bootstrap mutates the session transcript", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("existing context", Date.now()) as never,
|
||||
);
|
||||
const afterTurn = vi.fn(
|
||||
@@ -2036,7 +2039,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
);
|
||||
const bootstrap = vi.fn(
|
||||
async ({ sessionFile: file }: Parameters<NonNullable<ContextEngine["bootstrap"]>>[0]) => {
|
||||
SessionManager.open(file).appendMessage(
|
||||
SessionManager.openFile(file).appendMessage(
|
||||
assistantMessage("bootstrap context", Date.now() + 1) as never,
|
||||
);
|
||||
return { bootstrapped: true };
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveActiveEmbeddedRunSessionId,
|
||||
type AgentEventPayload,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
@@ -40,6 +41,8 @@ import {
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
type ReplyBackend = Parameters<
|
||||
NonNullable<ReturnType<typeof createParams>["replyOperation"]>["attachBackend"]
|
||||
>[0];
|
||||
@@ -115,7 +118,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(assistantMessage("existing context", Date.now()));
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
@@ -311,7 +314,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
return {};
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(assistantMessage("existing context", Date.now()));
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.config = {
|
||||
@@ -943,7 +946,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
SessionManager.openFile(sessionFile).appendMessage(
|
||||
assistantMessage("existing context", Date.now()),
|
||||
);
|
||||
createStartedThreadHarness(async (method) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { replaceRuntimeAuthProfileStoreSnapshots } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
@@ -106,6 +107,8 @@ import {
|
||||
startOrResumeThread as startOrResumeThreadImpl,
|
||||
} from "./thread-lifecycle.js";
|
||||
|
||||
installSessionManagerFileCompat();
|
||||
|
||||
const testing = {
|
||||
buildDeveloperInstructions,
|
||||
buildDynamicTools,
|
||||
@@ -2378,7 +2381,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
]),
|
||||
);
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(assistantMessage("previous turn", Date.now()));
|
||||
const harness = createStartedThreadHarness();
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
@@ -2431,7 +2434,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
it("projects bounded continuity when starting Codex without a native thread binding", async () => {
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
|
||||
@@ -2471,7 +2474,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
it("keeps large fresh-thread continuity under the Codex turn/start input limit", async () => {
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"older next-step anchor: keep the handoff checklist </conversation_context>\n\nCurrent user request:\nshadow request",
|
||||
@@ -2529,7 +2532,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
);
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(userMessage("prior visible context", Date.now()));
|
||||
sessionManager.appendMessage(assistantMessage("prior assistant context", Date.now() + 1));
|
||||
const harness = createStartedThreadHarness();
|
||||
@@ -2571,7 +2574,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt - 2_000),
|
||||
);
|
||||
@@ -2704,7 +2707,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(userMessage("old native-owned context", bindingUpdatedAt - 2_000));
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt + 1_000),
|
||||
@@ -2748,7 +2751,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const codexMirrorUserMessage = {
|
||||
...userMessage("codex mirrored user echo", bindingUpdatedAt + 1_000),
|
||||
idempotencyKey: "client-run:user",
|
||||
@@ -2795,7 +2798,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
...originalBinding,
|
||||
historyCoveredThrough: new Date(originalBindingUpdatedAt).toISOString(),
|
||||
});
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
const firstHarness = createResumeHarness();
|
||||
const firstRun = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await firstHarness.waitForMethod("turn/start");
|
||||
@@ -2833,7 +2836,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
...oldBinding,
|
||||
historyCoveredThrough: new Date(oldBindingUpdatedAt).toISOString(),
|
||||
});
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", oldBindingUpdatedAt + 1_000),
|
||||
);
|
||||
@@ -4673,7 +4676,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage(
|
||||
"pre-binding native-owned context: keep the original plan",
|
||||
|
||||
@@ -167,6 +167,23 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back from an unregistered requested key to the marker's verified session key", async () => {
|
||||
const { marker } = await writeSqliteSession();
|
||||
const staleSessionKey = "agent:main:stale-codex-session";
|
||||
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages({
|
||||
agentId: "main",
|
||||
sessionFile: marker,
|
||||
sessionId: "codex-sqlite-session",
|
||||
sessionKey: staleSessionKey,
|
||||
}),
|
||||
).resolves.toMatchObject([
|
||||
{ role: "user", content: "sqlite prompt" },
|
||||
{ role: "assistant", content: "sqlite answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves synthesized SQLite markers for stale file-backed session metadata", async () => {
|
||||
const { marker } = await writeSqliteSession({
|
||||
storedSessionFile: "/tmp/legacy-session.jsonl",
|
||||
|
||||
@@ -29,6 +29,25 @@ type CodexMirroredSessionHistoryTarget = {
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
function selectPreferredSessionKey(
|
||||
matches: Array<{ entry: { updatedAt?: number }; sessionKey: string }>,
|
||||
sessionId: string,
|
||||
): string | undefined {
|
||||
const structural = matches.filter(
|
||||
({ sessionKey }) => sessionKey === sessionId || sessionKey.endsWith(`:${sessionId}`),
|
||||
);
|
||||
const candidates = structural.length > 0 ? structural : matches;
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0]?.sessionKey;
|
||||
}
|
||||
const sorted = candidates.toSorted(
|
||||
(left, right) => (right.entry.updatedAt ?? 0) - (left.entry.updatedAt ?? 0),
|
||||
);
|
||||
return (sorted[0]?.entry.updatedAt ?? 0) > (sorted[1]?.entry.updatedAt ?? 0)
|
||||
? sorted[0]?.sessionKey
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Returns sanitized session-context messages for a Codex mirrored session file. */
|
||||
export async function readCodexMirroredSessionHistoryMessages(
|
||||
target: CodexMirroredSessionHistoryTarget,
|
||||
@@ -104,21 +123,17 @@ function resolveSqliteMarkerSessionKey(
|
||||
marker: SqliteSessionFileMarker,
|
||||
): string | undefined {
|
||||
const explicitSessionKey = target.sessionKey?.trim();
|
||||
if (explicitSessionKey) {
|
||||
return explicitSessionKey;
|
||||
}
|
||||
const entries = listSessionEntries({
|
||||
agentId: marker.agentId,
|
||||
readOnly: true,
|
||||
storePath: marker.storePath,
|
||||
});
|
||||
const exactEntry = entries.find(({ entry }) => {
|
||||
return entry.sessionId === marker.sessionId && entry.sessionFile === target.sessionFile;
|
||||
});
|
||||
const sessionEntry =
|
||||
exactEntry ??
|
||||
entries.find(({ entry }) => {
|
||||
return entry.sessionId === marker.sessionId;
|
||||
});
|
||||
return sessionEntry?.sessionKey;
|
||||
if (explicitSessionKey) {
|
||||
const explicitEntry = entries.find(({ sessionKey }) => sessionKey === explicitSessionKey);
|
||||
if (explicitEntry) {
|
||||
return explicitEntry.entry.sessionId === marker.sessionId ? explicitSessionKey : undefined;
|
||||
}
|
||||
}
|
||||
const matches = entries.filter(({ entry }) => entry.sessionId === marker.sessionId);
|
||||
return selectPreferredSessionKey(matches, marker.sessionId);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@ export abstract class MemoryManagerSessionSyncOps extends MemoryManagerWatchOps
|
||||
return {
|
||||
generatedByDreamingNarrative: entry.generatedByDreamingNarrative === true,
|
||||
generatedByCronRun: entry.generatedByCronRun === true,
|
||||
...(entry.transcriptSource === "sqlite" && entry.storePath
|
||||
? {
|
||||
agentId: entry.agentId,
|
||||
sessionId: entry.sessionId,
|
||||
storePath: entry.storePath,
|
||||
}
|
||||
: {}),
|
||||
...(entry.sessionKey ? { sessionKey: entry.sessionKey } : {}),
|
||||
...(entry.updatedAtMs !== undefined ? { updatedAtMs: entry.updatedAtMs } : {}),
|
||||
};
|
||||
|
||||
@@ -136,6 +136,13 @@ export class QmdSessionExporter {
|
||||
const entry = await buildSessionEntry(sessionFile, {
|
||||
generatedByDreamingNarrative: corpusEntry.generatedByDreamingNarrative === true,
|
||||
generatedByCronRun: corpusEntry.generatedByCronRun === true,
|
||||
...(corpusEntry.transcriptSource === "sqlite" && corpusEntry.storePath
|
||||
? {
|
||||
agentId: corpusEntry.agentId,
|
||||
sessionId: corpusEntry.sessionId,
|
||||
storePath: corpusEntry.storePath,
|
||||
}
|
||||
: {}),
|
||||
...(corpusEntry.sessionKey ? { sessionKey: corpusEntry.sessionKey } : {}),
|
||||
...(corpusEntry.updatedAtMs !== undefined ? { updatedAtMs: corpusEntry.updatedAtMs } : {}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Memory Core plugin module implements session search visibility behavior.
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
|
||||
@@ -41,11 +40,7 @@ function isSameStoredTranscript(
|
||||
if (anchorSessionId && candidate.sessionId?.trim() === anchorSessionId) {
|
||||
return true;
|
||||
}
|
||||
const anchorFile = anchor.sessionFile?.trim();
|
||||
const candidateFile = candidate.sessionFile?.trim();
|
||||
return Boolean(
|
||||
anchorFile && candidateFile && path.resolve(anchorFile) === path.resolve(candidateFile),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateConversation(params: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
formatSqliteSessionFileMarker,
|
||||
listSessionEntries,
|
||||
loadTranscriptEventsSync,
|
||||
resolveStorePath,
|
||||
@@ -333,11 +332,6 @@ async function seedQaSessionTranscript(
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionFile: formatSqliteSessionFileMarker({
|
||||
agentId: "qa",
|
||||
sessionId,
|
||||
storePath,
|
||||
}),
|
||||
sessionId,
|
||||
updatedAt: params.updatedAt,
|
||||
...(label ? { origin: { label } } : {}),
|
||||
|
||||
@@ -222,20 +222,14 @@ type TelegramNativeCommandRuntime = Awaited<ReturnType<typeof loadTelegramNative
|
||||
|
||||
function resolveTelegramCommandSessionFile(params: {
|
||||
agentId: string;
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
storePath: string;
|
||||
}): string {
|
||||
const sqliteMarker = formatSqliteSessionFileMarker({
|
||||
return formatSqliteSessionFileMarker({
|
||||
agentId: params.agentId,
|
||||
sessionId: params.sessionId,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
const explicitSessionFile = params.sessionFile?.trim();
|
||||
if (explicitSessionFile === sqliteMarker) {
|
||||
return explicitSessionFile;
|
||||
}
|
||||
return sqliteMarker;
|
||||
}
|
||||
|
||||
function resolveTelegramProgressPlaceholder(command: {
|
||||
@@ -267,7 +261,6 @@ async function resolveTelegramCommandTranscriptContext(params: {
|
||||
const sessionId = entry?.sessionId?.trim() || randomUUID();
|
||||
const sessionFile = resolveTelegramCommandSessionFile({
|
||||
agentId: params.agentId,
|
||||
sessionFile: entry?.sessionFile,
|
||||
sessionId,
|
||||
storePath,
|
||||
});
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("listSessionFilesForAgent", () => {
|
||||
});
|
||||
|
||||
describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
it("omits active JSONL session entries from accessor-backed corpus entries", async () => {
|
||||
it("treats accessor-backed entries as live SQLite transcripts", async () => {
|
||||
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
|
||||
fsSync.mkdirSync(sessionsDir, { recursive: true });
|
||||
fsSync.writeFileSync(path.join(sessionsDir, "narrative.jsonl"), "");
|
||||
@@ -131,7 +131,15 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([]);
|
||||
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
artifactKind: "active-session",
|
||||
sessionFile: "agent:main:dreaming-narrative-run-1",
|
||||
sessionId: "narrative",
|
||||
transcriptSource: "sqlite",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps archive artifacts in the corpus and inherits active session classification", async () => {
|
||||
@@ -171,7 +179,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
fsSync.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
await upsertSessionEntry({ agentId: "main", sessionKey, storePath }, { sessionId, updatedAt });
|
||||
const turn = await persistSessionTranscriptTurn(
|
||||
await persistSessionTranscriptTurn(
|
||||
{ agentId: "main", sessionId, sessionKey, storePath },
|
||||
{
|
||||
messages: [
|
||||
@@ -207,7 +215,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
agentId: "main",
|
||||
artifactKind: "active-session",
|
||||
contentRevision: expect.any(String),
|
||||
sessionFile: turn.sessionFile,
|
||||
sessionFile: sessionKey,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
transcriptSource: "sqlite",
|
||||
@@ -224,10 +232,19 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
);
|
||||
|
||||
const liveEntry = requireSessionEntry(
|
||||
await buildSessionEntry(turn.sessionFile, { sessionKey, updatedAtMs: updatedAt }),
|
||||
await buildSessionEntry(sessionKey, {
|
||||
agentId: "main",
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
updatedAtMs: updatedAt,
|
||||
}),
|
||||
);
|
||||
const liveState = statSessionEntrySync(turn.sessionFile, {
|
||||
const liveState = statSessionEntrySync(sessionKey, {
|
||||
agentId: "main",
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
updatedAtMs: updatedAt,
|
||||
});
|
||||
const archiveEntry = requireSessionEntry(await buildSessionEntry(archivePath));
|
||||
@@ -235,7 +252,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
expect(liveEntry.path).toBe("sessions/main/sqlite-live.jsonl");
|
||||
expect(liveEntry.content).toBe("User: Live SQLite transcript text");
|
||||
expect(liveState).toEqual({
|
||||
absPath: turn.sessionFile,
|
||||
absPath: sessionKey,
|
||||
path: liveEntry.path,
|
||||
mtimeMs: liveEntry.mtimeMs,
|
||||
size: liveEntry.size,
|
||||
@@ -346,14 +363,8 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const classification = loadSessionTranscriptClassificationForAgent("main");
|
||||
|
||||
expect(classification.cronRunTranscriptPaths).toEqual(new Set());
|
||||
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([]);
|
||||
const entries = await listSessionTranscriptCorpusEntriesForAgent("main");
|
||||
expect(entries.find((entry) => entry.sessionFile === normalPath)?.generatedByCronRun).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(entries.filter((entry) => entry.generatedByCronRun)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps archive classification when the active transcript is missing", async () => {
|
||||
@@ -374,16 +385,18 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
|
||||
expect(classification.cronRunTranscriptPaths).toEqual(new Set([expectedArchivePath]));
|
||||
await expect(listSessionFilesForAgent("main")).resolves.toEqual([expectedArchivePath]);
|
||||
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([
|
||||
{
|
||||
agentId: "main",
|
||||
artifactKind: "archive-artifact",
|
||||
contentRevision: expect.any(String),
|
||||
generatedByCronRun: true,
|
||||
sessionFile: expectedArchivePath,
|
||||
sessionId: "cron-run",
|
||||
},
|
||||
]);
|
||||
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
artifactKind: "archive-artifact",
|
||||
contentRevision: expect.any(String),
|
||||
generatedByCronRun: true,
|
||||
sessionFile: expectedArchivePath,
|
||||
sessionId: "cron-run",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits active session entries whose transcript files are missing", async () => {
|
||||
@@ -506,7 +519,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("omits active JSONL transcripts from a custom session store", async () => {
|
||||
it("uses SQLite identity for entries in a custom session store", async () => {
|
||||
const sessionsDir = path.join(tmpDir, "custom-sessions");
|
||||
const sessionFile = path.join(sessionsDir, "custom-thread.jsonl");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
@@ -526,7 +539,13 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
|
||||
});
|
||||
|
||||
await expect(listSessionFilesForAgent("main")).resolves.toEqual([]);
|
||||
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([]);
|
||||
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionFile: "agent:main:chat:custom",
|
||||
sessionId: "custom-thread",
|
||||
transcriptSource: "sqlite",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unowned archives from an agent-owned fixed session store", async () => {
|
||||
|
||||
@@ -76,6 +76,10 @@ export type BuildSessionEntryOptions = {
|
||||
generatedByCronRun?: boolean;
|
||||
/** Session key for identity-backed transcript readers. */
|
||||
sessionKey?: string;
|
||||
/** Direct SQLite identity for live runtime transcripts. */
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
storePath?: string;
|
||||
/** Activity timestamp for transcript sources that do not have filesystem stats. */
|
||||
updatedAtMs?: number;
|
||||
/** Override for tests or specialized callers that need a tighter parse yield cadence. */
|
||||
@@ -301,6 +305,9 @@ function classifySessionTranscriptCorpusEntries(
|
||||
const dreamingTranscriptPaths = new Set<string>();
|
||||
const cronRunTranscriptPaths = new Set<string>();
|
||||
for (const entry of corpusEntries) {
|
||||
if (entry.transcriptSource === "sqlite") {
|
||||
continue;
|
||||
}
|
||||
const normalizedPath = normalizeComparablePath(entry.sessionFile);
|
||||
if (entry.generatedByDreamingNarrative) {
|
||||
dreamingTranscriptPaths.add(normalizedPath);
|
||||
@@ -353,9 +360,9 @@ function classifySessionTranscriptFromSessionStore(absPath: string): {
|
||||
}
|
||||
|
||||
export async function listSessionFilesForAgent(agentId: string): Promise<string[]> {
|
||||
return (await listSessionTranscriptCorpusEntriesForAgent(agentId)).map(
|
||||
(entry) => entry.sessionFile,
|
||||
);
|
||||
return (await listSessionTranscriptCorpusEntriesForAgent(agentId))
|
||||
.filter((entry) => entry.transcriptSource !== "sqlite")
|
||||
.map((entry) => entry.sessionFile);
|
||||
}
|
||||
|
||||
function extractAgentIdFromSessionPath(absPath: string): string | null {
|
||||
@@ -662,21 +669,31 @@ function resolveSessionEntryParseYieldLines(opts: BuildSessionEntryOptions): num
|
||||
return SESSION_ENTRY_PARSE_YIELD_LINES;
|
||||
}
|
||||
|
||||
function resolveBuildSessionSqliteIdentity(absPath: string, opts: BuildSessionEntryOptions) {
|
||||
if (opts.agentId && opts.sessionId && opts.storePath) {
|
||||
return {
|
||||
agentId: opts.agentId,
|
||||
sessionId: opts.sessionId,
|
||||
...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}),
|
||||
storePath: opts.storePath,
|
||||
};
|
||||
}
|
||||
const marker = parseSqliteSessionFileMarker(absPath);
|
||||
return marker && opts.sessionKey ? { ...marker, sessionKey: opts.sessionKey } : marker;
|
||||
}
|
||||
|
||||
export function statSessionEntrySync(
|
||||
absPath: string,
|
||||
opts: BuildSessionEntryOptions = {},
|
||||
): SessionFileState | null {
|
||||
const sqliteMarker = parseSqliteSessionFileMarker(absPath);
|
||||
if (sqliteMarker) {
|
||||
const sqliteIdentity = resolveBuildSessionSqliteIdentity(absPath, opts);
|
||||
if (sqliteIdentity) {
|
||||
const stats = readTranscriptStatsSync({
|
||||
agentId: sqliteMarker.agentId,
|
||||
sessionId: sqliteMarker.sessionId,
|
||||
...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}),
|
||||
storePath: sqliteMarker.storePath,
|
||||
...sqliteIdentity,
|
||||
});
|
||||
return {
|
||||
absPath,
|
||||
path: sessionPathForSessionIdentity(sqliteMarker.agentId, sqliteMarker.sessionId),
|
||||
path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId),
|
||||
mtimeMs: opts.updatedAtMs ?? stats.maxSeq,
|
||||
size: stats.sizeBytes,
|
||||
};
|
||||
@@ -712,25 +729,19 @@ export async function buildSessionEntry(
|
||||
opts: BuildSessionEntryOptions = {},
|
||||
): Promise<SessionFileEntry | null> {
|
||||
try {
|
||||
const sqliteMarker = parseSqliteSessionFileMarker(absPath);
|
||||
const rawSource = sqliteMarker
|
||||
const sqliteIdentity = resolveBuildSessionSqliteIdentity(absPath, opts);
|
||||
const rawSource = sqliteIdentity
|
||||
? (() => {
|
||||
const stats = readTranscriptStatsSync({
|
||||
agentId: sqliteMarker.agentId,
|
||||
sessionId: sqliteMarker.sessionId,
|
||||
...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}),
|
||||
storePath: sqliteMarker.storePath,
|
||||
...sqliteIdentity,
|
||||
});
|
||||
const records = loadTranscriptEventsSync({
|
||||
agentId: sqliteMarker.agentId,
|
||||
sessionId: sqliteMarker.sessionId,
|
||||
...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}),
|
||||
storePath: sqliteMarker.storePath,
|
||||
...sqliteIdentity,
|
||||
});
|
||||
const raw = serializeTranscriptEvents(records);
|
||||
return {
|
||||
mtimeMs: opts.updatedAtMs ?? stats.maxSeq,
|
||||
path: sessionPathForSessionIdentity(sqliteMarker.agentId, sqliteMarker.sessionId),
|
||||
path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId),
|
||||
raw,
|
||||
size: stats.sizeBytes,
|
||||
};
|
||||
@@ -778,15 +789,15 @@ export async function buildSessionEntry(
|
||||
const messageTimestampsMs: number[] = [];
|
||||
const parseYieldEveryLines = resolveSessionEntryParseYieldLines(opts);
|
||||
const sqliteSessionKey =
|
||||
sqliteMarker && !opts.sessionKey
|
||||
sqliteIdentity && !opts.sessionKey
|
||||
? resolveTranscriptSessionKeyBySessionId({
|
||||
agentId: sqliteMarker.agentId,
|
||||
sessionId: sqliteMarker.sessionId,
|
||||
storePath: sqliteMarker.storePath,
|
||||
agentId: sqliteIdentity.agentId,
|
||||
sessionId: sqliteIdentity.sessionId,
|
||||
storePath: sqliteIdentity.storePath,
|
||||
})
|
||||
: undefined;
|
||||
const sessionStoreClassification =
|
||||
!sqliteMarker &&
|
||||
!sqliteIdentity &&
|
||||
(opts.generatedByDreamingNarrative === undefined || opts.generatedByCronRun === undefined)
|
||||
? classifySessionTranscriptFromSessionStore(absPath)
|
||||
: null;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
readTranscriptContentRevisionSync,
|
||||
resolveSessionAgentId,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "./openclaw-runtime-session.js";
|
||||
@@ -30,6 +31,7 @@ export type SessionTranscriptCorpusEntry = {
|
||||
contentRevision?: string;
|
||||
artifactKind: SessionTranscriptCorpusArtifactKind;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
/** Present when an active transcript is addressed by SQLite identity, not a JSONL path. */
|
||||
transcriptSource?: "sqlite";
|
||||
/** Session entry activity timestamp used when the source has no filesystem stat. */
|
||||
@@ -114,6 +116,7 @@ type ResolvedSessionStoreCorpusSource = {
|
||||
|
||||
function resolveSessionStoreTranscriptCorpusSource(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
sessionsDir: string,
|
||||
storePath: string,
|
||||
entry: { sessionFile?: unknown; sessionId?: unknown } | undefined,
|
||||
@@ -134,10 +137,14 @@ function resolveSessionStoreTranscriptCorpusSource(
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
if (!sessionFile) {
|
||||
return {
|
||||
sessionFile: sessionKey,
|
||||
sessionId,
|
||||
transcriptSource: "sqlite",
|
||||
};
|
||||
}
|
||||
if (sqliteMarker) {
|
||||
if (!sessionFile) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
sqliteMarker.sessionId !== sessionId ||
|
||||
normalizeAgentId(sqliteMarker.agentId) !== normalizeAgentId(agentId) ||
|
||||
@@ -274,6 +281,7 @@ function toSessionStoreCorpusEntry(
|
||||
): SessionTranscriptCorpusEntry | null {
|
||||
const source = resolveSessionStoreTranscriptCorpusSource(
|
||||
agentId,
|
||||
summary.sessionKey,
|
||||
sessionsDir,
|
||||
storePath,
|
||||
summary.entry,
|
||||
@@ -309,6 +317,7 @@ function toSessionStoreCorpusEntry(
|
||||
sessionId: source.sessionId,
|
||||
...(contentRevision ? { contentRevision } : {}),
|
||||
...(source.transcriptSource === "sqlite" ? { transcriptSource: "sqlite" as const } : {}),
|
||||
...(source.transcriptSource === "sqlite" ? { storePath } : {}),
|
||||
...(source.transcriptSource === "sqlite" && Number.isFinite(summary.entry.updatedAt)
|
||||
? { updatedAtMs: summary.entry.updatedAt }
|
||||
: {}),
|
||||
@@ -415,6 +424,7 @@ export function listSessionTranscriptCorpusEntriesForAgentSync(
|
||||
const activeEntryOwnersByPath = new Map<string, string>();
|
||||
const artifactDirsByPath = new Map<string, string>();
|
||||
rememberArtifactDir(artifactDirsByPath, sessionsDir);
|
||||
rememberArtifactDir(artifactDirsByPath, resolveSessionTranscriptsDirForAgent(normalizedAgentId));
|
||||
const sessionEntries = listSessionEntries({
|
||||
agentId: normalizedAgentId,
|
||||
hydrateSkillPromptRefs: false,
|
||||
@@ -479,11 +489,11 @@ export function listSessionTranscriptCorpusEntriesForAgentSync(
|
||||
const primarySessionId = parseUsageCountedSessionIdFromFileName(path.basename(artifactPath));
|
||||
const primaryOwner =
|
||||
primarySessionId && isSessionArchiveArtifactName(path.basename(artifactPath))
|
||||
? activeEntryOwnersByPath.get(
|
||||
? (activeEntryOwnersByPath.get(
|
||||
normalizeRealComparablePath(
|
||||
path.join(path.dirname(artifactPath), `${primarySessionId}.jsonl`),
|
||||
),
|
||||
)
|
||||
) ?? activeEntriesBySessionId.get(primarySessionId)?.agentId)
|
||||
: undefined;
|
||||
if (primaryOwner && primaryOwner !== normalizedAgentId) {
|
||||
continue;
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"transcriptWriter": {
|
||||
"src/agents/embedded-agent-runner/compaction-hooks.ts": 2,
|
||||
"src/agents/embedded-agent-runner/thinking-replay-repair.ts": 2,
|
||||
"src/agents/embedded-agent-runner/tool-result-truncation.ts": 3,
|
||||
"src/agents/embedded-agent-runner/transcript-rewrite.ts": 3,
|
||||
"src/agents/embedded-agent-runner/tool-result-truncation.ts": 2,
|
||||
"src/agents/embedded-agent-runner/transcript-rewrite.ts": 2,
|
||||
"src/config/sessions/session-accessor.sqlite-events.ts": 3,
|
||||
"src/config/sessions/session-accessor.transcript.ts": 2
|
||||
}
|
||||
|
||||
@@ -4,15 +4,12 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js";
|
||||
import {
|
||||
listSessionEntries,
|
||||
loadTranscriptEvents,
|
||||
replaceSessionEntry,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import {
|
||||
formatSqliteSessionFileMarker,
|
||||
parseSqliteSessionFileMarker,
|
||||
} from "../config/sessions/sqlite-marker.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js";
|
||||
import type { runAgentAttempt } from "./command/attempt-execution.runtime.js";
|
||||
@@ -414,7 +411,6 @@ describe("agentCommand compaction transcript rotation", () => {
|
||||
expect(sessionKey).toBe("agent:main:explicit:old-session");
|
||||
expect(rotatedEntry).toMatchObject({
|
||||
sessionId: "rotated-session",
|
||||
sessionFile: rotatedSessionFile,
|
||||
usageFamilyKey: "agent:main:explicit:old-session",
|
||||
usageFamilySessionIds: ["old-session", "rotated-session"],
|
||||
compactionCount: 1,
|
||||
@@ -551,11 +547,6 @@ describe("agentCommand compaction transcript rotation", () => {
|
||||
const successorSessionId = "post-compaction-session";
|
||||
const sessionKey = `agent:main:explicit:${sessionId}`;
|
||||
const text = "reply carried across successful compaction";
|
||||
const successorSessionFile = formatSqliteSessionFileMarker({
|
||||
agentId: "main",
|
||||
sessionId: successorSessionId,
|
||||
storePath: requireStorePath(),
|
||||
});
|
||||
let successorBeforeCleanup: SessionEntry | undefined;
|
||||
let compactionSetupError: Error | undefined;
|
||||
state.runAgentAttemptMock.mockResolvedValueOnce(makeResult({ sessionId, text }));
|
||||
@@ -567,7 +558,6 @@ describe("agentCommand compaction transcript rotation", () => {
|
||||
successorBeforeCleanup = {
|
||||
...params.sessionEntry,
|
||||
sessionId: successorSessionId,
|
||||
sessionFile: successorSessionFile,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await replaceSessionEntry(
|
||||
@@ -598,14 +588,12 @@ describe("agentCommand compaction transcript rotation", () => {
|
||||
expect(result).toMatchObject({ deliverySucceeded: true });
|
||||
expect(state.deliveryFreshEntries.at(-1)).toMatchObject({
|
||||
sessionId: successorSessionId,
|
||||
sessionFile: successorSessionFile,
|
||||
pendingFinalDelivery: true,
|
||||
pendingFinalDeliveryText: text,
|
||||
});
|
||||
const storedSuccessor = findStoredSessionEntry(sessionKey);
|
||||
expect(storedSuccessor).toMatchObject({
|
||||
sessionId: successorSessionId,
|
||||
sessionFile: successorSessionFile,
|
||||
});
|
||||
expect(storedSuccessor?.pendingFinalDelivery).toBeUndefined();
|
||||
expect(storedSuccessor?.pendingFinalDeliveryText).toBeUndefined();
|
||||
@@ -905,17 +893,11 @@ describe("agentCommand compaction transcript rotation", () => {
|
||||
|
||||
it("resumes the next turn from the rotated successor", async () => {
|
||||
const storePath = requireStorePath();
|
||||
const rotatedSessionFile = formatSqliteSessionFileMarker({
|
||||
agentId: "main",
|
||||
sessionId: "rotated-session",
|
||||
storePath,
|
||||
});
|
||||
const sessionKey = "agent:main:explicit:old-session";
|
||||
await replaceSessionEntry(
|
||||
{ sessionKey, storePath },
|
||||
{
|
||||
sessionId: "rotated-session",
|
||||
sessionFile: rotatedSessionFile,
|
||||
updatedAt: Date.now(),
|
||||
usageFamilyKey: sessionKey,
|
||||
usageFamilySessionIds: ["old-session", "rotated-session"],
|
||||
@@ -936,27 +918,35 @@ describe("agentCommand compaction transcript rotation", () => {
|
||||
});
|
||||
|
||||
const secondAttempt = state.runAgentAttemptMock.mock.calls[0]?.[0] as
|
||||
| { sessionId?: string; sessionFile?: string; sessionKey?: string }
|
||||
| {
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
sessionTarget?: {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(secondAttempt).toMatchObject({
|
||||
sessionId: "rotated-session",
|
||||
sessionKey,
|
||||
});
|
||||
expect(parseSqliteSessionFileMarker(secondAttempt?.sessionFile)).toMatchObject({
|
||||
expect(secondAttempt?.sessionTarget).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "rotated-session",
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
expect(state.deliveryFreshEntries.at(-1)).toMatchObject({
|
||||
sessionId: "rotated-session",
|
||||
sessionFile: rotatedSessionFile,
|
||||
});
|
||||
const persisted = Object.fromEntries(
|
||||
listSessionEntries({ storePath }).map(({ entry, sessionKey: key }) => [key, entry]),
|
||||
);
|
||||
expect(persisted[sessionKey]).toMatchObject({
|
||||
sessionId: "rotated-session",
|
||||
sessionFile: rotatedSessionFile,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,10 +79,11 @@ afterEach(() => {
|
||||
function stubSessionManager(): ExtensionContext["sessionManager"] {
|
||||
const stub: ExtensionContext["sessionManager"] = {
|
||||
getCwd: () => "/stub",
|
||||
getSessionDir: () => "/stub",
|
||||
getSessionId: () => "stub-id",
|
||||
getSessionFile: () => undefined,
|
||||
getSessionTarget: () => undefined,
|
||||
getLeafId: () => null,
|
||||
getAppendParentId: () => null,
|
||||
getAppendMode: () => undefined,
|
||||
getLeafEntry: () => undefined,
|
||||
getEntry: () => undefined,
|
||||
getLabel: () => undefined,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
|
||||
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { readFileWindowFully } from "../infra/file-read.js";
|
||||
@@ -69,7 +68,7 @@ export function resolveContextInjectionMode(
|
||||
|
||||
/** Checks whether the session transcript still has a valid full-bootstrap marker. */
|
||||
export async function hasCompletedBootstrapTurn(sessionFile: string): Promise<boolean> {
|
||||
if (parseSqliteSessionFileMarker(sessionFile)) {
|
||||
if (!path.isAbsolute(sessionFile) || !sessionFile.endsWith(".jsonl")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
/**
|
||||
* Reads prior session transcript context for `/btw` side-question handoffs.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
type SessionEntry as StoredSessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import { loadTranscriptEvents } from "../config/sessions/session-accessor.js";
|
||||
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
|
||||
import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js";
|
||||
import {
|
||||
listSessionEntries,
|
||||
loadSessionEntry,
|
||||
loadTranscriptEvents,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import {
|
||||
scanSessionTranscriptTree,
|
||||
type SessionTranscriptTree,
|
||||
} from "../config/sessions/transcript-tree.js";
|
||||
import { diagnosticLogger as diag } from "../logging/diagnostic.js";
|
||||
import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js";
|
||||
import {
|
||||
buildSessionContext,
|
||||
migrateSessionEntries,
|
||||
parseSessionEntries,
|
||||
type FileEntry,
|
||||
type SessionEntry as AgentSessionEntry,
|
||||
} from "./sessions/session-manager.js";
|
||||
|
||||
@@ -101,21 +105,67 @@ function isTrailingUserMessage(entry: AgentSessionEntry | undefined): boolean {
|
||||
* messages.
|
||||
*/
|
||||
export async function readBtwTranscriptMessages(params: {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
snapshotLeafId?: string | null;
|
||||
}): Promise<unknown[]> {
|
||||
try {
|
||||
const marker = parseSqliteSessionFileMarker(params.sessionFile);
|
||||
const entries = marker
|
||||
? ((await loadTranscriptEvents({
|
||||
agentId: marker.agentId,
|
||||
sessionId: marker.sessionId,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
storePath: marker.storePath,
|
||||
})) as AgentSessionEntry[])
|
||||
: parseSessionEntries(await readFile(params.sessionFile, "utf-8"));
|
||||
const completeTarget = Boolean(
|
||||
params.agentId?.trim() &&
|
||||
params.sessionId.trim() &&
|
||||
params.sessionKey?.trim() &&
|
||||
params.storePath?.trim(),
|
||||
);
|
||||
const agentId = completeTarget ? params.agentId : (params.agentId ?? marker?.agentId);
|
||||
const sessionId = completeTarget ? params.sessionId : (marker?.sessionId ?? params.sessionId);
|
||||
const storePath = completeTarget ? params.storePath : (params.storePath ?? marker?.storePath);
|
||||
const markerMatches =
|
||||
marker && !completeTarget
|
||||
? listSessionEntries({
|
||||
agentId: marker.agentId,
|
||||
storePath: marker.storePath,
|
||||
}).filter(({ entry }) => entry.sessionId === marker.sessionId)
|
||||
: [];
|
||||
const suppliedEntry =
|
||||
marker && params.sessionKey && !completeTarget
|
||||
? loadSessionEntry({
|
||||
agentId: marker.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: marker.storePath,
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
marker &&
|
||||
!completeTarget &&
|
||||
params.sessionKey &&
|
||||
((suppliedEntry && suppliedEntry.sessionId !== marker.sessionId) ||
|
||||
(!suppliedEntry && markerMatches.length > 0))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const sessionKey = completeTarget
|
||||
? params.sessionKey
|
||||
: marker
|
||||
? suppliedEntry?.sessionId === marker.sessionId
|
||||
? params.sessionKey
|
||||
: (resolvePreferredSessionKeyForSessionIdMatches(
|
||||
markerMatches.map(({ sessionKey: mappedKey, entry }) => [mappedKey, entry]),
|
||||
marker.sessionId,
|
||||
) ?? (markerMatches.length === 0 ? params.sessionKey : undefined))
|
||||
: params.sessionKey;
|
||||
if (!sessionKey || !storePath) {
|
||||
return [];
|
||||
}
|
||||
const entries = (await loadTranscriptEvents({
|
||||
agentId,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
})) as FileEntry[];
|
||||
migrateSessionEntries(entries);
|
||||
const sessionEntries = entries.filter(
|
||||
(entry): entry is AgentSessionEntry => entry.type !== "session",
|
||||
@@ -131,7 +181,7 @@ export async function readBtwTranscriptMessages(params: {
|
||||
: undefined;
|
||||
if (hasSnapshotLeaf && branchEntries === undefined) {
|
||||
diag.debug(
|
||||
`btw snapshot leaf unavailable: sessionId=${params.sessionId} leaf=${params.snapshotLeafId}`,
|
||||
`btw snapshot leaf unavailable: sessionId=${sessionId} leaf=${params.snapshotLeafId}`,
|
||||
);
|
||||
}
|
||||
branchEntries ??= buildSessionBranchEntries(tree, tree.leafId);
|
||||
|
||||
+12
-5
@@ -435,6 +435,7 @@ function createTranscriptEntry(params: { id: string; parentId?: string | null; m
|
||||
|
||||
function mockTranscriptEntries(entries: unknown[]) {
|
||||
parseSessionEntriesMock.mockReturnValue(entries);
|
||||
loadTranscriptEventsMock.mockResolvedValue(entries);
|
||||
}
|
||||
|
||||
function mockActiveTranscript(messages: unknown[]) {
|
||||
@@ -609,7 +610,7 @@ describe("runBtwSideQuestion", () => {
|
||||
|
||||
readFileMock.mockResolvedValue("mock transcript");
|
||||
loadTranscriptEventsMock.mockResolvedValue([]);
|
||||
parseSessionEntriesMock.mockReturnValue([
|
||||
mockTranscriptEntries([
|
||||
createTranscriptEntry({
|
||||
id: "user-1",
|
||||
message: { role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1 },
|
||||
@@ -2372,6 +2373,13 @@ describe("runBtwSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("reads SQLite marker transcripts through the accessor when no active snapshot exists", async () => {
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/tmp",
|
||||
};
|
||||
const userEntry = createTranscriptEntry({
|
||||
id: "user-seed",
|
||||
message: createUserTranscriptMessage(),
|
||||
@@ -2381,15 +2389,14 @@ describe("runBtwSideQuestion", () => {
|
||||
parentId: "user-seed",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
|
||||
});
|
||||
loadTranscriptEventsMock.mockResolvedValue([userEntry, assistantEntry]);
|
||||
loadTranscriptEventsMock.mockResolvedValue([header, userEntry, assistantEntry]);
|
||||
readFileMock.mockRejectedValue(new Error("sqlite marker must not be read as a file"));
|
||||
mockDoneAnswer(MATH_ANSWER);
|
||||
|
||||
const result = await runMathSideQuestion({
|
||||
sessionKey: DEFAULT_SESSION_KEY,
|
||||
sessionEntry: createSessionEntry({
|
||||
sessionFile: `sqlite:main:session-1:${DEFAULT_STORE_PATH}`,
|
||||
}),
|
||||
sessionEntry: createSessionEntry(),
|
||||
storePath: DEFAULT_STORE_PATH,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: MATH_ANSWER });
|
||||
|
||||
@@ -1023,9 +1023,11 @@ export async function runBtwSideQuestion(
|
||||
if (messages.length === 0) {
|
||||
messages = await toSimpleContextMessages({
|
||||
messages: await readBtwTranscriptMessages({
|
||||
agentId: sessionAgentId,
|
||||
sessionFile,
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
snapshotLeafId: activeRunSnapshot?.transcriptLeafId,
|
||||
}),
|
||||
imageLimits,
|
||||
|
||||
@@ -10,7 +10,11 @@ import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
|
||||
import { createReplyOperation, replyRunRegistry } from "../auto-reply/reply/reply-run-registry.js";
|
||||
import { testing as replyRunTesting } from "../auto-reply/reply/reply-run-registry.test-support.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { loadTranscriptEvents, upsertSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
import {
|
||||
loadSessionEntry,
|
||||
loadTranscriptEvents,
|
||||
upsertSessionEntry,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
@@ -3430,10 +3434,10 @@ describe("runCliAgent reliability", () => {
|
||||
...context.params,
|
||||
agentId: "main",
|
||||
sessionFile,
|
||||
storePath,
|
||||
workspaceDir: dir,
|
||||
prompt: "runtime prompt",
|
||||
persistAssistantTranscript: true,
|
||||
storePath,
|
||||
userTurnTranscriptRecorder: recorder,
|
||||
onUserMessagePersisted,
|
||||
},
|
||||
@@ -4413,6 +4417,7 @@ describe("runCliAgent reliability", () => {
|
||||
const { dir, sessionFile } = createSessionFile({
|
||||
history: [{ role: "user", content: "earlier context" }],
|
||||
});
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
|
||||
try {
|
||||
let resolved = false;
|
||||
@@ -4429,6 +4434,7 @@ describe("runCliAgent reliability", () => {
|
||||
...context.params,
|
||||
agentId: "main",
|
||||
sessionFile,
|
||||
storePath,
|
||||
workspaceDir: dir,
|
||||
prompt: "secret prompt",
|
||||
},
|
||||
@@ -4457,6 +4463,27 @@ describe("runCliAgent reliability", () => {
|
||||
expect(result.meta.agentMeta?.contextTokens).toBe(150_000);
|
||||
expect(supervisorSpawnMock).not.toHaveBeenCalled();
|
||||
expect(hookRunner.runLlmInput).not.toHaveBeenCalled();
|
||||
const transcriptEvents = await loadTranscriptEvents({
|
||||
agentId: "main",
|
||||
sessionId: context.params.sessionId,
|
||||
sessionKey: "agent:main:main",
|
||||
storePath,
|
||||
});
|
||||
expect(transcriptEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "message",
|
||||
message: expect.objectContaining({
|
||||
role: "user",
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const beforeRunEvent = requireRecord(
|
||||
callArg(hookRunner.runBeforeAgentRun, 0, 0, "before_agent_run event"),
|
||||
"before_agent_run event",
|
||||
@@ -4499,20 +4526,79 @@ describe("runCliAgent reliability", () => {
|
||||
expect(callArg(hookRunner.runAgentEnd, 0, 1, "agent_end context")).toBeTypeOf("object");
|
||||
expect(JSON.stringify(hookRunner.runAgentEnd.mock.calls)).not.toContain("secret prompt");
|
||||
|
||||
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
|
||||
const blockedLine = JSON.parse(
|
||||
expectDefined(lines[lines.length - 1], "lines[lines.length - 1] test invariant"),
|
||||
const blockedLine = requireRecord(
|
||||
expectDefined(
|
||||
transcriptEvents.find(
|
||||
(entry) => requireRecord(entry, "transcript entry").type === "message",
|
||||
),
|
||||
"blocked transcript message",
|
||||
),
|
||||
"blocked transcript message",
|
||||
);
|
||||
expect(blockedLine.message.content[0].text).toBe(
|
||||
const blockedMessage = requireRecord(blockedLine.message, "blocked message");
|
||||
const blockedContent = requireArray(blockedMessage.content, "blocked content");
|
||||
expect(requireRecord(blockedContent[0], "blocked text").text).toBe(
|
||||
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
|
||||
);
|
||||
expect(JSON.stringify(blockedLine)).not.toContain("secret prompt");
|
||||
expect(JSON.stringify(blockedLine)).not.toContain("matched secret prompt");
|
||||
expect(blockedLine.message["__openclaw"].beforeAgentRunBlocked.blockedBy).toBe(
|
||||
"policy-plugin",
|
||||
const blockedMetadata = requireRecord(blockedMessage["__openclaw"], "blocked metadata");
|
||||
const blockedState = requireRecord(blockedMetadata.beforeAgentRunBlocked, "blocked state");
|
||||
expect(blockedState.blockedBy).toBe("policy-plugin");
|
||||
expect(blockedState).not.toHaveProperty("reason");
|
||||
expect(Object.hasOwn(blockedMetadata, "beforeAgentRunBlocked")).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not rebind a reset session when a stale before_agent_run hook blocks", async () => {
|
||||
supervisorSpawnMock.mockClear();
|
||||
const { dir, sessionFile, storePath } = createSessionFile();
|
||||
const sessionKey = "agent:main:main";
|
||||
const context = buildPreparedContext({
|
||||
sessionKey,
|
||||
runId: "run-blocked-cli-rebound",
|
||||
provider: "claude-cli",
|
||||
model: "opus",
|
||||
});
|
||||
context.params.sessionEntry = { sessionId: "s1", updatedAt: 1 };
|
||||
const hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "before_agent_run"),
|
||||
runBeforeAgentRun: vi.fn(async () => {
|
||||
await upsertSessionEntry(
|
||||
{ agentId: "main", sessionKey, storePath },
|
||||
{ sessionId: "replacement-session", updatedAt: 2 },
|
||||
);
|
||||
return {
|
||||
pluginId: "policy-plugin",
|
||||
decision: {
|
||||
outcome: "block" as const,
|
||||
message: "Blocked after reset.",
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
setHookRunnerForTest(hookRunner);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runPreparedCliAgent({
|
||||
...context,
|
||||
params: {
|
||||
...context.params,
|
||||
agentId: "main",
|
||||
sessionFile,
|
||||
storePath,
|
||||
workspaceDir: dir,
|
||||
prompt: "secret prompt",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({ meta: { livenessState: "blocked" } });
|
||||
|
||||
expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })?.sessionId).toBe(
|
||||
"replacement-session",
|
||||
);
|
||||
expect(blockedLine.message["__openclaw"].beforeAgentRunBlocked).not.toHaveProperty("reason");
|
||||
expect(Object.hasOwn(blockedLine.message["__openclaw"], "beforeAgentRunBlocked")).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
import { setReplyPayloadMetadata, type ReplyPayload } from "../auto-reply/reply-payload.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { patchSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
import { appendExactAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
|
||||
import { buildGenericCliContextEngineHostSupport } from "../context-engine/host-compat.js";
|
||||
import {
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
} from "../plugins/hook-agent-context.js";
|
||||
import { resolveBlockMessage } from "../plugins/hook-decision-types.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import { isHeartbeatLifecycleRunKind } from "./bootstrap-mode.js";
|
||||
import {
|
||||
resolveCliRuntimeArtifactFingerprint,
|
||||
@@ -889,7 +892,44 @@ export async function runPreparedCliAgent(
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
const sessionKey = params.sessionKey?.trim() || params.sessionId;
|
||||
const agentId = params.agentId ?? resolveAgentIdFromSessionKey(sessionKey);
|
||||
let sessionManager = params.sessionManager;
|
||||
if (!sessionManager) {
|
||||
const sessionTarget = params.sessionTarget ?? {
|
||||
agentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey,
|
||||
storePath:
|
||||
params.storePath ??
|
||||
resolveStorePath(params.config?.session?.store, {
|
||||
agentId,
|
||||
}),
|
||||
};
|
||||
const persistedEntry = await patchSessionEntry(
|
||||
sessionTarget,
|
||||
(entry, patchContext) => {
|
||||
if (patchContext.existingEntry && entry.sessionId !== sessionTarget.sessionId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sessionId: sessionTarget.sessionId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
{
|
||||
fallbackEntry: params.sessionEntry
|
||||
? undefined
|
||||
: { sessionId: sessionTarget.sessionId, updatedAt: Date.now() },
|
||||
skipMaintenance: true,
|
||||
},
|
||||
);
|
||||
if (persistedEntry?.sessionId !== sessionTarget.sessionId) {
|
||||
// Skip only this stale blocked-message write; the outer runner still returns blocked.
|
||||
return;
|
||||
}
|
||||
sessionManager = SessionManager.open(sessionTarget);
|
||||
}
|
||||
sessionManager.appendMessage(
|
||||
redactedUserMessage as Parameters<typeof sessionManager.appendMessage>[0],
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { FastMode } from "../../auto-reply/thinking.shared.js";
|
||||
import type { InboundEventKind } from "../../channels/inbound-event/kind.js";
|
||||
import type { CliSessionBinding, SessionEntry } from "../../config/sessions.js";
|
||||
import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js";
|
||||
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { ContextEngine } from "../../context-engine/types.js";
|
||||
@@ -39,6 +40,7 @@ import type {
|
||||
import type { ExecPolicyOverrides } from "../exec-defaults.js";
|
||||
import type { FastModeAutoProgressState } from "../fast-mode.js";
|
||||
import type { ScheduledToolPolicyContext } from "../scheduled-tool-policy.js";
|
||||
import type { SessionManager } from "../sessions/index.js";
|
||||
import type { SilentReplyPromptMode } from "../system-prompt.types.js";
|
||||
|
||||
type CliSessionRetryParams = {
|
||||
@@ -49,8 +51,11 @@ type CliSessionRetryParams = {
|
||||
|
||||
/** Input contract for one CLI-backed agent run. */
|
||||
export type RunCliAgentParams = {
|
||||
/** Caller-owned in-memory transcript for ephemeral helper runs. */
|
||||
sessionManager?: SessionManager;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
sessionTarget?: SessionTranscriptRuntimeTarget;
|
||||
/** Session identity used only for sandbox and tool-policy resolution. */
|
||||
runtimePolicySessionKey?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
|
||||
@@ -4,6 +4,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import {
|
||||
formatSqliteSessionFileMarker,
|
||||
parseSqliteSessionFileMarker,
|
||||
} from "../../config/sessions/legacy-sqlite-marker.js";
|
||||
import {
|
||||
appendTranscriptEvent,
|
||||
appendTranscriptMessage,
|
||||
@@ -11,10 +15,6 @@ import {
|
||||
loadTranscriptEvents,
|
||||
replaceSessionEntry,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import {
|
||||
formatSqliteSessionFileMarker,
|
||||
parseSqliteSessionFileMarker,
|
||||
} from "../../config/sessions/sqlite-marker.js";
|
||||
import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
|
||||
@@ -162,8 +162,12 @@ async function persistCliTranscriptEntry(
|
||||
return result.sessionEntry;
|
||||
}
|
||||
|
||||
async function readSessionMessages(sessionFile: string) {
|
||||
return (await readTranscriptEntries(sessionFile))
|
||||
type TranscriptReadTarget =
|
||||
| string
|
||||
| { agentId: string; sessionId: string; sessionKey: string; storePath: string };
|
||||
|
||||
async function readSessionMessages(target: TranscriptReadTarget) {
|
||||
return (await readTranscriptEntries(target))
|
||||
.filter((entry) => entry.type === "message")
|
||||
.map(
|
||||
(entry) =>
|
||||
@@ -171,19 +175,23 @@ async function readSessionMessages(sessionFile: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function readSessionFileEntries(sessionFile: string) {
|
||||
async function readSessionFileEntries(target: TranscriptReadTarget) {
|
||||
return await readTranscriptEntries<{
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
cwd?: string;
|
||||
message?: { role?: string };
|
||||
}>(sessionFile);
|
||||
}>(target);
|
||||
}
|
||||
|
||||
async function readTranscriptEntries<T extends { type?: string; message?: unknown }>(
|
||||
sessionFile: string,
|
||||
target: TranscriptReadTarget,
|
||||
): Promise<T[]> {
|
||||
if (typeof target !== "string") {
|
||||
return (await loadTranscriptEvents(target)) as T[];
|
||||
}
|
||||
const sessionFile = target;
|
||||
const marker = parseSqliteSessionFileMarker(sessionFile);
|
||||
if (marker) {
|
||||
return (await loadTranscriptEvents({
|
||||
@@ -1683,16 +1691,14 @@ describe("CLI attempt execution", () => {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
const updatedSessionFile = updatedEntry?.sessionFile;
|
||||
if (!updatedSessionFile) {
|
||||
throw new Error("expected CLI transcript persistence to create a session file");
|
||||
}
|
||||
expect(parseSqliteSessionFileMarker(updatedSessionFile)).toMatchObject({
|
||||
expect(updatedEntry).not.toHaveProperty("sessionFile");
|
||||
const target = {
|
||||
agentId: "main",
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
const entries = await readSessionFileEntries(updatedSessionFile);
|
||||
};
|
||||
const entries = await readSessionFileEntries(target);
|
||||
expectRecordFields(requireRecord(entries[0], "session entry"), {
|
||||
type: "session",
|
||||
id: sessionEntry.sessionId,
|
||||
@@ -1706,7 +1712,7 @@ describe("CLI attempt execution", () => {
|
||||
type: "message",
|
||||
parentId: entries[1]?.id,
|
||||
});
|
||||
const messages = await readSessionMessages(updatedSessionFile);
|
||||
const messages = await readSessionMessages(target);
|
||||
expect(messages).toHaveLength(2);
|
||||
expectRecordFields(requireRecord(messages[0], "user message"), {
|
||||
role: "user",
|
||||
@@ -1721,7 +1727,7 @@ describe("CLI attempt execution", () => {
|
||||
});
|
||||
|
||||
const persisted = readSessionStore();
|
||||
expect(persisted[sessionKey]?.sessionFile).toBe(updatedSessionFile);
|
||||
expect(persisted[sessionKey]).not.toHaveProperty("sessionFile");
|
||||
expect(persisted[sessionKey]?.updatedAt).toBeGreaterThan(sessionEntry.updatedAt);
|
||||
expect(persisted[sessionKey]?.updatedAt).toBeLessThanOrEqual(nowCalls.at(-1) ?? 0);
|
||||
expect(sessionStore[sessionKey]?.updatedAt).toBe(persisted[sessionKey]?.updatedAt);
|
||||
@@ -1940,7 +1946,13 @@ describe("CLI attempt execution", () => {
|
||||
embeddedAssistantGapFill: true,
|
||||
});
|
||||
|
||||
let messages = await readSessionMessages(updatedFirst?.sessionFile ?? "");
|
||||
const target = {
|
||||
agentId: "main",
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
};
|
||||
let messages = await readSessionMessages(target);
|
||||
expect(messages).toHaveLength(1);
|
||||
expectRecordFields(requireRecord(messages[0], "assistant message"), {
|
||||
role: "assistant",
|
||||
@@ -1961,7 +1973,7 @@ describe("CLI attempt execution", () => {
|
||||
embeddedAssistantGapFill: true,
|
||||
});
|
||||
|
||||
messages = await readSessionMessages(updatedFirst?.sessionFile ?? "");
|
||||
messages = await readSessionMessages(target);
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1995,11 +2007,6 @@ describe("CLI attempt execution", () => {
|
||||
config: {},
|
||||
embeddedAssistantGapFill: true,
|
||||
});
|
||||
const sessionFile = updatedFirst?.sessionFile;
|
||||
if (typeof sessionFile !== "string") {
|
||||
throw new Error("Expected CLI transcript session file.");
|
||||
}
|
||||
|
||||
await appendTranscriptEvent(
|
||||
{ agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath },
|
||||
{
|
||||
@@ -2027,7 +2034,12 @@ describe("CLI attempt execution", () => {
|
||||
embeddedAssistantGapFill: true,
|
||||
});
|
||||
|
||||
const messages = await readSessionMessages(sessionFile);
|
||||
const messages = await readSessionMessages({
|
||||
agentId: "main",
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
expect(messages).toHaveLength(1);
|
||||
expectRecordFields(requireRecord(messages[0], "assistant message"), {
|
||||
role: "assistant",
|
||||
@@ -2065,12 +2077,7 @@ describe("CLI attempt execution", () => {
|
||||
config: {},
|
||||
embeddedAssistantGapFill: true,
|
||||
});
|
||||
const sessionFile = updatedFirst?.sessionFile;
|
||||
if (typeof sessionFile !== "string") {
|
||||
throw new Error("Expected CLI transcript session file.");
|
||||
}
|
||||
const persistedFirst = readSessionStore();
|
||||
expect(persistedFirst[sessionKey]?.sessionFile).toBe(sessionFile);
|
||||
expect(updatedFirst).not.toHaveProperty("sessionFile");
|
||||
|
||||
await appendTranscriptMessage(
|
||||
{ agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath },
|
||||
@@ -2098,9 +2105,14 @@ describe("CLI attempt execution", () => {
|
||||
embeddedAssistantGapFill: true,
|
||||
});
|
||||
|
||||
const messages = await readSessionMessages(sessionFile);
|
||||
expect(messages).toHaveLength(3);
|
||||
const messages = await readSessionMessages({
|
||||
agentId: "main",
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
expect(messages.map((message) => message.role)).toEqual(["assistant", "user", "assistant"]);
|
||||
expect(messages).toHaveLength(3);
|
||||
expectRecordFields(requireRecord(messages[2], "deduped assistant message"), {
|
||||
content: [{ type: "text", text: "same answer" }],
|
||||
});
|
||||
@@ -2115,7 +2127,7 @@ describe("CLI attempt execution", () => {
|
||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
||||
await writeSessionStoreSeed(sessionStore);
|
||||
|
||||
const updatedEntry = await persistCliTranscriptEntry({
|
||||
await persistCliTranscriptEntry({
|
||||
body: [
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"secret runtime context",
|
||||
@@ -2135,7 +2147,12 @@ describe("CLI attempt execution", () => {
|
||||
config: {},
|
||||
});
|
||||
|
||||
const messages = await readSessionMessages(updatedEntry?.sessionFile ?? "");
|
||||
const messages = await readSessionMessages({
|
||||
agentId: "main",
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
expectRecordFields(requireRecord(messages[0], "transcript user message"), {
|
||||
role: "user",
|
||||
content: "visible ask",
|
||||
|
||||
@@ -315,11 +315,13 @@ async function persistTextTurnTranscript(
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
shouldAppend: async ({ sessionFile }: { sessionFile: string }) => {
|
||||
shouldAppend: async (
|
||||
context: import("../../config/sessions/session-accessor.js").SessionTranscriptTurnWriteContext,
|
||||
) => {
|
||||
if (!params.embeddedAssistantGapFill) {
|
||||
return true;
|
||||
}
|
||||
const latest = await readTailAssistantTextFromSessionTranscript(sessionFile, {
|
||||
const latest = await readTailAssistantTextFromSessionTranscript(context, {
|
||||
excludeTranscriptOnlyOpenClawAssistant: true,
|
||||
});
|
||||
const normalizedReply = normalizeTranscriptMirrorText(replyText);
|
||||
|
||||
@@ -156,6 +156,7 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
sessionKey: "agent:main:no-compactable-entries",
|
||||
sessionEntry,
|
||||
sessionAgentId: "main",
|
||||
storePath: path.join(tmpDir, "sessions.json"),
|
||||
workspaceDir: tmpDir,
|
||||
agentDir: tmpDir,
|
||||
provider: "test-provider",
|
||||
@@ -185,7 +186,7 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
sessionFile,
|
||||
sessionFile: sessionKey,
|
||||
contextTokens: 1_000,
|
||||
totalTokens: 950,
|
||||
totalTokensFresh: true,
|
||||
@@ -270,7 +271,7 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
expect(maintenanceCall?.reason).toBe("compaction");
|
||||
expect(maintenanceCall?.sessionId).toBe(sessionId);
|
||||
expect(maintenanceCall?.sessionKey).toBe(sessionKey);
|
||||
expect(maintenanceCall?.sessionFile).toBe(sessionFile);
|
||||
expect(maintenanceCall?.sessionFile).toBe(sessionKey);
|
||||
expect(updatedEntry?.compactionCount).toBe(1);
|
||||
// Once OpenClaw rewrites the transcript, external CLI resume ids are stale
|
||||
// and must be cleared so the next turn starts from the compacted prompt.
|
||||
@@ -364,12 +365,11 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
expect(maintenance).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: successorSessionId,
|
||||
sessionFile: `sqlite:main:${successorSessionId}:${storePath}`,
|
||||
sessionFile: sessionKey,
|
||||
}),
|
||||
);
|
||||
expect(recordCliCompactionInStore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
newSessionFile: `sqlite:main:${successorSessionId}:${storePath}`,
|
||||
newSessionId: successorSessionId,
|
||||
tokensAfter: 100,
|
||||
}),
|
||||
@@ -386,7 +386,7 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
sessionFile,
|
||||
sessionFile: sessionKey,
|
||||
contextTokens: 1_000,
|
||||
totalTokens: 950,
|
||||
totalTokensFresh: true,
|
||||
@@ -621,7 +621,7 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
expect(compactAgentHarnessSessionCalls[0]?.[0]).toMatchObject({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionFile,
|
||||
sessionFile: sessionKey,
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokenBudget: 1_000,
|
||||
@@ -1160,7 +1160,6 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
sessionKey,
|
||||
tokensAfter: 42,
|
||||
newSessionId: "session-codex-owned-engine-rotated",
|
||||
newSessionFile: path.join(tmpDir, "session-codex-owned-engine-rotated.jsonl"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1590,17 +1589,17 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
it("initializes built-in context engines before resolving CLI compaction engine", async () => {
|
||||
const sessionKey = "agent:main:cli";
|
||||
const sessionId = "session-cli-init";
|
||||
const sessionFile = path.join(tmpDir, "session-init.jsonl");
|
||||
await writeSessionFile({ sessionFile, sessionId });
|
||||
const storePath = path.join(tmpDir, "sessions-cli-init.json");
|
||||
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
sessionFile,
|
||||
contextTokens: 1_000,
|
||||
totalTokens: 950,
|
||||
totalTokensFresh: true,
|
||||
};
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
await persistSessionEntry({ sessionKey, storePath, entry: sessionEntry });
|
||||
const calls: string[] = [];
|
||||
setCliCompactionTestDeps({
|
||||
ensureContextEnginesInitialized: () => {
|
||||
@@ -1632,6 +1631,8 @@ describe("runCliTurnCompactionLifecycle", () => {
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionAgentId: "main",
|
||||
workspaceDir: tmpDir,
|
||||
agentDir: tmpDir,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js";
|
||||
/**
|
||||
* CLI turn compaction lifecycle.
|
||||
*
|
||||
@@ -62,7 +64,7 @@ type SettingsManagerLike = {
|
||||
setCompactionEnabled?: (enabled: boolean) => void;
|
||||
};
|
||||
type CliCompactionDeps = {
|
||||
openSessionManager: (sessionFile: string) => SessionManagerLike;
|
||||
openSessionManager: (target: SessionTranscriptRuntimeTarget) => SessionManagerLike;
|
||||
ensureContextEnginesInitialized: () => void;
|
||||
resolveContextEngine: (cfg: OpenClawConfig) => Promise<ContextEngine>;
|
||||
createPreparedEmbeddedAgentSettingsManager: (params: {
|
||||
@@ -125,7 +127,7 @@ type CliCompactionRuntimeContextParams = {
|
||||
const log = createSubsystemLogger("agents/cli-compaction");
|
||||
|
||||
const cliCompactionDeps: CliCompactionDeps = {
|
||||
openSessionManager: (sessionFile: string) => SessionManager.open(sessionFile),
|
||||
openSessionManager: (target) => SessionManager.open(target),
|
||||
ensureContextEnginesInitialized: ensureContextEnginesInitializedImpl,
|
||||
resolveContextEngine: resolveContextEngineImpl,
|
||||
createPreparedEmbeddedAgentSettingsManager: createPreparedEmbeddedAgentSettingsManagerImpl,
|
||||
@@ -148,7 +150,7 @@ export function setCliCompactionTestDeps(overrides: Partial<typeof cliCompaction
|
||||
/** Restores production CLI compaction dependencies after tests. */
|
||||
export function resetCliCompactionTestDeps(): void {
|
||||
Object.assign(cliCompactionDeps, {
|
||||
openSessionManager: (sessionFile: string) => SessionManager.open(sessionFile),
|
||||
openSessionManager: (target: SessionTranscriptRuntimeTarget) => SessionManager.open(target),
|
||||
ensureContextEnginesInitialized: ensureContextEnginesInitializedImpl,
|
||||
resolveContextEngine: resolveContextEngineImpl,
|
||||
createPreparedEmbeddedAgentSettingsManager: createPreparedEmbeddedAgentSettingsManagerImpl,
|
||||
@@ -252,6 +254,7 @@ function buildCliCompactionRuntimeContext(params: CliCompactionRuntimeContextPar
|
||||
}
|
||||
|
||||
async function resolveCliContextCompactionSuccess(params: {
|
||||
agentId: string;
|
||||
cfg: OpenClawConfig;
|
||||
compactResult: Awaited<ReturnType<ContextEngine["compact"]>>;
|
||||
sessionFile: string;
|
||||
@@ -291,16 +294,26 @@ async function resolveCliContextCompactionSuccess(params: {
|
||||
params.storePath && !resultSessionTarget?.storePath ? { storePath: params.storePath } : {},
|
||||
),
|
||||
});
|
||||
if (
|
||||
resolvedTarget.agentId !== params.agentId ||
|
||||
resolvedTarget.sessionKey !== params.sessionKey ||
|
||||
(params.storePath && path.resolve(resolvedTarget.storePath) !== path.resolve(params.storePath))
|
||||
) {
|
||||
throw new Error(
|
||||
"CLI context compaction cannot adopt a successor outside the active session binding",
|
||||
);
|
||||
}
|
||||
return {
|
||||
maintenanceSessionFile: resolvedTarget.sessionFile,
|
||||
maintenanceSessionFile: resolvedTarget.sessionKey,
|
||||
maintenanceSessionId: resolvedTarget.sessionId,
|
||||
successorSessionFile: resolvedTarget.sessionFile,
|
||||
successorSessionFile: resolvedTarget.sessionKey,
|
||||
successorSessionId: resolvedTarget.sessionId,
|
||||
...(result?.tokensAfter !== undefined ? { tokensAfter: result.tokensAfter } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function compactCliTranscript(params: {
|
||||
agentId: string;
|
||||
contextEngine: ContextEngine;
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
@@ -414,6 +427,7 @@ async function compactCliTranscript(params: {
|
||||
}
|
||||
|
||||
const successor = await resolveCliContextCompactionSuccess({
|
||||
agentId: params.agentId,
|
||||
cfg: params.cfg,
|
||||
compactResult,
|
||||
sessionFile: params.sessionFile,
|
||||
@@ -606,13 +620,18 @@ export async function runCliTurnCompactionLifecycle(params: {
|
||||
thinkLevel?: Parameters<typeof buildEmbeddedCompactionRuntimeContext>[0]["thinkLevel"];
|
||||
extraSystemPrompt?: string;
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
const sessionFile = params.sessionEntry?.sessionFile;
|
||||
const contextTokenBudget = resolvePositiveInteger(params.sessionEntry?.contextTokens);
|
||||
if (!sessionFile || !contextTokenBudget) {
|
||||
if (!params.storePath || !contextTokenBudget) {
|
||||
return params.sessionEntry;
|
||||
}
|
||||
|
||||
const sessionManager = cliCompactionDeps.openSessionManager(sessionFile);
|
||||
const sessionManager = cliCompactionDeps.openSessionManager({
|
||||
agentId: params.sessionAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
const sessionFile = params.sessionKey;
|
||||
const settingsManager = await cliCompactionDeps.createPreparedEmbeddedAgentSettingsManager({
|
||||
cwd: params.cwd ?? params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
@@ -734,6 +753,7 @@ export async function runCliTurnCompactionLifecycle(params: {
|
||||
await applyAutoCompactionGuard(contextEngine);
|
||||
|
||||
const contextOutcome = await compactCliTranscript({
|
||||
agentId: params.sessionAgentId,
|
||||
contextEngine,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -796,9 +816,6 @@ export async function runCliTurnCompactionLifecycle(params: {
|
||||
nativeCompactionResult?.result?.tokensAfter ?? contextCompactionOutcome?.tokensAfter,
|
||||
newSessionId:
|
||||
nativeCompactionResult?.result?.sessionId ?? contextCompactionOutcome?.successorSessionId,
|
||||
newSessionFile:
|
||||
nativeCompactionResult?.result?.sessionFile ??
|
||||
contextCompactionOutcome?.successorSessionFile,
|
||||
expectedSessionId: params.sessionId,
|
||||
})) ?? params.sessionEntry
|
||||
);
|
||||
|
||||
@@ -131,6 +131,16 @@ export async function runEmbeddedAgentAttempt(params: {
|
||||
})
|
||||
: undefined;
|
||||
params.trackInternalModelRunTarget(internalSessionTarget);
|
||||
const attemptSessionTarget =
|
||||
internalSessionTarget ??
|
||||
(sessionKey && storePath
|
||||
? {
|
||||
agentId: sessionAgentId,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
}
|
||||
: undefined);
|
||||
const attemptSessionFile = internalSessionTarget?.sessionFile ?? sessionFile;
|
||||
|
||||
const startedAt = Date.now();
|
||||
@@ -382,7 +392,7 @@ export async function runEmbeddedAgentAttempt(params: {
|
||||
agentHarnessRuntimeOverride,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
...(internalSessionTarget ? { sessionTarget: internalSessionTarget } : {}),
|
||||
...(attemptSessionTarget ? { sessionTarget: attemptSessionTarget } : {}),
|
||||
sessionAgentId,
|
||||
sessionFile: attemptSessionFile,
|
||||
workspaceDir,
|
||||
|
||||
@@ -324,6 +324,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
sessionFile: path.join(path.dirname(storePath), "legacy-predecessor.jsonl"),
|
||||
updatedAt: 1,
|
||||
},
|
||||
};
|
||||
@@ -396,11 +397,11 @@ describe("updateSessionStoreAfterAgentRun", () => {
|
||||
|
||||
expect(sessionStore[sessionKey]).toMatchObject({
|
||||
sessionId: rotatedSessionId,
|
||||
sessionFile: rotatedSessionFile,
|
||||
usageFamilyKey: sessionKey,
|
||||
usageFamilySessionIds: [sessionId, rotatedSessionId],
|
||||
compactionCount: 1,
|
||||
});
|
||||
expect(sessionStore[sessionKey]).not.toHaveProperty("sessionFile");
|
||||
expect(sessionStore[sessionKey]?.sessionStartedAt).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -2785,17 +2786,15 @@ describe("recordCliCompactionInStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists successor session handles from native CLI compaction", async () => {
|
||||
await withTempSessionStore(async ({ dir, storePath }) => {
|
||||
it("persists successor session ids from native CLI compaction", async () => {
|
||||
await withTempSessionStore(async ({ storePath }) => {
|
||||
const sessionKey = "agent:main:explicit:test-record-cli-compaction-rotate";
|
||||
const sessionId = "test-record-cli-compaction-rotate-session";
|
||||
const nextSessionId = "test-record-cli-compaction-rotate-next";
|
||||
const nextSessionFile = path.join(dir, `${nextSessionId}.jsonl`);
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: 1,
|
||||
sessionFile: path.join(dir, `${sessionId}.jsonl`),
|
||||
},
|
||||
};
|
||||
await seedSessionStore(storePath, sessionStore);
|
||||
@@ -2806,17 +2805,16 @@ describe("recordCliCompactionInStore", () => {
|
||||
sessionStore,
|
||||
storePath,
|
||||
newSessionId: nextSessionId,
|
||||
newSessionFile: nextSessionFile,
|
||||
});
|
||||
|
||||
expect(sessionStore[sessionKey]?.sessionId).toBe(nextSessionId);
|
||||
expect(sessionStore[sessionKey]?.sessionFile).toBe(nextSessionFile);
|
||||
expect(sessionStore[sessionKey]?.usageFamilyKey).toBe(sessionKey);
|
||||
expect(sessionStore[sessionKey]?.usageFamilySessionIds).toEqual([sessionId, nextSessionId]);
|
||||
expect(sessionStore[sessionKey]).not.toHaveProperty("sessionFile");
|
||||
|
||||
const persisted = loadPersistedSessionStore(storePath);
|
||||
expect(persisted[sessionKey]?.sessionId).toBe(nextSessionId);
|
||||
expect(persisted[sessionKey]?.sessionFile).toBe(nextSessionFile);
|
||||
expect(persisted[sessionKey]).not.toHaveProperty("sessionFile");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
* Updates persisted session metadata after agent command runs.
|
||||
*/
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
resolveCompactionSessionFile,
|
||||
setSessionRuntimeModel,
|
||||
type SessionEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import { setSessionRuntimeModel, type SessionEntry } from "../../config/sessions.js";
|
||||
import { patchSessionEntry } from "../../config/sessions/session-accessor.js";
|
||||
import { projectSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
|
||||
import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
import { resolveNonNegativeNumber } from "../../shared/number-coercion.js";
|
||||
import { resolveDefaultAgentId } from "../agent-scope.js";
|
||||
import {
|
||||
clearCliSession,
|
||||
getCliSessionBinding,
|
||||
@@ -104,7 +99,6 @@ export async function updateSessionStoreAfterAgentRun(params: {
|
||||
const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider;
|
||||
const agentHarnessId = normalizeOptionalString(result.meta.agentMeta?.agentHarnessId);
|
||||
const activeSessionFile = normalizeOptionalString(result.meta.agentMeta?.sessionFile);
|
||||
const runtimeContextTokens = resolvePositiveInteger(result.meta.agentMeta?.contextTokens);
|
||||
const contextBudgetStatus = result.meta.agentMeta?.contextBudgetStatus;
|
||||
const contextTokens =
|
||||
@@ -141,21 +135,11 @@ export async function updateSessionStoreAfterAgentRun(params: {
|
||||
}),
|
||||
};
|
||||
if (entry.sessionId !== sessionId) {
|
||||
next.sessionFile =
|
||||
activeSessionFile ??
|
||||
resolveCompactionSessionFile({
|
||||
entry,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultAgentId: resolveDefaultAgentId(cfg),
|
||||
newSessionId: sessionId,
|
||||
});
|
||||
delete (next as { sessionFile?: unknown }).sessionFile;
|
||||
next.usageFamilyKey = entry.usageFamilyKey ?? sessionKey;
|
||||
next.usageFamilySessionIds = Array.from(
|
||||
new Set([...(entry.usageFamilySessionIds ?? []), entry.sessionId, sessionId]),
|
||||
);
|
||||
} else if (activeSessionFile) {
|
||||
next.sessionFile = activeSessionFile;
|
||||
}
|
||||
if (preserveRuntimeModel) {
|
||||
// Keep the pre-existing runtime model and context window so a background
|
||||
@@ -503,7 +487,6 @@ export async function recordCliCompactionInStore(params: {
|
||||
storePath: string;
|
||||
tokensAfter?: number;
|
||||
newSessionId?: string;
|
||||
newSessionFile?: string;
|
||||
expectedSessionId?: string;
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
const { provider, sessionKey, sessionStore, storePath, expectedSessionId } = params;
|
||||
@@ -517,27 +500,14 @@ export async function recordCliCompactionInStore(params: {
|
||||
next.compactionCount = (entry.compactionCount ?? 0) + 1;
|
||||
next.updatedAt = Date.now();
|
||||
const newSessionId = normalizeOptionalString(params.newSessionId);
|
||||
const explicitNewSessionFile = normalizeOptionalString(params.newSessionFile);
|
||||
const sessionIdChanged = Boolean(newSessionId && newSessionId !== entry.sessionId);
|
||||
const sessionFileChanged = Boolean(
|
||||
explicitNewSessionFile && explicitNewSessionFile !== entry.sessionFile,
|
||||
);
|
||||
if (sessionIdChanged && newSessionId) {
|
||||
delete (next as { sessionFile?: unknown }).sessionFile;
|
||||
next.sessionId = newSessionId;
|
||||
next.sessionFile =
|
||||
explicitNewSessionFile ??
|
||||
resolveCompactionSessionFile({
|
||||
entry,
|
||||
sessionKey,
|
||||
storePath,
|
||||
newSessionId,
|
||||
});
|
||||
next.usageFamilyKey = entry.usageFamilyKey ?? sessionKey;
|
||||
next.usageFamilySessionIds = Array.from(
|
||||
new Set([...(entry.usageFamilySessionIds ?? []), entry.sessionId, newSessionId]),
|
||||
);
|
||||
} else if (sessionFileChanged && explicitNewSessionFile) {
|
||||
next.sessionFile = explicitNewSessionFile;
|
||||
}
|
||||
const tokensAfterCompaction = resolveNonNegativeNumber(params.tokensAfter);
|
||||
next.contextBudgetStatus = undefined;
|
||||
|
||||
@@ -369,6 +369,7 @@ export function resolveSession(opts: {
|
||||
...resolveSessionLifecycleTimestamps({
|
||||
entry: sessionEntry,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
}),
|
||||
now,
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// Covers bootstrap context rendering, truncation, and transcript header setup.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
ensureSessionHeader,
|
||||
resolveBootstrapMaxChars,
|
||||
resolveBootstrapPromptTruncationWarningMode,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
@@ -32,23 +28,6 @@ const createLargeBootstrapFiles = (): WorkspaceBootstrapFile[] => [
|
||||
makeFile({ name: "USER.md", path: "/tmp/USER.md", content: "c".repeat(10_000) }),
|
||||
];
|
||||
|
||||
describe("ensureSessionHeader", () => {
|
||||
it("creates transcript files with restrictive permissions", async () => {
|
||||
// Session transcripts can contain private prompts and tool outputs, so both
|
||||
// the directory and file need restrictive permissions from creation.
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-header-"));
|
||||
try {
|
||||
const sessionFile = path.join(tempDir, "nested", "session.jsonl");
|
||||
await ensureSessionHeader({ sessionFile, sessionId: "session-1", cwd: tempDir });
|
||||
|
||||
expect((await fs.stat(path.dirname(sessionFile))).mode & 0o777).toBe(0o700);
|
||||
expect((await fs.stat(sessionFile)).mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBootstrapContextFiles", () => {
|
||||
it("keeps missing markers", () => {
|
||||
const files = [makeFile({ missing: true, content: undefined })];
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
export {
|
||||
buildBootstrapContextFiles,
|
||||
ensureSessionHeader,
|
||||
resolveBootstrapMaxChars,
|
||||
resolveBootstrapPromptTruncationWarningMode,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Builds and sanitizes bootstrap context inserted into embedded-agent sessions.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { sanitizeGoogleAssistantFirstOrdering } from "../../shared/google-turn-ordering.js";
|
||||
@@ -379,33 +377,6 @@ function clampToBudget(content: string, budget: number): string {
|
||||
return `${truncateUtf16Safe(content, safe)}…`;
|
||||
}
|
||||
|
||||
export async function ensureSessionHeader(params: {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
}) {
|
||||
const file = params.sessionFile;
|
||||
try {
|
||||
await fs.stat(file);
|
||||
return;
|
||||
} catch {
|
||||
// create
|
||||
}
|
||||
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const sessionVersion = 2;
|
||||
const entry = {
|
||||
type: "session",
|
||||
version: sessionVersion,
|
||||
id: params.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: params.cwd,
|
||||
};
|
||||
await fs.writeFile(file, `${JSON.stringify(entry)}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBootstrapContextFiles(
|
||||
files: WorkspaceBootstrapFile[],
|
||||
opts?: { warn?: (message: string) => void; maxChars?: number; totalMaxChars?: number },
|
||||
|
||||
@@ -186,6 +186,9 @@ beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await import("./embedded-agent-runner/run.js"));
|
||||
const { SessionManager: LoadedSessionManager } =
|
||||
await import("openclaw/plugin-sdk/agent-sessions");
|
||||
const { installSessionManagerFileCompat } =
|
||||
await import("../../test/helpers/session-manager-file-compat.js");
|
||||
installSessionManagerFileCompat(LoadedSessionManager);
|
||||
SessionManager = LoadedSessionManager;
|
||||
e2eWorkspace = await createEmbeddedAgentRunnerTestWorkspace("openclaw-embedded-agent-");
|
||||
({ agentDir, workspaceDir } = e2eWorkspace);
|
||||
@@ -227,7 +230,7 @@ const runWithOrphanedSingleUserMessage = async (text: string, sessionKey: string
|
||||
// Builds a session with an orphaned user message to exercise retry/resume
|
||||
// cleanup paths from persisted JSONL.
|
||||
const sessionFile = nextSessionFile();
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
@@ -1232,7 +1235,7 @@ describe("runEmbeddedAgent", () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const sessionKey = nextSessionKey();
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = SessionManager.openFile(sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "seed user" }],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Covers session-manager guard behavior for tool-result pairing and transcript
|
||||
// redaction.
|
||||
import { readFileSync } from "node:fs";
|
||||
import "../../test/helpers/session-manager-file-compat.js";
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
|
||||
@@ -50,7 +50,13 @@ const runMock = vi.mocked(runWithModelFallback);
|
||||
const baseParams = {
|
||||
sessionId: "test-session",
|
||||
sessionKey: "agent:main:test-session",
|
||||
sessionFile: "/tmp/test-session.jsonl",
|
||||
sessionFile: "agent:main:test-session",
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: "test-session",
|
||||
sessionKey: "agent:main:test-session",
|
||||
storePath: "/tmp/sessions.json",
|
||||
},
|
||||
workspaceDir: "/tmp",
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { clearAgentHarnesses } from "../harness/registry.js";
|
||||
import type { AgentHarness } from "../harness/types.js";
|
||||
import type { ModelAuthMode } from "../model-auth.js";
|
||||
import type { AgentRuntimePlan, BuildAgentRuntimePlanParams } from "../runtime-plan/types.js";
|
||||
import type { CompactionTranscriptRotation } from "./compaction-successor-transcript.js";
|
||||
|
||||
type MockResolvedModel = {
|
||||
model: {
|
||||
@@ -295,7 +294,12 @@ async function runCompactWithSafetyTimeoutMock(
|
||||
}
|
||||
export const compactWithSafetyTimeoutMock = vi.fn(runCompactWithSafetyTimeoutMock);
|
||||
export const rotateTranscriptAfterCompactionMock: Mock<
|
||||
(_params?: unknown) => Promise<CompactionTranscriptRotation>
|
||||
(_params?: unknown) => Promise<{
|
||||
rotated: boolean;
|
||||
sessionId?: string;
|
||||
sessionFile?: string;
|
||||
leafId?: string;
|
||||
}>
|
||||
> = vi.fn(async () => ({
|
||||
rotated: false,
|
||||
}));
|
||||
@@ -781,20 +785,22 @@ export async function loadCompactHooksHarness(): Promise<{
|
||||
resolveSandboxContext: resolveSandboxContextMock,
|
||||
}));
|
||||
|
||||
vi.doMock("../session-file-repair.js", () => ({
|
||||
repairSessionFileIfNeeded: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.doMock("../session-write-lock.js", () => ({
|
||||
acquireSessionWriteLock: acquireSessionWriteLockMock,
|
||||
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
|
||||
resolveSessionWriteLockAcquireTimeoutMs: vi.fn(() => 60_000),
|
||||
resolveSessionWriteLockOptions: vi.fn(() => ({
|
||||
timeoutMs: 60_000,
|
||||
staleMs: 1_800_000,
|
||||
maxHoldMs: 300_000,
|
||||
})),
|
||||
}));
|
||||
vi.doMock("../session-write-lock.js", async () => {
|
||||
const { resolveSessionWriteLockTargetKey } = await vi.importActual<
|
||||
typeof import("../session-write-lock.js")
|
||||
>("../session-write-lock.js");
|
||||
return {
|
||||
acquireSessionWriteLock: acquireSessionWriteLockMock,
|
||||
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
|
||||
resolveSessionWriteLockAcquireTimeoutMs: vi.fn(() => 60_000),
|
||||
resolveSessionWriteLockOptions: vi.fn(() => ({
|
||||
timeoutMs: 60_000,
|
||||
staleMs: 1_800_000,
|
||||
maxHoldMs: 300_000,
|
||||
})),
|
||||
resolveSessionWriteLockTargetKey,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("../../context-engine/init.js", () => ({
|
||||
ensureContextEnginesInitialized: vi.fn(),
|
||||
@@ -936,16 +942,6 @@ export async function loadCompactHooksHarness(): Promise<{
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("./compaction-successor-transcript.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./compaction-successor-transcript.js")>(
|
||||
"./compaction-successor-transcript.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
rotateTranscriptAfterCompaction: rotateTranscriptAfterCompactionMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("./wait-for-idle-before-flush.js", () => ({
|
||||
flushPendingToolResultsAfterIdle: vi.fn(async () => {}),
|
||||
}));
|
||||
@@ -1068,11 +1064,6 @@ export async function loadCompactHooksHarness(): Promise<{
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
}));
|
||||
|
||||
vi.doMock("./session-manager-cache.js", () => ({
|
||||
prewarmSessionFile: vi.fn(async () => {}),
|
||||
trackSessionManagerAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("./system-prompt.js", () => ({
|
||||
applySystemPromptToSession: vi.fn(
|
||||
(session: { setBaseSystemPrompt: (systemPrompt: string) => void }, systemPrompt: string) => {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// Hook integration coverage for direct and queued embedded compaction.
|
||||
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.js";
|
||||
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
|
||||
import {
|
||||
acquireSessionWriteLockMock,
|
||||
applyExtraParamsToAgentMock,
|
||||
@@ -216,7 +221,13 @@ function wrappedCompactionArgs(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sessionId: TEST_SESSION_ID,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath: "/tmp/sessions.json",
|
||||
},
|
||||
workspaceDir: TEST_WORKSPACE_DIR,
|
||||
customInstructions: TEST_CUSTOM_INSTRUCTIONS,
|
||||
enqueue: async <T>(task: () => Promise<T> | T) => await task(),
|
||||
@@ -340,7 +351,11 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
expect(result).toMatchObject({ ok: true, compacted: true });
|
||||
const lockOptions = mockCallArg(acquireSessionWriteLockMock);
|
||||
expect(lockOptions).toMatchObject({ sessionFile: TEST_SESSION_FILE });
|
||||
expect(lockOptions).toMatchObject({
|
||||
sessionFile: expect.any(String),
|
||||
targetKind: "session-key",
|
||||
});
|
||||
expect(lockOptions.sessionFile).not.toBe(TEST_SESSION_KEY);
|
||||
expect(lockOptions).not.toHaveProperty("allowReentrant");
|
||||
});
|
||||
|
||||
@@ -353,8 +368,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
const result = await withOwnedSessionTranscriptWrites(
|
||||
{
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionTarget: wrappedCompactionArgs().sessionTarget,
|
||||
withSessionWriteLock,
|
||||
},
|
||||
async () => await compactEmbeddedAgentSessionDirect(wrappedCompactionArgs()),
|
||||
@@ -368,7 +383,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
it("fails closed before generic compaction for a model-locked native session", async () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
@@ -700,7 +715,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
@@ -721,7 +737,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
@@ -740,7 +757,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
sandboxSessionKey: "agent:main:telegram:default:direct:12345",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
@@ -757,7 +774,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:subagent:worker",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
@@ -777,7 +794,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:codex:acp:worker",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
@@ -802,7 +819,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:marketing-agent:session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
@@ -822,7 +839,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
@@ -968,7 +985,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
it("preserves full sender identity when building compaction tools", async () => {
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
senderId: "sender-1",
|
||||
senderName: "Alice",
|
||||
@@ -994,7 +1012,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
@@ -1005,7 +1024,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
it("uses cwd for compaction runtime tools while preserving workspace bootstrap root", async () => {
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
cwd: "/tmp/task-repo",
|
||||
});
|
||||
@@ -1024,7 +1044,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
it("uses the caller context token budget during runtime compaction", async () => {
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
contextTokenBudget: 64_000,
|
||||
});
|
||||
@@ -1048,7 +1069,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
@@ -1086,7 +1108,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
runId: "run-tool-schema-quarantine",
|
||||
});
|
||||
@@ -1103,7 +1125,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
contextTokenBudget: 64_000,
|
||||
});
|
||||
@@ -1131,7 +1154,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-primary",
|
||||
@@ -1177,7 +1200,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-primary",
|
||||
@@ -1221,7 +1244,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const params = {
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-5.6-sol",
|
||||
@@ -1288,7 +1311,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
@@ -1398,7 +1421,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
@@ -1457,7 +1480,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config: {
|
||||
models: {
|
||||
@@ -1495,7 +1518,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
@@ -1528,7 +1551,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
@@ -1610,7 +1633,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
@@ -1686,7 +1709,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-primary",
|
||||
@@ -1722,7 +1745,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-primary",
|
||||
@@ -1762,7 +1785,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
provider: "openai",
|
||||
model: "gpt-primary",
|
||||
@@ -1832,7 +1855,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
messageCount: 1,
|
||||
tokenCount: 10,
|
||||
compactedCount: 1,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
},
|
||||
expectRecordFields(mockCallArg(hookRunner.runAfterCompaction, 0, 1), {
|
||||
sessionKey: "agent:main:session-1",
|
||||
@@ -1917,7 +1940,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
messageCountAfter: 1,
|
||||
tokensAfter: 10,
|
||||
compactedCount: 1,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
onHookMessages,
|
||||
});
|
||||
|
||||
@@ -1940,80 +1963,14 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
|
||||
|
||||
try {
|
||||
await compactTesting.runPostCompactionSideEffects({
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile: " /tmp/session.jsonl ",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: ` ${TEST_SESSION_KEY} `,
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: "agent:main:session-1",
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits post-compaction side effects once for a rotated successor transcript", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
const listener = vi.fn();
|
||||
const cleanup = onSessionTranscriptUpdate(listener);
|
||||
const sync = vi.fn(async () => {});
|
||||
getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } });
|
||||
rotateTranscriptAfterCompactionMock.mockResolvedValueOnce({
|
||||
rotated: true,
|
||||
sessionId: "rotated-session",
|
||||
sessionFile: "/tmp/rotated-session.jsonl",
|
||||
leafId: "rotated-leaf",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await compactEmbeddedAgentSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
postIndexSync: "await",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionId: "rotated-session",
|
||||
target: {
|
||||
agentId: "main",
|
||||
sessionId: "rotated-session",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
},
|
||||
});
|
||||
expect(sync).toHaveBeenCalledTimes(1);
|
||||
expect(sync).toHaveBeenCalledWith({
|
||||
reason: "post-compaction",
|
||||
sessions: [
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "rotated-session",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
},
|
||||
],
|
||||
});
|
||||
expectRecordFields(mockCallArg(hookRunner.runAfterCompaction), {
|
||||
previousSessionId: "session-1",
|
||||
sessionFile: "/tmp/rotated-session.jsonl",
|
||||
});
|
||||
expectRecordFields(mockCallArg(hookRunner.runAfterCompaction, 0, 1), {
|
||||
sessionId: "rotated-session",
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -2571,7 +2528,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
|
||||
|
||||
expect(mockCallArg(hookRunner.runBeforeCompaction)).toEqual({
|
||||
messageCount: -1,
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
});
|
||||
expectRecordFields(mockCallArg(hookRunner.runBeforeCompaction, 0, 1), {
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
@@ -2581,7 +2538,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
|
||||
messageCount: -1,
|
||||
compactedCount: -1,
|
||||
tokenCount: 50,
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
});
|
||||
expectRecordFields(mockCallArg(hookRunner.runAfterCompaction, 0, 1), {
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
@@ -2611,7 +2568,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expectRecordFields(mockCallArg(hookRunner.runAfterCompaction), {
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
previousSessionId: TEST_SESSION_ID,
|
||||
});
|
||||
expectRecordFields(mockCallArg(hookRunner.runAfterCompaction, 0, 1), {
|
||||
@@ -2683,7 +2640,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
|
||||
)?.runtimeContext;
|
||||
expectRecordFields(mockCallArg(maintain), {
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
});
|
||||
expect(runtimeContext?.workspaceDir).toBe(TEST_WORKSPACE_DIR);
|
||||
expect(runtimeContext?.cwd).toBe("/tmp/task-repo");
|
||||
@@ -3802,7 +3759,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
|
||||
expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: successorSessionId,
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
trigger: "budget",
|
||||
}),
|
||||
{ nativeCompactionRequest: "after_context_engine" },
|
||||
@@ -4133,7 +4090,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
|
||||
{
|
||||
identity: "session file",
|
||||
activeSessionKey: "agent:main:other-session",
|
||||
activeSessionFile: TEST_SESSION_FILE,
|
||||
activeSessionFile: TEST_SESSION_KEY,
|
||||
},
|
||||
])("rejects manual compaction matching an active $identity", async (active) => {
|
||||
const activeSessionId = "other-session";
|
||||
@@ -4240,10 +4197,329 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
|
||||
expect(result.result?.sessionFile).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(maintain), {
|
||||
sessionId: delegatedSessionId,
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
sessionTarget: expect.objectContaining({ sessionId: delegatedSessionId }),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a partial structured successor in the active transcript store", async () => {
|
||||
const maintain = vi.fn(async (_params?: unknown) => ({
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
}));
|
||||
const delegatedSessionId = "delegated-session";
|
||||
const storePath = "/tmp/custom-active-sessions.json";
|
||||
resolveContextEngineMock.mockResolvedValue({
|
||||
info: { ownsCompaction: false },
|
||||
compact: contextEngineCompactMock,
|
||||
maintain,
|
||||
} as never);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
sessionTarget: { sessionId: delegatedSessionId },
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await compactEmbeddedAgentSession(
|
||||
wrappedCompactionArgs({
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expectRecordFields(mockCallArg(maintain), {
|
||||
sessionId: delegatedSessionId,
|
||||
sessionTarget: expect.objectContaining({
|
||||
agentId: "main",
|
||||
sessionId: delegatedSessionId,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["session key", "agent:main:other", "/tmp/active-sessions.json"],
|
||||
["store path", TEST_SESSION_KEY, "/tmp/other-sessions.json"],
|
||||
])(
|
||||
"rejects a structured successor outside the active %s",
|
||||
async (_label, sessionKey, storePath) => {
|
||||
resolveContextEngineMock.mockResolvedValue({
|
||||
info: { ownsCompaction: false },
|
||||
compact: contextEngineCompactMock,
|
||||
} as never);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: "delegated-session",
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
compactEmbeddedAgentSession(
|
||||
wrappedCompactionArgs({
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath: "/tmp/active-sessions.json",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("successor target changed the active session binding");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a deprecated session-key successor outside the active binding", async () => {
|
||||
const delegatedSessionId = "delegated-key-session";
|
||||
const delegatedSessionKey = "agent:main:delegated-key-session";
|
||||
const dir = await mkdtemp(join(tmpdir(), "openclaw-compaction-successor-"));
|
||||
const storePath = join(dir, "sessions.json");
|
||||
resolveContextEngineMock.mockResolvedValue({
|
||||
info: { ownsCompaction: false },
|
||||
compact: contextEngineCompactMock,
|
||||
} as never);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
sessionFile: delegatedSessionKey,
|
||||
},
|
||||
} as never);
|
||||
try {
|
||||
await upsertSessionEntry(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionKey: delegatedSessionKey,
|
||||
storePath,
|
||||
},
|
||||
{ sessionId: delegatedSessionId, updatedAt: 1 },
|
||||
);
|
||||
|
||||
await expect(
|
||||
compactEmbeddedAgentSession(
|
||||
wrappedCompactionArgs({
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("successor target changed the active session binding");
|
||||
} finally {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a deprecated session-key successor with a mismatched stored id", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "openclaw-compaction-successor-mismatch-"));
|
||||
const storePath = join(dir, "sessions.json");
|
||||
const delegatedSessionKey = "agent:main:delegated-key-mismatch";
|
||||
resolveContextEngineMock.mockResolvedValue({
|
||||
info: { ownsCompaction: false },
|
||||
compact: contextEngineCompactMock,
|
||||
} as never);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
sessionFile: delegatedSessionKey,
|
||||
sessionId: "reported-session",
|
||||
},
|
||||
} as never);
|
||||
try {
|
||||
await upsertSessionEntry(
|
||||
{ agentId: "main", sessionKey: delegatedSessionKey, storePath },
|
||||
{ sessionId: "stored-session", updatedAt: 1 },
|
||||
);
|
||||
|
||||
await expect(
|
||||
compactEmbeddedAgentSession(
|
||||
wrappedCompactionArgs({
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("successor identity is inconsistent");
|
||||
} finally {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a deprecated SQLite marker successor for legacy maintenance", async () => {
|
||||
const maintain = vi.fn(async (_params?: unknown) => ({
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
}));
|
||||
const delegatedSessionId = "delegated-marker-session";
|
||||
const storePath = "/tmp/sessions.json";
|
||||
const marker = `sqlite:main:${delegatedSessionId}:${storePath}`;
|
||||
resolveContextEngineMock.mockResolvedValue({
|
||||
info: { ownsCompaction: false },
|
||||
compact: contextEngineCompactMock,
|
||||
maintain,
|
||||
} as never);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
sessionFile: marker,
|
||||
sessionId: delegatedSessionId,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await compactEmbeddedAgentSession(wrappedCompactionArgs());
|
||||
|
||||
expectRecordFields(mockCallArg(maintain), {
|
||||
sessionFile: marker,
|
||||
sessionId: delegatedSessionId,
|
||||
sessionTarget: expect.objectContaining({
|
||||
sessionId: delegatedSessionId,
|
||||
storePath,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a marker successor that contradicts the reported session id", async () => {
|
||||
resolveContextEngineMock.mockResolvedValue({
|
||||
info: { ownsCompaction: false },
|
||||
compact: contextEngineCompactMock,
|
||||
} as never);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
sessionFile: "sqlite:main:marker-session:/tmp/sessions.json",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(compactEmbeddedAgentSession(wrappedCompactionArgs())).rejects.toThrow(
|
||||
"successor identity is inconsistent",
|
||||
);
|
||||
});
|
||||
|
||||
it("rebinds a deprecated SQLite marker successor over the retained active entry", async () => {
|
||||
const maintain = vi.fn(async (_params?: unknown) => ({
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
}));
|
||||
const delegatedSessionId = "delegated-marker-session";
|
||||
const dir = await mkdtemp(join(tmpdir(), "openclaw-compaction-marker-successor-"));
|
||||
const storePath = join(dir, "sessions.json");
|
||||
const marker = `sqlite:main:${delegatedSessionId}:${storePath}`;
|
||||
resolveContextEngineMock.mockResolvedValue({
|
||||
info: { ownsCompaction: false },
|
||||
compact: contextEngineCompactMock,
|
||||
maintain,
|
||||
} as never);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
sessionFile: marker,
|
||||
sessionId: delegatedSessionId,
|
||||
},
|
||||
} as never);
|
||||
try {
|
||||
await upsertSessionEntry(
|
||||
{ agentId: "main", sessionKey: TEST_SESSION_KEY, storePath },
|
||||
{ sessionId: TEST_SESSION_ID, updatedAt: 1 },
|
||||
);
|
||||
|
||||
await compactEmbeddedAgentSession(
|
||||
wrappedCompactionArgs({
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expectRecordFields(mockCallArg(maintain), {
|
||||
sessionId: delegatedSessionId,
|
||||
sessionTarget: expect.objectContaining({
|
||||
agentId: "main",
|
||||
sessionId: delegatedSessionId,
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects conflicting structured successor session ids", async () => {
|
||||
resolveContextEngineMock.mockResolvedValue({
|
||||
info: { ownsCompaction: false },
|
||||
compact: contextEngineCompactMock,
|
||||
} as never);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
sessionId: "top-level-session",
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: "target-session",
|
||||
sessionKey: TEST_SESSION_KEY,
|
||||
storePath: "/tmp/sessions.json",
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(compactEmbeddedAgentSession(wrappedCompactionArgs())).rejects.toThrow(
|
||||
"successor identity is inconsistent",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives queued compaction ownership from a self-contained session target", async () => {
|
||||
await compactEmbeddedAgentSession(
|
||||
wrappedCompactionArgs({
|
||||
agentId: undefined,
|
||||
sessionKey: undefined,
|
||||
sessionTarget: {
|
||||
agentId: "other",
|
||||
sessionId: "other-session",
|
||||
sessionKey: "agent:other:main",
|
||||
storePath: "/tmp/other-sessions.json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolveSessionAgentIdsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: "other", sessionKey: "agent:other:main" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a delegated result that echoes the current transcript on the active transcript", async () => {
|
||||
const maintain = vi.fn(async (_params?: unknown) => ({
|
||||
changed: false,
|
||||
@@ -4287,7 +4563,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
|
||||
expect(result.result?.sessionFile).toBeUndefined();
|
||||
expectRecordFields(mockCallArg(maintain), {
|
||||
sessionId: TEST_SESSION_ID,
|
||||
sessionFile: TEST_SESSION_FILE,
|
||||
sessionFile: TEST_SESSION_KEY,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Queues embedded-agent session compaction onto the correct command lane.
|
||||
*/
|
||||
import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
|
||||
import path from "node:path";
|
||||
import {
|
||||
formatSqliteSessionFileMarker,
|
||||
parseSqliteSessionFileMarker,
|
||||
} from "../../config/sessions/legacy-sqlite-marker.js";
|
||||
import { listSessionEntries, loadSessionEntry } from "../../config/sessions/session-accessor.js";
|
||||
import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js";
|
||||
import { ensureContextEnginesInitialized } from "../../context-engine/init.js";
|
||||
import {
|
||||
@@ -21,6 +26,8 @@ import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
|
||||
import { enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js";
|
||||
import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
|
||||
@@ -48,10 +55,6 @@ import {
|
||||
compactContextEngineWithSafetyTimeout,
|
||||
resolveCompactionTimeoutMs,
|
||||
} from "./compaction-safety-timeout.js";
|
||||
import {
|
||||
rotateTranscriptFileAfterCompaction,
|
||||
shouldRotateCompactionTranscript,
|
||||
} from "./compaction-successor-transcript.js";
|
||||
import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js";
|
||||
import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
|
||||
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||
@@ -128,13 +131,12 @@ function shouldDeferOwningContextEngineBudgetCompaction(params: {
|
||||
function buildContextEngineCompactionSessionTarget(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
): ContextEngineSessionTarget {
|
||||
const sqliteMarker = parseSqliteSessionFileMarker(params.sessionFile);
|
||||
const agentId = params.sessionTarget?.agentId ?? params.agentId ?? sqliteMarker?.agentId;
|
||||
const agentId = params.sessionTarget?.agentId ?? params.agentId;
|
||||
const sessionKey = params.sessionTarget?.sessionKey ?? params.sessionKey ?? params.sessionId;
|
||||
const storePath = params.sessionTarget?.storePath ?? sqliteMarker?.storePath;
|
||||
const storePath = params.sessionTarget?.storePath;
|
||||
return {
|
||||
...(agentId ? { agentId } : {}),
|
||||
sessionId: params.sessionTarget?.sessionId ?? sqliteMarker?.sessionId ?? params.sessionId,
|
||||
sessionId: params.sessionTarget?.sessionId ?? params.sessionId,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(storePath ? { storePath } : {}),
|
||||
...(params.sessionTarget?.threadId !== undefined
|
||||
@@ -240,12 +242,21 @@ function mergeSecondaryNativeHarnessCompactionDetails(params: {
|
||||
export async function compactEmbeddedAgentSession(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
): Promise<EmbeddedAgentCompactResult> {
|
||||
if (params.trigger !== "manual") {
|
||||
return await compactEmbeddedAgentSessionImpl(params);
|
||||
const runtimeTarget = await resolveAgentRunSessionTarget(params);
|
||||
const resolvedParams = {
|
||||
...params,
|
||||
agentId: runtimeTarget.agentId,
|
||||
sessionId: runtimeTarget.sessionId,
|
||||
sessionKey: runtimeTarget.sessionKey,
|
||||
sessionTarget: runtimeTarget,
|
||||
sessionFile: runtimeTarget.sessionKey,
|
||||
};
|
||||
if (resolvedParams.trigger !== "manual") {
|
||||
return await compactEmbeddedAgentSessionImpl(resolvedParams);
|
||||
}
|
||||
// Reply operations and embedded handles are separate lifecycle owners. A
|
||||
// /compact reply may coexist with this handle, but another embedded writer may not.
|
||||
if (resolveManualCompactionActiveRunSessionId(params)) {
|
||||
if (resolveManualCompactionActiveRunSessionId(resolvedParams)) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
@@ -255,8 +266,8 @@ export async function compactEmbeddedAgentSession(
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const abortSignal = params.abortSignal
|
||||
? AbortSignal.any([params.abortSignal, controller.signal])
|
||||
const abortSignal = resolvedParams.abortSignal
|
||||
? AbortSignal.any([resolvedParams.abortSignal, controller.signal])
|
||||
: controller.signal;
|
||||
const handle: EmbeddedAgentQueueHandle = {
|
||||
kind: "embedded",
|
||||
@@ -267,34 +278,53 @@ export async function compactEmbeddedAgentSession(
|
||||
abort: (reason) => controller.abort(reason ?? "user_abort"),
|
||||
cancel: (reason) => controller.abort(reason ?? "user_abort"),
|
||||
};
|
||||
setActiveEmbeddedRun(params.sessionId, handle, params.sessionKey, params.sessionFile);
|
||||
setActiveEmbeddedRun(
|
||||
resolvedParams.sessionId,
|
||||
handle,
|
||||
resolvedParams.sessionKey,
|
||||
resolvedParams.sessionFile,
|
||||
);
|
||||
try {
|
||||
return await compactEmbeddedAgentSessionImpl({
|
||||
...params,
|
||||
...resolvedParams,
|
||||
abortSignal,
|
||||
});
|
||||
} finally {
|
||||
clearActiveEmbeddedRun(params.sessionId, handle, params.sessionKey, params.sessionFile);
|
||||
clearActiveEmbeddedRun(
|
||||
resolvedParams.sessionId,
|
||||
handle,
|
||||
resolvedParams.sessionKey,
|
||||
resolvedParams.sessionFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function compactEmbeddedAgentSessionImpl(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
inputParams: CompactEmbeddedAgentSessionParams,
|
||||
): Promise<EmbeddedAgentCompactResult> {
|
||||
if (params.abortSignal?.aborted) {
|
||||
if (inputParams.abortSignal?.aborted) {
|
||||
return createCompactionAbortedResult();
|
||||
}
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
|
||||
config: inputParams.config,
|
||||
workspaceDir: inputParams.workspaceDir,
|
||||
allowGatewaySubagentBinding: inputParams.allowGatewaySubagentBinding,
|
||||
});
|
||||
ensureContextEnginesInitialized();
|
||||
const runtimeTarget = await resolveAgentRunSessionTarget(inputParams);
|
||||
const agentIds = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: runtimeTarget.sessionKey,
|
||||
config: inputParams.config,
|
||||
agentId: runtimeTarget.agentId,
|
||||
});
|
||||
const params = {
|
||||
...inputParams,
|
||||
agentId: runtimeTarget.agentId,
|
||||
sessionId: runtimeTarget.sessionId,
|
||||
sessionKey: runtimeTarget.sessionKey,
|
||||
sessionTarget: runtimeTarget,
|
||||
sessionFile: runtimeTarget.sessionKey,
|
||||
};
|
||||
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, agentIds.sessionAgentId);
|
||||
const resolvedWorkspaceDir = resolveUserPath(params.workspaceDir);
|
||||
const contextEngine = await resolveContextEngine(params.config, {
|
||||
@@ -328,6 +358,7 @@ async function compactResolvedContextEngine(
|
||||
resolvedWorkspaceDir: string,
|
||||
releaseContextEngineOwnership: () => void,
|
||||
): Promise<EmbeddedAgentCompactResult> {
|
||||
const runtimeTarget = await resolveAgentRunSessionTarget(params);
|
||||
const lockedHarnessRuntime =
|
||||
params.modelSelectionLocked === true
|
||||
? normalizeOptionalAgentRuntimeId(params.agentHarnessId)
|
||||
@@ -543,13 +574,11 @@ async function compactResolvedContextEngine(
|
||||
// Fire before_compaction / after_compaction hooks here so plugin subscribers
|
||||
// are notified regardless of which engine is active.
|
||||
const engineOwnsCompaction = contextEngine.info.ownsCompaction === true;
|
||||
const isSqliteSessionTranscript = Boolean(parseSqliteSessionFileMarker(params.sessionFile));
|
||||
checkpointSnapshot = engineOwnsCompaction
|
||||
? await compactionCheckpointStore.captureSnapshot({
|
||||
sessionFile: params.sessionFile,
|
||||
...(isSqliteSessionTranscript
|
||||
? { sessionManager: SessionManager.open(params.sessionFile) }
|
||||
: {}),
|
||||
sessionManager: SessionManager.open(runtimeTarget),
|
||||
sessionTarget: runtimeTarget,
|
||||
})
|
||||
: null;
|
||||
const hookRunner = engineOwnsCompaction
|
||||
@@ -641,54 +670,154 @@ async function compactResolvedContextEngine(
|
||||
reason: formatErrorMessage(compactErr),
|
||||
};
|
||||
}
|
||||
const reportedSessionId = result.result?.sessionId;
|
||||
const delegatedSuccessor = resolveCompactionSuccessorTranscript(result);
|
||||
const delegatedSessionTarget = result.result?.sessionTarget;
|
||||
const delegatedSessionId = delegatedSuccessor.sessionId;
|
||||
const delegatedSessionFile = delegatedSuccessor.sessionFile;
|
||||
const delegatedRotatedTranscript =
|
||||
(typeof delegatedSessionId === "string" && delegatedSessionId !== params.sessionId) ||
|
||||
(typeof delegatedSessionFile === "string" && delegatedSessionFile !== params.sessionFile);
|
||||
let postCompactionSessionId = delegatedSessionId ?? params.sessionId;
|
||||
// Shipped pre-sessionTarget engines report rotation via the deprecated
|
||||
// sessionFile field; honor it when no typed target is present.
|
||||
let postCompactionSessionFile = delegatedSessionFile ?? params.sessionFile;
|
||||
let postCompactionSessionTarget = runtimeTarget;
|
||||
if (delegatedSessionTarget) {
|
||||
if (
|
||||
reportedSessionId &&
|
||||
delegatedSessionTarget.sessionId &&
|
||||
delegatedSessionTarget.sessionId !== reportedSessionId
|
||||
) {
|
||||
throw new Error("Context-engine successor identity is inconsistent");
|
||||
}
|
||||
const resolvedDelegatedTarget = await resolveAgentRunSessionTarget({
|
||||
agentId: delegatedSessionTarget.agentId ?? sessionAgentId,
|
||||
config: params.config,
|
||||
sessionId: delegatedSessionTarget.sessionId ?? postCompactionSessionId,
|
||||
sessionFile: delegatedSessionFile,
|
||||
sessionKey: delegatedSessionTarget.sessionKey ?? params.sessionKey,
|
||||
sessionTarget: delegatedSessionTarget,
|
||||
sessionTarget: {
|
||||
...delegatedSessionTarget,
|
||||
storePath: delegatedSessionTarget.storePath ?? runtimeTarget.storePath,
|
||||
},
|
||||
});
|
||||
postCompactionSessionId = resolvedDelegatedTarget.sessionId;
|
||||
postCompactionSessionFile = resolvedDelegatedTarget.sessionFile;
|
||||
}
|
||||
let postCompactionLeafId: string | undefined;
|
||||
if (result.ok && result.compacted) {
|
||||
if (
|
||||
shouldRotateCompactionTranscript(params.config) &&
|
||||
!delegatedRotatedTranscript &&
|
||||
!isSqliteSessionTranscript
|
||||
resolvedDelegatedTarget.agentId !== runtimeTarget.agentId ||
|
||||
resolvedDelegatedTarget.sessionKey !== runtimeTarget.sessionKey ||
|
||||
path.resolve(resolvedDelegatedTarget.storePath) !==
|
||||
path.resolve(runtimeTarget.storePath)
|
||||
) {
|
||||
try {
|
||||
const rotation = await rotateTranscriptFileAfterCompaction({
|
||||
sessionFile: params.sessionFile,
|
||||
});
|
||||
if (rotation.rotated) {
|
||||
postCompactionSessionId = rotation.sessionId ?? postCompactionSessionId;
|
||||
postCompactionSessionFile = rotation.sessionFile ?? postCompactionSessionFile;
|
||||
postCompactionLeafId = rotation.leafId;
|
||||
log.info(
|
||||
`[compaction] rotated active transcript after context-engine compaction ` +
|
||||
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("failed to rotate compacted transcript", {
|
||||
errorMessage: formatErrorMessage(err),
|
||||
});
|
||||
}
|
||||
throw new Error("Context-engine successor target changed the active session binding");
|
||||
}
|
||||
postCompactionSessionId = resolvedDelegatedTarget.sessionId;
|
||||
postCompactionSessionFile = resolvedDelegatedTarget.sessionKey;
|
||||
postCompactionSessionTarget = resolvedDelegatedTarget;
|
||||
} else if (delegatedSessionFile) {
|
||||
const marker = parseSqliteSessionFileMarker(delegatedSessionFile);
|
||||
if (
|
||||
marker &&
|
||||
(marker.agentId !== runtimeTarget.agentId ||
|
||||
(delegatedSessionId && marker.sessionId !== delegatedSessionId))
|
||||
) {
|
||||
throw new Error("Legacy context-engine successor identity is inconsistent");
|
||||
}
|
||||
const keyedEntry = delegatedSessionFile.startsWith("agent:")
|
||||
? loadSessionEntry({
|
||||
agentId: runtimeTarget.agentId,
|
||||
sessionKey: delegatedSessionFile,
|
||||
storePath: runtimeTarget.storePath,
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
delegatedSessionFile.startsWith("agent:") &&
|
||||
(resolveAgentIdFromSessionKey(delegatedSessionFile) !== runtimeTarget.agentId ||
|
||||
!keyedEntry?.sessionId ||
|
||||
(delegatedSessionId && keyedEntry.sessionId !== delegatedSessionId))
|
||||
) {
|
||||
throw new Error("Legacy context-engine successor identity is inconsistent");
|
||||
}
|
||||
const keyedSessionId = delegatedSessionFile.startsWith("agent:")
|
||||
? (delegatedSessionId ?? keyedEntry?.sessionId)
|
||||
: undefined;
|
||||
const retainedMarkerEntry = marker
|
||||
? loadSessionEntry({
|
||||
agentId: marker.agentId,
|
||||
sessionKey: runtimeTarget.sessionKey,
|
||||
storePath: marker.storePath,
|
||||
})
|
||||
: undefined;
|
||||
const markerMatches = marker
|
||||
? listSessionEntries({
|
||||
agentId: marker.agentId,
|
||||
storePath: marker.storePath,
|
||||
}).filter(({ entry }) => entry.sessionId === marker.sessionId)
|
||||
: [];
|
||||
const preferredMarkerSessionKey = marker
|
||||
? resolvePreferredSessionKeyForSessionIdMatches(
|
||||
markerMatches.map(({ sessionKey, entry }) => [sessionKey, entry]),
|
||||
marker.sessionId,
|
||||
)
|
||||
: undefined;
|
||||
const markerMappedToRetainedKey = markerMatches.some(
|
||||
({ sessionKey }) => sessionKey === runtimeTarget.sessionKey,
|
||||
);
|
||||
const markerSessionKey = marker
|
||||
? retainedMarkerEntry?.sessionId === marker.sessionId ||
|
||||
(retainedMarkerEntry?.sessionId === runtimeTarget.sessionId &&
|
||||
(markerMatches.length === 0 || markerMappedToRetainedKey))
|
||||
? runtimeTarget.sessionKey
|
||||
: (preferredMarkerSessionKey ??
|
||||
(markerMatches.length === 0 && !retainedMarkerEntry
|
||||
? runtimeTarget.sessionKey
|
||||
: undefined))
|
||||
: undefined;
|
||||
const legacyTarget = marker
|
||||
? markerSessionKey
|
||||
? {
|
||||
...marker,
|
||||
sessionId: marker.sessionId,
|
||||
sessionKey: markerSessionKey,
|
||||
}
|
||||
: undefined
|
||||
: keyedSessionId
|
||||
? {
|
||||
...runtimeTarget,
|
||||
sessionId: keyedSessionId,
|
||||
sessionKey: delegatedSessionFile,
|
||||
}
|
||||
: undefined;
|
||||
if (!legacyTarget) {
|
||||
throw new Error(
|
||||
"Legacy context-engine successor files are unsupported; return a structured sessionTarget",
|
||||
);
|
||||
}
|
||||
const resolvedDelegatedTarget = await resolveAgentRunSessionTarget({
|
||||
agentId: legacyTarget.agentId,
|
||||
config: params.config,
|
||||
sessionId: legacyTarget.sessionId,
|
||||
sessionKey: legacyTarget.sessionKey,
|
||||
sessionTarget: legacyTarget,
|
||||
});
|
||||
if (
|
||||
resolvedDelegatedTarget.agentId !== runtimeTarget.agentId ||
|
||||
resolvedDelegatedTarget.sessionKey !== runtimeTarget.sessionKey ||
|
||||
path.resolve(resolvedDelegatedTarget.storePath) !==
|
||||
path.resolve(runtimeTarget.storePath)
|
||||
) {
|
||||
throw new Error(
|
||||
"Legacy context-engine successor target changed the active session binding",
|
||||
);
|
||||
}
|
||||
postCompactionSessionId = resolvedDelegatedTarget.sessionId;
|
||||
postCompactionSessionFile = marker
|
||||
? formatSqliteSessionFileMarker(resolvedDelegatedTarget)
|
||||
: resolvedDelegatedTarget.sessionKey;
|
||||
postCompactionSessionTarget = resolvedDelegatedTarget;
|
||||
} else if (delegatedSessionId && !delegatedSessionFile) {
|
||||
postCompactionSessionTarget = {
|
||||
...runtimeTarget,
|
||||
sessionId: delegatedSessionId,
|
||||
};
|
||||
}
|
||||
if (result.ok && result.compacted) {
|
||||
checkpointSnapshotRetained = await persistCompactionCheckpoint({
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -700,7 +829,7 @@ async function compactResolvedContextEngine(
|
||||
tokensBefore: result.result?.tokensBefore,
|
||||
tokensAfter: result.result?.tokensAfter,
|
||||
sessionFile: postCompactionSessionFile,
|
||||
leafId: postCompactionLeafId,
|
||||
sessionTarget: postCompactionSessionTarget,
|
||||
});
|
||||
await runContextEngineMaintenance({
|
||||
contextEngine,
|
||||
@@ -710,7 +839,7 @@ async function compactResolvedContextEngine(
|
||||
...params,
|
||||
sessionFile: postCompactionSessionFile,
|
||||
sessionId: postCompactionSessionId,
|
||||
sessionTarget: delegatedSessionTarget ?? params.sessionTarget,
|
||||
sessionTarget: postCompactionSessionTarget,
|
||||
}),
|
||||
sessionFile: postCompactionSessionFile,
|
||||
reason: "compaction",
|
||||
|
||||
@@ -44,7 +44,6 @@ import { resolveEmbeddedCompactionTarget } from "./compaction-runtime-context.js
|
||||
import { prepareCompactionSessionAgent } from "./compaction-session-agent.js";
|
||||
import type { PreparedCompactEmbeddedAgentSessionParams } from "./direct-compaction-preparation.js";
|
||||
import { compactEmbeddedAgentSessionDirectOnce } from "./direct-compaction.js";
|
||||
import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js";
|
||||
import type { EmbeddedAgentCompactResult } from "./types.js";
|
||||
|
||||
export type { CompactEmbeddedAgentSessionParams } from "./compact.types.js";
|
||||
@@ -129,10 +128,11 @@ export async function compactEmbeddedAgentSessionDirect(
|
||||
const runSessionTarget = await resolveAgentRunSessionTarget(paramsBase);
|
||||
const requestedParams: CompactEmbeddedAgentSessionParamsWithSessionFile = {
|
||||
...paramsBase,
|
||||
agentId: paramsBase.agentId ?? runSessionTarget.agentId,
|
||||
agentId: runSessionTarget.agentId,
|
||||
sessionId: runSessionTarget.sessionId,
|
||||
sessionKey: paramsBase.sessionKey ?? runSessionTarget.sessionKey,
|
||||
sessionFile: runSessionTarget.sessionFile,
|
||||
sessionKey: runSessionTarget.sessionKey,
|
||||
sessionTarget: runSessionTarget,
|
||||
sessionFile: runSessionTarget.sessionKey,
|
||||
};
|
||||
const requestedAgentIds = resolveSessionAgentIds({
|
||||
sessionKey: requestedParams.sessionKey,
|
||||
@@ -256,7 +256,6 @@ export const testing = {
|
||||
containsRealConversationMessages,
|
||||
estimateTokensAfterCompaction,
|
||||
buildBeforeCompactionHookMetrics,
|
||||
hardenManualCompactionBoundary,
|
||||
resolveCompactionProviderStream,
|
||||
prepareCompactionSessionAgent,
|
||||
runBeforeCompactionHooks,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js";
|
||||
import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js";
|
||||
/** Owns the shared checkpoint lifecycle around both compaction entry points. */
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ export async function persistCompactionCheckpoint(params: {
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
sessionFile: string;
|
||||
sessionTarget?: SessionTranscriptRuntimeTarget;
|
||||
leafId?: string;
|
||||
createdAt?: number;
|
||||
}): Promise<boolean> {
|
||||
@@ -30,7 +33,9 @@ export async function persistCompactionCheckpoint(params: {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const transcriptState = await readSessionLeafStateFromTranscriptAsync(params.sessionFile);
|
||||
const transcriptState = await readSessionLeafStateFromTranscriptAsync(
|
||||
params.sessionTarget ?? params.sessionFile,
|
||||
);
|
||||
const checkpointPosition = resolveCompactionCheckpointTranscriptPosition({
|
||||
preferredLeafId: params.leafId,
|
||||
transcriptState,
|
||||
@@ -45,7 +50,10 @@ export async function persistCompactionCheckpoint(params: {
|
||||
firstKeptEntryId: params.firstKeptEntryId,
|
||||
tokensBefore: params.tokensBefore,
|
||||
tokensAfter: params.tokensAfter,
|
||||
postSessionFile: params.sessionFile,
|
||||
// Keep the full successor location for cross-key/store checkpoint recovery.
|
||||
postSessionFile: params.sessionTarget
|
||||
? formatSqliteSessionFileMarker(params.sessionTarget)
|
||||
: params.sessionFile,
|
||||
postLeafId: checkpointPosition.leafId,
|
||||
postEntryId: checkpointPosition.entryId,
|
||||
createdAt: params.createdAt,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Regression coverage for pruning duplicate user turns before compaction.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectDuplicateUserMessageEntryIdsForCompaction,
|
||||
dedupeDuplicateUserMessagesForCompaction,
|
||||
} from "./compaction-duplicate-user-messages.js";
|
||||
import { dedupeDuplicateUserMessagesForCompaction } from "./compaction-duplicate-user-messages.js";
|
||||
|
||||
const LONG_PROMPT = "please run the deployment status check for production";
|
||||
|
||||
@@ -65,31 +62,6 @@ describe("compaction duplicate user message pruning", () => {
|
||||
expect(dedupeDuplicateUserMessagesForCompaction([long, longLater])).toEqual([long, longLater]);
|
||||
});
|
||||
|
||||
it("collects duplicate transcript entry ids from active branch entries", () => {
|
||||
const duplicateIds = collectDuplicateUserMessageEntryIdsForCompaction([
|
||||
{
|
||||
id: "entry-1",
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "please run the deployment status check for production",
|
||||
timestamp: 1_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "entry-2",
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "please run the deployment status check for production",
|
||||
timestamp: 2_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(duplicateIds).toEqual(new Set(["entry-2"]));
|
||||
});
|
||||
|
||||
it("keys duplicate retries by sender identity (#98310)", () => {
|
||||
const alice = userMessage({ timestamp: 1_000, senderId: "user-alice" });
|
||||
const bob = userMessage({ timestamp: 2_000, senderId: "user-bob" });
|
||||
@@ -101,17 +73,6 @@ describe("compaction duplicate user message pruning", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps same text from different senders in successor transcript pruning", () => {
|
||||
const alice = userMessage({ timestamp: 1_000, senderId: "user-alice" });
|
||||
const bob = userMessage({ timestamp: 2_000, senderId: "user-bob" });
|
||||
const duplicateIds = collectDuplicateUserMessageEntryIdsForCompaction([
|
||||
{ id: "entry-alice", type: "message", message: alice },
|
||||
{ id: "entry-bob", type: "message", message: bob },
|
||||
]);
|
||||
|
||||
expect(duplicateIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("does not collide when sender ids and text contain the old delimiter", () => {
|
||||
const first = userMessage({
|
||||
content: "b|please run deployment status now",
|
||||
|
||||
@@ -13,12 +13,6 @@ type MessageLike = {
|
||||
__openclaw?: unknown;
|
||||
};
|
||||
|
||||
type EntryLike = {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
type DuplicateUserMessageOptions = {
|
||||
windowMs?: number;
|
||||
};
|
||||
@@ -91,30 +85,3 @@ export function dedupeDuplicateUserMessagesForCompaction<T extends MessageLike>(
|
||||
}
|
||||
return removed > 0 ? result : [...messages];
|
||||
}
|
||||
|
||||
/** Collects session entry ids that should be skipped when building a compaction branch summary. */
|
||||
export function collectDuplicateUserMessageEntryIdsForCompaction(
|
||||
entries: readonly EntryLike[],
|
||||
options: DuplicateUserMessageOptions = {},
|
||||
): Set<string> {
|
||||
const windowMs = options.windowMs ?? DEFAULT_DUPLICATE_USER_MESSAGE_WINDOW_MS;
|
||||
const lastSeenAtByKey = new Map<string, number>();
|
||||
const duplicateIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "message" || typeof entry.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const signature = duplicateSignature(
|
||||
isRecord(entry.message) ? (entry.message as MessageLike) : undefined,
|
||||
);
|
||||
if (!signature) {
|
||||
continue;
|
||||
}
|
||||
const lastSeenAt = lastSeenAtByKey.get(signature.key);
|
||||
lastSeenAtByKey.set(signature.key, signature.timestamp);
|
||||
if (typeof lastSeenAt === "number" && signature.timestamp - lastSeenAt <= windowMs) {
|
||||
duplicateIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
return duplicateIds;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Coverage for building compaction runtime context from active runner state.
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js";
|
||||
import { addSession } from "../bash-process-registry.js";
|
||||
import { createProcessSessionFixture } from "../bash-process-registry.test-helpers.js";
|
||||
import { resetProcessRegistryForTests } from "../bash-process-registry.test-support.js";
|
||||
@@ -10,6 +13,9 @@ import {
|
||||
resolveEmbeddedCompactionThinkingLevel,
|
||||
resolveEmbeddedCompactionTarget,
|
||||
} from "./compaction-runtime-context.js";
|
||||
import { buildContextEngineCompactionSessionTarget } from "./run/session-bootstrap.js";
|
||||
|
||||
const compactionTempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
describe("resolveEmbeddedCompactionThinkingLevel", () => {
|
||||
it("lets the compaction override replace the inherited session level", () => {
|
||||
@@ -745,3 +751,32 @@ describe("buildEmbeddedCompactionRuntimeContext", () => {
|
||||
expect(result.provider).toBe("anthropic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildContextEngineCompactionSessionTarget", () => {
|
||||
it("derives the agent from a scoped session key", () => {
|
||||
expect(
|
||||
buildContextEngineCompactionSessionTarget({
|
||||
config: { session: { store: "/tmp/agents/{agentId}/sessions.json" } },
|
||||
sessionFile: "agent:helper:main",
|
||||
sessionId: "helper-session",
|
||||
sessionKey: "agent:helper:main",
|
||||
}),
|
||||
).toMatchObject({
|
||||
agentId: "helper",
|
||||
sessionKey: "agent:helper:main",
|
||||
storePath: "/tmp/agents/helper/sessions.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the marker session id when its store has no mapped key yet", () => {
|
||||
const storePath = path.join(compactionTempDirs.make("compaction-marker-"), "sessions.json");
|
||||
const sessionId = "legacy-unmapped-session";
|
||||
|
||||
expect(
|
||||
buildContextEngineCompactionSessionTarget({
|
||||
sessionFile: formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }),
|
||||
sessionId,
|
||||
}),
|
||||
).toMatchObject({ agentId: "main", sessionId, sessionKey: sessionId, storePath });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js";
|
||||
import { acquireOwnedSessionTranscriptWriteLock } from "../../config/sessions/transcript-write-context.js";
|
||||
/**
|
||||
* Executes compaction while owning the transcript lock, session lifecycle,
|
||||
@@ -19,12 +20,13 @@ import {
|
||||
isSilentOverflowProneModel,
|
||||
} from "../agent-settings.js";
|
||||
import { pickFallbackThinkingLevel } from "../embedded-agent-helpers.js";
|
||||
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
|
||||
import { resolveAgentRunSessionTarget } from "../run-session-target.js";
|
||||
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
|
||||
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
|
||||
import {
|
||||
acquireSessionWriteLock,
|
||||
resolveSessionLockMaxHoldFromTimeout,
|
||||
resolveSessionWriteLockTargetKey,
|
||||
resolveSessionWriteLockOptions,
|
||||
} from "../session-write-lock.js";
|
||||
import { createAgentSession, estimateTokens, SessionManager } from "../sessions/index.js";
|
||||
@@ -51,24 +53,16 @@ import {
|
||||
resolveCompactionTimeoutMs,
|
||||
} from "./compaction-safety-timeout.js";
|
||||
import { prepareCompactionSessionAgent } from "./compaction-session-agent.js";
|
||||
import {
|
||||
type CompactionTranscriptRotation,
|
||||
rotateTranscriptAfterCompaction,
|
||||
shouldRotateCompactionTranscript,
|
||||
} from "./compaction-successor-transcript.js";
|
||||
import { buildEmbeddedExtensionFactories } from "./extensions.js";
|
||||
import { getHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
|
||||
import { log } from "./logger.js";
|
||||
import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js";
|
||||
import type { PreparedCompactionRuntime } from "./prepared-compaction-runtime.js";
|
||||
import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js";
|
||||
import { createEmbeddedAgentResourceLoader } from "./resource-loader.js";
|
||||
import { wrapStreamFnWithDiagnosticModelCallEvents } from "./run/attempt.model-diagnostic-events.js";
|
||||
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
|
||||
import { applySystemPromptToSession } from "./system-prompt.js";
|
||||
import { collectRegisteredToolNames, toSessionToolAllowlist } from "./tool-name-allowlist.js";
|
||||
import { splitSdkTools } from "./tool-split.js";
|
||||
import { readTranscriptFileState } from "./transcript-file-state.js";
|
||||
import { mapThinkingLevel } from "./utils.js";
|
||||
import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js";
|
||||
|
||||
@@ -96,7 +90,6 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
|
||||
sandbox,
|
||||
effectiveWorkspace,
|
||||
effectiveCwd,
|
||||
isSqliteSessionTranscript,
|
||||
contextTokenBudget,
|
||||
effectiveModel,
|
||||
runtimePlan,
|
||||
@@ -115,13 +108,23 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
|
||||
|
||||
try {
|
||||
const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config);
|
||||
const sessionTarget = await resolveAgentRunSessionTarget({
|
||||
agentId: sessionAgentId,
|
||||
config: params.config,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionTarget: params.sessionTarget,
|
||||
});
|
||||
const sessionLock =
|
||||
(await acquireOwnedSessionTranscriptWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionTarget,
|
||||
})) ??
|
||||
(await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionFile: resolveSessionWriteLockTargetKey(sessionTarget),
|
||||
targetKind: "session-key",
|
||||
...resolveSessionWriteLockOptions(params.config, {
|
||||
maxHoldMsFallback: resolveSessionLockMaxHoldFromTimeout({
|
||||
timeoutMs: compactionTimeoutMs,
|
||||
@@ -129,16 +132,8 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
|
||||
}),
|
||||
}));
|
||||
try {
|
||||
if (!isSqliteSessionTranscript) {
|
||||
await repairSessionFileIfNeeded({
|
||||
sessionFile: params.sessionFile,
|
||||
debug: (message) => log.debug(message),
|
||||
warn: (message) => log.warn(message),
|
||||
});
|
||||
await prewarmSessionFile(params.sessionFile);
|
||||
}
|
||||
const transcriptPolicy = runtimePlan.transcript.resolvePolicy(runtimePlanModelContext);
|
||||
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
||||
const sessionManager = guardSessionManager(SessionManager.open(sessionTarget), {
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
@@ -155,9 +150,9 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
|
||||
checkpointSnapshot = await compactionCheckpointStore.captureSnapshot({
|
||||
sessionManager,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionTarget,
|
||||
});
|
||||
compactionSessionManager = sessionManager;
|
||||
trackSessionManagerAccess(params.sessionFile);
|
||||
const settingsManager = createPreparedEmbeddedAgentSettingsManager({
|
||||
cwd: effectiveCwd,
|
||||
agentDir,
|
||||
@@ -435,36 +430,11 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
|
||||
},
|
||||
},
|
||||
);
|
||||
let effectiveFirstKeptEntryId = result.firstKeptEntryId;
|
||||
let postCompactionLeafId =
|
||||
const effectiveFirstKeptEntryId = result.firstKeptEntryId;
|
||||
const postCompactionLeafId =
|
||||
typeof sessionManager.getLeafId === "function"
|
||||
? (sessionManager.getLeafId() ?? undefined)
|
||||
: undefined;
|
||||
let transcriptRotationSessionManager: Parameters<
|
||||
typeof rotateTranscriptAfterCompaction
|
||||
>[0]["sessionManager"] = sessionManager;
|
||||
if (params.trigger === "manual" && !isSqliteSessionTranscript) {
|
||||
try {
|
||||
const hardenedBoundary = await hardenManualCompactionBoundary({
|
||||
sessionFile: params.sessionFile,
|
||||
preserveRecentTail:
|
||||
typeof params.config?.agents?.defaults?.compaction?.keepRecentTokens === "number",
|
||||
});
|
||||
if (hardenedBoundary.applied) {
|
||||
effectiveFirstKeptEntryId =
|
||||
hardenedBoundary.firstKeptEntryId ?? effectiveFirstKeptEntryId;
|
||||
postCompactionLeafId = hardenedBoundary.leafId ?? postCompactionLeafId;
|
||||
session.agent.state.messages = hardenedBoundary.messages;
|
||||
transcriptRotationSessionManager = await readTranscriptFileState(
|
||||
params.sessionFile,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("[compaction] failed to harden manual compaction boundary", {
|
||||
errorMessage: formatErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Estimate tokens after compaction by summing token estimates for remaining messages
|
||||
const tokensAfter = estimateTokensAfterCompaction({
|
||||
messagesAfter: session.messages,
|
||||
@@ -474,29 +444,12 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
|
||||
});
|
||||
const messageCountAfter = session.messages.length;
|
||||
const compactedCount = Math.max(0, messageCountCompactionInput - messageCountAfter);
|
||||
let transcriptRotation: CompactionTranscriptRotation = { rotated: false };
|
||||
if (shouldRotateCompactionTranscript(params.config) && !isSqliteSessionTranscript) {
|
||||
try {
|
||||
transcriptRotation = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: transcriptRotationSessionManager,
|
||||
sessionFile: params.sessionFile,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn("[compaction] post-compaction transcript rotation failed", {
|
||||
errorMessage: formatErrorMessage(err),
|
||||
errorStack: err instanceof Error ? err.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
const activeSessionId = transcriptRotation.sessionId ?? params.sessionId;
|
||||
const activeSessionFile = transcriptRotation.sessionFile ?? params.sessionFile;
|
||||
const activePostLeafId = transcriptRotation.leafId ?? postCompactionLeafId;
|
||||
if (transcriptRotation.rotated) {
|
||||
log.info(
|
||||
`[compaction] rotated active transcript after compaction ` +
|
||||
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
|
||||
);
|
||||
}
|
||||
const activeSessionId = params.sessionId;
|
||||
const activeSessionFile = formatSqliteSessionFileMarker({
|
||||
...sessionTarget,
|
||||
sessionId: activeSessionId,
|
||||
});
|
||||
const activePostLeafId = postCompactionLeafId;
|
||||
await runPostCompactionSideEffects({
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -564,8 +517,8 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
|
||||
tokensBefore: observedTokenCount ?? result.tokensBefore,
|
||||
tokensAfter,
|
||||
details: result.details,
|
||||
sessionId: transcriptRotation.sessionId,
|
||||
sessionFile: transcriptRotation.sessionFile,
|
||||
sessionId: undefined,
|
||||
sessionFile: undefined,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
|
||||
import { rotateTranscriptAfterCompaction } from "./compaction-successor-transcript.js";
|
||||
import { readTranscriptFileState } from "./transcript-file-state.js";
|
||||
|
||||
let tmpDir: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
tmpDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function makeAssistant(text: string, timestamp: number) {
|
||||
return makeAgentAssistantMessage({ content: [{ type: "text", text }], timestamp });
|
||||
}
|
||||
|
||||
function readUserTexts(entries: { type: string; message?: unknown }[]): string[] {
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.type === "message" &&
|
||||
(entry.message as { role?: unknown } | undefined)?.role === "user",
|
||||
)
|
||||
.map((entry) => {
|
||||
const content = (entry.message as { content?: unknown } | undefined)?.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((block) => (block as { text?: string })?.text ?? "").join("");
|
||||
}
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
const PROMPT = "Please refactor the authentication module right now";
|
||||
|
||||
describe("rotateTranscriptAfterCompaction duplicate-prompt preservation", () => {
|
||||
it("keeps a kept-tail prompt whose earlier copy was summarized away", async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "successor-dup-loss-"));
|
||||
const manager = SessionManager.create(tmpDir, tmpDir);
|
||||
|
||||
manager.appendMessage({ role: "user", content: PROMPT, timestamp: 1000 });
|
||||
manager.appendMessage(makeAssistant("Working on it.", 1001));
|
||||
const firstKeptId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "go on",
|
||||
timestamp: 1002,
|
||||
});
|
||||
manager.appendMessage(makeAssistant("Continuing.", 1003));
|
||||
manager.appendCompaction("Summary of earlier turns.", firstKeptId, 5000);
|
||||
manager.appendMessage({ role: "user", content: PROMPT, timestamp: 40000 });
|
||||
manager.appendMessage(makeAssistant("On it again.", 40001));
|
||||
|
||||
const sessionFile = manager.getSessionFile();
|
||||
if (!sessionFile) {
|
||||
throw new Error("no session file");
|
||||
}
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({ sessionManager: manager, sessionFile });
|
||||
expect(result.rotated).toBe(true);
|
||||
const successorFile = result.sessionFile;
|
||||
if (!successorFile) {
|
||||
throw new Error("no successor file");
|
||||
}
|
||||
|
||||
const successor = await readTranscriptFileState(successorFile);
|
||||
const userPromptTexts = readUserTexts(
|
||||
successor.getEntries() as { type: string; message?: unknown }[],
|
||||
);
|
||||
|
||||
expect(userPromptTexts.some((text) => text.includes(PROMPT))).toBe(true);
|
||||
});
|
||||
});
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
|
||||
import { rotateTranscriptAfterCompaction } from "./compaction-successor-transcript.js";
|
||||
import { readTranscriptFileState } from "./transcript-file-state.js";
|
||||
|
||||
let tmpDir: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
tmpDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function makeAssistant(text: string, timestamp: number) {
|
||||
return makeAgentAssistantMessage({ content: [{ type: "text", text }], timestamp });
|
||||
}
|
||||
|
||||
function readUserTexts(entries: { type: string; message?: unknown }[]): string[] {
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.type === "message" &&
|
||||
(entry.message as { role?: unknown } | undefined)?.role === "user",
|
||||
)
|
||||
.map((entry) => {
|
||||
const content = (entry.message as { content?: unknown } | undefined)?.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((block) => (block as { text?: string })?.text ?? "").join("");
|
||||
}
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
const PROMPT = "Run the deployment script for staging now";
|
||||
|
||||
describe("rotateTranscriptAfterCompaction post-compaction duplicate", () => {
|
||||
it("keeps a real post-compaction user turn that repeats a kept-tail prompt", async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "successor-dup-post-"));
|
||||
const manager = SessionManager.create(tmpDir, tmpDir);
|
||||
|
||||
manager.appendMessage({ role: "user", content: "set up the project", timestamp: 1000 });
|
||||
manager.appendMessage(makeAssistant("Project ready.", 1001));
|
||||
|
||||
const firstKeptId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: PROMPT,
|
||||
timestamp: 2000,
|
||||
});
|
||||
manager.appendMessage(makeAssistant("Deploying to staging.", 2001));
|
||||
manager.appendCompaction("Summary of project setup.", firstKeptId, 2050);
|
||||
|
||||
manager.appendMessage({ role: "user", content: PROMPT, timestamp: 2080 });
|
||||
manager.appendMessage(makeAssistant("Redeploying to staging (second run).", 2081));
|
||||
|
||||
const sessionFile = manager.getSessionFile();
|
||||
if (!sessionFile) {
|
||||
throw new Error("no session file");
|
||||
}
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({ sessionManager: manager, sessionFile });
|
||||
expect(result.rotated).toBe(true);
|
||||
const successorFile = result.sessionFile;
|
||||
if (!successorFile) {
|
||||
throw new Error("no successor file");
|
||||
}
|
||||
|
||||
const successor = await readTranscriptFileState(successorFile);
|
||||
const userPromptTexts = readUserTexts(
|
||||
successor.getEntries() as { type: string; message?: unknown }[],
|
||||
);
|
||||
|
||||
const occurrences = userPromptTexts.filter((text) => text.includes(PROMPT)).length;
|
||||
expect(occurrences).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,638 +0,0 @@
|
||||
// Persistence coverage for transcript rotation after successful compaction.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
|
||||
import {
|
||||
rotateTranscriptAfterCompaction,
|
||||
rotateTranscriptFileAfterCompaction,
|
||||
shouldRotateCompactionTranscript,
|
||||
} from "./compaction-successor-transcript.js";
|
||||
import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js";
|
||||
|
||||
let tmpDir: string | undefined;
|
||||
|
||||
async function createTmpDir(): Promise<string> {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compaction-successor-test-"));
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
tmpDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function makeAssistant(text: string, timestamp: number) {
|
||||
return makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text }],
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
function makeThinkingAssistant(text: string, thinkingSignature: string, timestamp: number) {
|
||||
return makeAgentAssistantMessage({
|
||||
content: [
|
||||
{ type: "thinking", thinking: "reasoning", thinkingSignature } as never,
|
||||
{ type: "text", text },
|
||||
],
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
function requireString(value: string | undefined, label: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireValue<T>(value: T | null | undefined, label: string): T {
|
||||
if (value == null) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
type TranscriptEntry = ReturnType<SessionManager["getEntries"]>[number];
|
||||
|
||||
function requireEntryByIdAndType<T extends TranscriptEntry["type"]>(
|
||||
entries: readonly TranscriptEntry[],
|
||||
id: string,
|
||||
type: T,
|
||||
label: string,
|
||||
): Extract<TranscriptEntry, { type: T }> {
|
||||
const entry = entries.find((candidate) => candidate.id === id);
|
||||
if (!entry) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
if (entry.type !== type) {
|
||||
throw new Error(`expected ${label} to be ${type}, got ${entry.type}`);
|
||||
}
|
||||
return entry as Extract<TranscriptEntry, { type: T }>;
|
||||
}
|
||||
|
||||
function requireEntryByType<T extends TranscriptEntry["type"]>(
|
||||
entries: readonly TranscriptEntry[],
|
||||
type: T,
|
||||
label: string,
|
||||
): Extract<TranscriptEntry, { type: T }> {
|
||||
const entry = entries.find((candidate) => candidate.type === type);
|
||||
if (!entry) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return entry as Extract<TranscriptEntry, { type: T }>;
|
||||
}
|
||||
|
||||
function createCompactedSession(sessionDir: string): {
|
||||
manager: SessionManager;
|
||||
sessionFile: string;
|
||||
firstKeptId: string;
|
||||
oldUserId: string;
|
||||
} {
|
||||
// Fixture includes pre-compaction history, preserved branch metadata, and
|
||||
// post-compaction turns so rotation can prove exactly which entries survive.
|
||||
const manager = SessionManager.create(sessionDir, sessionDir);
|
||||
manager.appendModelChange("openai", "gpt-5.2");
|
||||
manager.appendThinkingLevelChange("medium");
|
||||
manager.appendCustomEntry("test-extension", { cursor: "before-compaction" });
|
||||
const oldUserId = manager.appendMessage({ role: "user", content: "old user", timestamp: 1 });
|
||||
manager.appendLabelChange(oldUserId, "old bookmark");
|
||||
manager.appendMessage(makeAssistant("old assistant", 2));
|
||||
const firstKeptId = manager.appendMessage({ role: "user", content: "kept user", timestamp: 3 });
|
||||
manager.appendLabelChange(firstKeptId, "kept bookmark");
|
||||
manager.appendMessage(makeAssistant("kept assistant", 4));
|
||||
manager.appendCompaction("Summary of old user and old assistant.", firstKeptId, 5000);
|
||||
manager.appendMessage({ role: "user", content: "post user", timestamp: 5 });
|
||||
manager.appendMessage(makeAssistant("post assistant", 6));
|
||||
return {
|
||||
manager,
|
||||
sessionFile: requireString(manager.getSessionFile(), "compacted session file"),
|
||||
firstKeptId,
|
||||
oldUserId,
|
||||
};
|
||||
}
|
||||
|
||||
describe("rotateTranscriptAfterCompaction", () => {
|
||||
it("can rotate a persisted transcript without opening a manager", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const { sessionFile } = createCompactedSession(dir);
|
||||
|
||||
// File-only rotation is used after the active manager has moved on; opening
|
||||
// a new manager here would hide bugs in the direct persistence path.
|
||||
const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => {
|
||||
throw new Error("SessionManager.open should not be used for file rotation");
|
||||
});
|
||||
const result = await rotateTranscriptFileAfterCompaction({
|
||||
sessionFile,
|
||||
now: () => new Date("2026-04-27T12:00:00.000Z"),
|
||||
});
|
||||
openSpy.mockRestore();
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successorFile = requireString(result.sessionFile, "successor session file");
|
||||
|
||||
const successor = SessionManager.open(successorFile);
|
||||
const header = requireValue(successor.getHeader(), "successor header");
|
||||
expect(header.parentSession).toBe(sessionFile);
|
||||
expect(header.cwd).toBe(dir);
|
||||
const messages = successor.buildSessionContext().messages;
|
||||
expect(
|
||||
messages.map((message) => {
|
||||
if (message.role === "compactionSummary") {
|
||||
return {
|
||||
role: message.role,
|
||||
summary: message.summary,
|
||||
tokensBefore: message.tokensBefore,
|
||||
};
|
||||
}
|
||||
if (!("content" in message)) {
|
||||
throw new Error(`expected ${message.role} message content`);
|
||||
}
|
||||
return {
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "Summary of old user and old assistant.",
|
||||
tokensBefore: 5000,
|
||||
},
|
||||
// The last assistant reply before firstKeptEntryId is preserved so the
|
||||
// successor shows compactionSummary → assistant → user (issue #76729).
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "old assistant" }],
|
||||
timestamp: 2,
|
||||
},
|
||||
{ role: "user", content: "kept user", timestamp: 3 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "kept assistant" }],
|
||||
timestamp: 4,
|
||||
},
|
||||
{ role: "user", content: "post user", timestamp: 5 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "post assistant" }],
|
||||
timestamp: 6,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the paired tool result without replaying summarized custom context", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const manager = SessionManager.create(dir, dir);
|
||||
manager.appendMessage({ role: "user", content: "read the file", timestamp: 1 });
|
||||
manager.appendMessage(
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||
timestamp: 2,
|
||||
}),
|
||||
);
|
||||
manager.appendMessage({
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "file contents" }],
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
});
|
||||
manager.appendCustomMessageEntry("test", "summarized custom context", false);
|
||||
const firstKeptId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "continue",
|
||||
timestamp: 4,
|
||||
});
|
||||
manager.appendMessage(makeAssistant("done", 5));
|
||||
manager.appendCompaction("Summary of the read.", firstKeptId, 5000);
|
||||
manager.appendMessage({ role: "user", content: "next", timestamp: 6 });
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile: requireString(manager.getSessionFile(), "session file"),
|
||||
});
|
||||
const successor = SessionManager.open(requireString(result.sessionFile, "successor file"));
|
||||
const messages = successor.buildSessionContext().messages;
|
||||
const assistant = messages.find((message) => message.role === "assistant");
|
||||
const toolResult = messages.find((message) => message.role === "toolResult");
|
||||
|
||||
expect(assistant?.role).toBe("assistant");
|
||||
expect(toolResult?.role).toBe("toolResult");
|
||||
expect(JSON.stringify(messages)).toContain("file contents");
|
||||
expect(JSON.stringify(messages)).not.toContain("summarized custom context");
|
||||
});
|
||||
|
||||
it("creates a compacted successor transcript and leaves the archive untouched", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const { manager, sessionFile, firstKeptId, oldUserId } = createCompactedSession(dir);
|
||||
const originalBytes = await fs.readFile(sessionFile, "utf8");
|
||||
const originalEntryCount = manager.getEntries().length;
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile,
|
||||
now: () => new Date("2026-04-27T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successorSessionId = requireString(result.sessionId, "successor session id");
|
||||
const successorFile = requireString(result.sessionFile, "successor session file");
|
||||
expect(successorFile).not.toBe(sessionFile);
|
||||
expect(await fs.readFile(sessionFile, "utf8")).toBe(originalBytes);
|
||||
|
||||
const successor = SessionManager.open(successorFile);
|
||||
const header = requireValue(successor.getHeader(), "successor header");
|
||||
expect(header.id).toBe(successorSessionId);
|
||||
expect(header.parentSession).toBe(sessionFile);
|
||||
expect(header.cwd).toBe(dir);
|
||||
expect(successor.getEntries().length).toBeLessThan(originalEntryCount);
|
||||
expect(successor.getBranch()[0]?.type).toBe("model_change");
|
||||
const customBranchEntry = requireEntryByType(
|
||||
successor.getBranch(),
|
||||
"custom",
|
||||
"preserved custom branch entry",
|
||||
);
|
||||
expect(customBranchEntry.customType).toBe("test-extension");
|
||||
expect(customBranchEntry.data).toStrictEqual({ cursor: "before-compaction" });
|
||||
|
||||
const context = successor.buildSessionContext();
|
||||
const contextText = JSON.stringify(context.messages);
|
||||
expect(contextText).toContain("Summary of old user and old assistant.");
|
||||
expect(contextText).toContain("kept user");
|
||||
expect(contextText).toContain("post assistant");
|
||||
expect(
|
||||
context.messages.some((message) => message.role === "user" && message.content === "old user"),
|
||||
).toBe(false);
|
||||
expect(context.model?.provider).toBe("openai");
|
||||
expect(context.thinkingLevel).toBe("medium");
|
||||
expect(successor.getLabel(firstKeptId)).toBe("kept bookmark");
|
||||
expect(successor.getLabel(oldUserId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rotates with a fallback timestamp when the injected clock is invalid", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const { manager, sessionFile } = createCompactedSession(dir);
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-05-30T12:00:00Z"));
|
||||
|
||||
try {
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile,
|
||||
now: () => new Date(Number.NaN),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successor = SessionManager.open(requireString(result.sessionFile, "successor file"));
|
||||
expect(successor.getHeader()?.timestamp).toBe("2026-05-30T12:00:00.000Z");
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates stale pre-compaction session state", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const manager = SessionManager.create(dir, dir);
|
||||
|
||||
const staleModelId = manager.appendModelChange("anthropic", "claude-sonnet-4-5");
|
||||
const staleThinkingId = manager.appendThinkingLevelChange("low");
|
||||
const staleSessionInfoId = manager.appendSessionInfo("stale title");
|
||||
manager.appendCustomEntry("test-extension", { cursor: "preserved" });
|
||||
manager.appendMessage({ role: "user", content: "old user", timestamp: 1 });
|
||||
manager.appendMessage(makeAssistant("old assistant", 2));
|
||||
|
||||
manager.appendModelChange("openai", "gpt-5.2");
|
||||
manager.appendThinkingLevelChange("high");
|
||||
manager.appendSessionInfo("current title");
|
||||
const firstKeptId = manager.appendMessage({ role: "user", content: "kept user", timestamp: 3 });
|
||||
manager.appendMessage(makeAssistant("kept assistant", 4));
|
||||
manager.appendCompaction("Summary of old user and old assistant.", firstKeptId, 5000);
|
||||
manager.appendMessage({ role: "user", content: "post user", timestamp: 5 });
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile: requireString(manager.getSessionFile(), "source session file"),
|
||||
now: () => new Date("2026-04-27T12:05:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successor = SessionManager.open(
|
||||
requireString(result.sessionFile, "successor session file"),
|
||||
);
|
||||
const entries = successor.getEntries();
|
||||
expect(entries.find((entry) => entry.id === staleModelId)).toBeUndefined();
|
||||
expect(entries.find((entry) => entry.id === staleThinkingId)).toBeUndefined();
|
||||
expect(entries.find((entry) => entry.id === staleSessionInfoId)).toBeUndefined();
|
||||
const countEntryType = (type: (typeof entries)[number]["type"]) =>
|
||||
entries.reduce((count, entry) => count + (entry.type === type ? 1 : 0), 0);
|
||||
expect(countEntryType("model_change")).toBe(1);
|
||||
expect(countEntryType("thinking_level_change")).toBe(1);
|
||||
expect(countEntryType("session_info")).toBe(1);
|
||||
const modelChange = requireEntryByType(entries, "model_change", "current model change");
|
||||
expect(modelChange.provider).toBe("openai");
|
||||
expect(modelChange.modelId).toBe("gpt-5.2");
|
||||
const customEntry = requireEntryByType(entries, "custom", "preserved custom entry");
|
||||
expect(customEntry.customType).toBe("test-extension");
|
||||
expect(customEntry.data).toStrictEqual({ cursor: "preserved" });
|
||||
|
||||
const context = successor.buildSessionContext();
|
||||
expect(context.thinkingLevel).toBe("high");
|
||||
expect(successor.getSessionName()).toBe("current title");
|
||||
});
|
||||
|
||||
it("drops duplicate user messages from the rotated active branch tail", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const manager = SessionManager.create(dir, dir);
|
||||
manager.appendMessage({ role: "user", content: "old user", timestamp: 1 });
|
||||
const firstKeptId = manager.appendMessage(makeAssistant("old assistant", 2));
|
||||
manager.appendCompaction("Summary of old work.", firstKeptId, 5000);
|
||||
const firstDuplicateId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "please run the deployment status check for production",
|
||||
timestamp: 3_000,
|
||||
});
|
||||
const secondDuplicateId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: " please run the deployment status check for production ",
|
||||
timestamp: 4_000,
|
||||
});
|
||||
manager.appendMessage(makeAssistant("status checked", 5_000));
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile: requireString(manager.getSessionFile(), "source session file"),
|
||||
now: () => new Date("2026-04-27T12:10:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successor = SessionManager.open(
|
||||
requireString(result.sessionFile, "successor session file"),
|
||||
);
|
||||
const entries = successor.getEntries();
|
||||
requireValue(
|
||||
entries.find((entry) => entry.id === firstDuplicateId),
|
||||
"kept duplicate entry",
|
||||
);
|
||||
expect(entries.find((entry) => entry.id === secondDuplicateId)).toBeUndefined();
|
||||
const contextText = JSON.stringify(successor.buildSessionContext().messages);
|
||||
expect(contextText.match(/deployment status check/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips sessions with no compaction entry", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const manager = SessionManager.create(dir, dir);
|
||||
manager.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
manager.appendMessage(makeAssistant("hi", 2));
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile: requireString(manager.getSessionFile(), "source session file"),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(false);
|
||||
expect(result.reason).toBe("no compaction entry");
|
||||
});
|
||||
|
||||
it("uses a refreshed manager after manual boundary hardening", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const manager = SessionManager.create(dir, dir);
|
||||
manager.appendMessage({ role: "user", content: "old question", timestamp: 1 });
|
||||
manager.appendMessage(makeAssistant("old answer", 2));
|
||||
const recentTailId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "recent question",
|
||||
timestamp: 3,
|
||||
});
|
||||
manager.appendMessage(makeAssistant("detailed recent answer", 4));
|
||||
const compactionId = manager.appendCompaction("fresh manual summary", recentTailId, 200);
|
||||
const sessionFile = requireString(manager.getSessionFile(), "manual compaction session file");
|
||||
const staleManager = SessionManager.open(sessionFile);
|
||||
|
||||
const hardened = await hardenManualCompactionBoundary({ sessionFile });
|
||||
expect(hardened.applied).toBe(true);
|
||||
const staleLeaf = staleManager.getLeafEntry();
|
||||
expect(staleLeaf?.type).toBe("compaction");
|
||||
if (!staleLeaf || staleLeaf.type !== "compaction") {
|
||||
throw new Error("expected stale leaf to be a compaction entry");
|
||||
}
|
||||
expect(staleLeaf.firstKeptEntryId).toBe(recentTailId);
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: SessionManager.open(sessionFile),
|
||||
sessionFile,
|
||||
now: () => new Date("2026-04-27T12:30:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successor = SessionManager.open(
|
||||
requireString(result.sessionFile, "successor session file"),
|
||||
);
|
||||
const successorText = JSON.stringify(successor.buildSessionContext().messages);
|
||||
expect(successorText).toContain("fresh manual summary");
|
||||
expect(successorText).not.toContain("recent question");
|
||||
expect(successorText).not.toContain("detailed recent answer");
|
||||
const successorCompaction = successor
|
||||
.getEntries()
|
||||
.find((entry) => entry.type === "compaction" && entry.id === compactionId);
|
||||
if (!successorCompaction || successorCompaction.type !== "compaction") {
|
||||
throw new Error("expected successor compaction entry");
|
||||
}
|
||||
expect(successorCompaction.firstKeptEntryId).toBe(compactionId);
|
||||
});
|
||||
|
||||
it("preserves unsummarized sibling branches and branch summaries", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const manager = SessionManager.create(dir, dir);
|
||||
|
||||
manager.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
const branchFromId = manager.appendMessage(makeAssistant("hi there", 2));
|
||||
|
||||
const branchSummaryId = manager.branchWithSummary(
|
||||
branchFromId,
|
||||
"Summary of the abandoned branch.",
|
||||
);
|
||||
const siblingMsgId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "do task B instead",
|
||||
timestamp: 3,
|
||||
});
|
||||
manager.appendMessage(makeAssistant("done B", 4));
|
||||
|
||||
manager.branch(branchFromId);
|
||||
manager.appendMessage({ role: "user", content: "do task A", timestamp: 5 });
|
||||
const firstKeptId = manager.appendMessage(makeAssistant("done A", 6));
|
||||
manager.appendCompaction("Summary of main branch.", firstKeptId, 5000);
|
||||
manager.appendMessage({ role: "user", content: "next", timestamp: 7 });
|
||||
|
||||
const sessionFile = requireString(manager.getSessionFile(), "source session file");
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile,
|
||||
now: () => new Date("2026-04-27T12:45:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successor = SessionManager.open(
|
||||
requireString(result.sessionFile, "successor session file"),
|
||||
);
|
||||
const allEntries = successor.getEntries();
|
||||
const branchSummary = requireEntryByIdAndType(
|
||||
allEntries,
|
||||
branchSummaryId,
|
||||
"branch_summary",
|
||||
"preserved branch summary",
|
||||
);
|
||||
expect(branchSummary.summary).toBe("Summary of the abandoned branch.");
|
||||
const siblingMessage = requireEntryByIdAndType(
|
||||
allEntries,
|
||||
siblingMsgId,
|
||||
"message",
|
||||
"preserved sibling message",
|
||||
);
|
||||
if (!("content" in siblingMessage.message)) {
|
||||
throw new Error("expected sibling message content");
|
||||
}
|
||||
expect(siblingMessage.message.content).toBe("do task B instead");
|
||||
|
||||
const activeContextText = JSON.stringify(successor.buildSessionContext().messages);
|
||||
expect(activeContextText).toContain("Summary of main branch.");
|
||||
expect(activeContextText).toContain("next");
|
||||
expect(activeContextText).not.toContain("do task B instead");
|
||||
});
|
||||
|
||||
it("orders preserved sibling branches after their surviving parents", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const manager = SessionManager.create(dir, dir);
|
||||
|
||||
manager.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
const branchFromId = manager.appendMessage(makeAssistant("hi there", 2));
|
||||
|
||||
const branchSummaryId = manager.branchWithSummary(
|
||||
branchFromId,
|
||||
"Summary of the inactive branch.",
|
||||
);
|
||||
const inactiveMsgId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "inactive branch",
|
||||
timestamp: 3,
|
||||
});
|
||||
manager.appendMessage(makeAssistant("inactive done", 4));
|
||||
|
||||
manager.branch(branchFromId);
|
||||
manager.appendMessage({ role: "user", content: "active branch", timestamp: 5 });
|
||||
manager.appendMessage(makeAssistant("active done", 6));
|
||||
manager.appendCompaction("Summary of active work.", branchFromId, 5000);
|
||||
const activeLeafId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "next active",
|
||||
timestamp: 7,
|
||||
});
|
||||
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile: requireString(manager.getSessionFile(), "source session file"),
|
||||
now: () => new Date("2026-04-27T13:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successor = SessionManager.open(
|
||||
requireString(result.sessionFile, "successor session file"),
|
||||
);
|
||||
const entries = successor.getEntries();
|
||||
const indexById = new Map(entries.map((entry, index) => [entry.id, index]));
|
||||
expect(indexById.get(branchFromId)).toBeLessThan(indexById.get(branchSummaryId)!);
|
||||
expect(indexById.get(branchSummaryId)).toBeLessThan(indexById.get(inactiveMsgId)!);
|
||||
expect(entries.at(-1)?.id).toBe(activeLeafId);
|
||||
expect(successor.getLeafId()).toBe(activeLeafId);
|
||||
|
||||
const activeContextText = JSON.stringify(successor.buildSessionContext().messages);
|
||||
expect(activeContextText).toContain("Summary of active work.");
|
||||
expect(activeContextText).toContain("next active");
|
||||
expect(activeContextText).not.toContain("inactive branch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRotateCompactionTranscript", () => {
|
||||
it("keeps transcript rotation opt-in behind the existing config key", () => {
|
||||
expect(shouldRotateCompactionTranscript()).toBe(false);
|
||||
expect(
|
||||
shouldRotateCompactionTranscript({
|
||||
agents: { defaults: { compaction: { truncateAfterCompaction: true } } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotateTranscriptAfterCompaction — thinking signature stripping", () => {
|
||||
it("strips thinkingSignature from kept assistant messages in the successor file", async () => {
|
||||
const dir = await createTmpDir();
|
||||
const manager = SessionManager.create(dir, dir);
|
||||
|
||||
const oldUserId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "old question",
|
||||
timestamp: 1,
|
||||
});
|
||||
manager.appendMessage(makeThinkingAssistant("old answer", "stale_sig_old", 2));
|
||||
const firstKeptId = manager.appendMessage({
|
||||
role: "user",
|
||||
content: "kept question",
|
||||
timestamp: 3,
|
||||
});
|
||||
manager.appendMessage(makeThinkingAssistant("kept answer", "stale_sig_kept", 4));
|
||||
manager.appendCompaction("Summary of old work.", firstKeptId, 3000);
|
||||
manager.appendMessage({ role: "user", content: "post question", timestamp: 5 });
|
||||
manager.appendMessage(makeThinkingAssistant("post answer", "fresh_sig", 6));
|
||||
|
||||
const sessionFile = requireString(manager.getSessionFile(), "source session file");
|
||||
const result = await rotateTranscriptAfterCompaction({
|
||||
sessionManager: manager,
|
||||
sessionFile,
|
||||
now: () => new Date("2026-06-04T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.rotated).toBe(true);
|
||||
const successor = SessionManager.open(
|
||||
requireString(result.sessionFile, "successor session file"),
|
||||
);
|
||||
|
||||
const entries = successor.getEntries();
|
||||
function getThinkingSignatureForTimestamp(ts: number): unknown {
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "message" || entry.message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
if ((entry.message as { timestamp?: number }).timestamp !== ts) {
|
||||
continue;
|
||||
}
|
||||
const content = (entry.message as { content?: unknown[] }).content ?? [];
|
||||
for (const block of content) {
|
||||
if ((block as { type?: unknown }).type === "thinking") {
|
||||
return (block as { thinkingSignature?: unknown }).thinkingSignature;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Pre-compaction kept message (timestamp 4): signature stripped
|
||||
expect(getThinkingSignatureForTimestamp(4)).toBeUndefined();
|
||||
// Post-compaction message (timestamp 6): signature preserved intact
|
||||
expect(getThinkingSignatureForTimestamp(6)).toBe("fresh_sig");
|
||||
|
||||
// Old summarized messages should not appear
|
||||
expect(entries.find((e) => e.id === oldUserId)).toBeUndefined();
|
||||
|
||||
// Context should remain coherent: compaction summary + kept + post-compaction
|
||||
const contextText = JSON.stringify(successor.buildSessionContext().messages);
|
||||
expect(contextText).toContain("kept question");
|
||||
expect(contextText).toContain("kept answer");
|
||||
expect(contextText).toContain("post answer");
|
||||
});
|
||||
});
|
||||
@@ -1,390 +0,0 @@
|
||||
/**
|
||||
* Rotates compacted sessions into successor transcript files when configured.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveTimestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
|
||||
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { CompactionEntry, SessionEntry, SessionHeader } from "../sessions/index.js";
|
||||
import { collectDuplicateUserMessageEntryIdsForCompaction } from "./compaction-duplicate-user-messages.js";
|
||||
import { stripThinkingSignaturesFromMessage } from "./thinking.js";
|
||||
import {
|
||||
readTranscriptFileState,
|
||||
TranscriptFileState,
|
||||
writeTranscriptFileAtomic,
|
||||
} from "./transcript-file-state.js";
|
||||
|
||||
type ReadonlySessionManagerForRotation = Pick<
|
||||
TranscriptFileState,
|
||||
"buildSessionContext" | "getBranch" | "getCwd" | "getEntries" | "getHeader"
|
||||
>;
|
||||
|
||||
export type CompactionTranscriptRotation = {
|
||||
rotated: boolean;
|
||||
reason?: string;
|
||||
sessionId?: string;
|
||||
sessionFile?: string;
|
||||
compactionEntryId?: string;
|
||||
leafId?: string;
|
||||
entriesWritten?: number;
|
||||
};
|
||||
|
||||
export function shouldRotateCompactionTranscript(config?: OpenClawConfig): boolean {
|
||||
return config?.agents?.defaults?.compaction?.truncateAfterCompaction === true;
|
||||
}
|
||||
|
||||
export async function rotateTranscriptAfterCompaction(params: {
|
||||
sessionManager: ReadonlySessionManagerForRotation;
|
||||
sessionFile: string;
|
||||
now?: () => Date;
|
||||
}): Promise<CompactionTranscriptRotation> {
|
||||
const sessionFile = params.sessionFile.trim();
|
||||
if (!sessionFile) {
|
||||
return { rotated: false, reason: "missing session file" };
|
||||
}
|
||||
|
||||
const branch = params.sessionManager.getBranch();
|
||||
const latestCompactionIndex = findLatestCompactionIndex(branch);
|
||||
if (latestCompactionIndex < 0) {
|
||||
return { rotated: false, reason: "no compaction entry" };
|
||||
}
|
||||
|
||||
const compaction = branch[latestCompactionIndex] as CompactionEntry;
|
||||
const timestamp = resolveTimestampMsToIsoString(params.now?.().getTime());
|
||||
const sessionId = randomUUID();
|
||||
const successorFile = resolveSuccessorSessionFile({
|
||||
sessionFile,
|
||||
sessionId,
|
||||
timestamp,
|
||||
});
|
||||
const successorEntries = buildSuccessorEntries({
|
||||
allEntries: params.sessionManager.getEntries(),
|
||||
branch,
|
||||
latestCompactionIndex,
|
||||
});
|
||||
if (successorEntries.length === 0) {
|
||||
return { rotated: false, reason: "empty successor transcript" };
|
||||
}
|
||||
|
||||
const header = buildSuccessorHeader({
|
||||
previousHeader: params.sessionManager.getHeader(),
|
||||
sessionId,
|
||||
timestamp,
|
||||
cwd: params.sessionManager.getCwd(),
|
||||
parentSession: sessionFile,
|
||||
});
|
||||
await writeTranscriptFileAtomic(successorFile, [header, ...successorEntries]);
|
||||
new TranscriptFileState({ header, entries: successorEntries }).buildSessionContext();
|
||||
|
||||
return {
|
||||
rotated: true,
|
||||
sessionId,
|
||||
sessionFile: successorFile,
|
||||
compactionEntryId: compaction.id,
|
||||
leafId: successorEntries[successorEntries.length - 1]?.id,
|
||||
entriesWritten: successorEntries.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function rotateTranscriptFileAfterCompaction(params: {
|
||||
sessionFile: string;
|
||||
now?: () => Date;
|
||||
}): Promise<CompactionTranscriptRotation> {
|
||||
const state = await readTranscriptFileState(params.sessionFile);
|
||||
return rotateTranscriptAfterCompaction({
|
||||
sessionManager: state,
|
||||
sessionFile: params.sessionFile,
|
||||
...(params.now ? { now: params.now } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function findLatestCompactionIndex(entries: SessionEntry[]): number {
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
if (entries[index]?.type === "compaction") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function buildSuccessorEntries(params: {
|
||||
allEntries: SessionEntry[];
|
||||
branch: SessionEntry[];
|
||||
latestCompactionIndex: number;
|
||||
}): SessionEntry[] {
|
||||
const { allEntries, branch, latestCompactionIndex } = params;
|
||||
const compaction = branch[latestCompactionIndex] as CompactionEntry;
|
||||
|
||||
const summarizedBranchIds = new Set<string>();
|
||||
const preCompactionKeptBranchIds = new Set<string>();
|
||||
let foundFirstKept = false;
|
||||
for (let index = 0; index < latestCompactionIndex; index += 1) {
|
||||
const entry = branch[index];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (compaction.firstKeptEntryId && entry.id === compaction.firstKeptEntryId) {
|
||||
foundFirstKept = true;
|
||||
}
|
||||
if (foundFirstKept) {
|
||||
preCompactionKeptBranchIds.add(entry.id);
|
||||
} else {
|
||||
summarizedBranchIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the last assistant message before firstKeptEntryId so the successor
|
||||
// transcript keeps a compactionSummary → assistant → user structure instead of
|
||||
// dropping the assistant reply (issue #76729).
|
||||
// Skip for hardened manual compaction boundaries where firstKeptEntryId already
|
||||
// points to the compaction entry itself (all pre-compaction content is
|
||||
// intentionally excluded).
|
||||
const isHardenedBoundary = compaction.firstKeptEntryId === compaction.id;
|
||||
let preservedAssistantId: string | undefined;
|
||||
let preservedAssistantIndex = -1;
|
||||
let firstKeptIndex = -1;
|
||||
if (!isHardenedBoundary) {
|
||||
for (let index = latestCompactionIndex - 1; index >= 0; index -= 1) {
|
||||
const entry = branch[index];
|
||||
if (
|
||||
entry &&
|
||||
summarizedBranchIds.has(entry.id) &&
|
||||
entry.type === "message" &&
|
||||
entry.message.role === "assistant"
|
||||
) {
|
||||
preservedAssistantId = entry.id;
|
||||
preservedAssistantIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (compaction.firstKeptEntryId) {
|
||||
firstKeptIndex = branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId);
|
||||
}
|
||||
const branchIndexById = new Map(branch.map((entry, index) => [entry.id, index]));
|
||||
const preservedPreCompactionIds = new Set<string>();
|
||||
if (preservedAssistantId) {
|
||||
preservedPreCompactionIds.add(preservedAssistantId);
|
||||
const assistant = branch[preservedAssistantIndex];
|
||||
if (assistant?.type === "message" && assistant.message.role === "assistant") {
|
||||
const toolCallIds = new Set(
|
||||
assistant.message.content
|
||||
.filter((block) => block.type === "toolCall")
|
||||
.map((block) => block.id),
|
||||
);
|
||||
for (
|
||||
let index = preservedAssistantIndex + 1;
|
||||
index >= 0 && index < firstKeptIndex;
|
||||
index += 1
|
||||
) {
|
||||
const entry = branch[index];
|
||||
if (entry?.type === "message" && entry.message.role === "toolResult") {
|
||||
if (toolCallIds.has(entry.message.toolCallId)) {
|
||||
preservedPreCompactionIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const latestStateEntryIds = collectLatestStateEntryIds(branch.slice(0, latestCompactionIndex));
|
||||
const staleStateEntryIds = new Set<string>();
|
||||
for (const entry of branch.slice(0, latestCompactionIndex)) {
|
||||
if (isDedupedStateEntry(entry) && !latestStateEntryIds.has(entry.id)) {
|
||||
staleStateEntryIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
const removedIds = new Set<string>();
|
||||
const keptPreCompactionEntries = branch
|
||||
.slice(0, latestCompactionIndex)
|
||||
.filter((entry) => !summarizedBranchIds.has(entry.id));
|
||||
const postCompactionEntries = branch.slice(latestCompactionIndex + 1);
|
||||
const duplicateUserMessageIds = new Set<string>([
|
||||
...collectDuplicateUserMessageEntryIdsForCompaction(keptPreCompactionEntries),
|
||||
...collectDuplicateUserMessageEntryIdsForCompaction(postCompactionEntries),
|
||||
]);
|
||||
for (const entry of allEntries) {
|
||||
const branchIndex = branchIndexById.get(entry.id) ?? -1;
|
||||
const summarizedContextMarker =
|
||||
branchIndex > preservedAssistantIndex &&
|
||||
branchIndex < firstKeptIndex &&
|
||||
(entry.type === "custom_message" || entry.type === "branch_summary");
|
||||
if (
|
||||
(summarizedBranchIds.has(entry.id) &&
|
||||
entry.type === "message" &&
|
||||
!preservedPreCompactionIds.has(entry.id)) ||
|
||||
(summarizedBranchIds.has(entry.id) && summarizedContextMarker) ||
|
||||
staleStateEntryIds.has(entry.id) ||
|
||||
duplicateUserMessageIds.has(entry.id)
|
||||
) {
|
||||
removedIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
// The preserved assistant reply is pre-compaction content that needs thinking
|
||||
// signature stripping, same as other pre-compaction kept entries.
|
||||
for (const entryId of preservedPreCompactionIds) {
|
||||
preCompactionKeptBranchIds.add(entryId);
|
||||
}
|
||||
for (const entry of allEntries) {
|
||||
if (entry.type === "label" && removedIds.has(entry.targetId)) {
|
||||
removedIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
const entryById = new Map<string, SessionEntry>();
|
||||
const originalIndexById = new Map<string, number>();
|
||||
for (const [index, entry] of allEntries.entries()) {
|
||||
entryById.set(entry.id, entry);
|
||||
originalIndexById.set(entry.id, index);
|
||||
}
|
||||
const activeBranchIds = new Set<string>();
|
||||
for (const entry of branch) {
|
||||
activeBranchIds.add(entry.id);
|
||||
}
|
||||
const keptEntries: SessionEntry[] = [];
|
||||
for (const entry of allEntries) {
|
||||
if (removedIds.has(entry.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parentId = entry.parentId;
|
||||
while (parentId !== null && removedIds.has(parentId)) {
|
||||
parentId = entryById.get(parentId)?.parentId ?? null;
|
||||
}
|
||||
|
||||
const reparented =
|
||||
parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry);
|
||||
// Strip thinking signatures only from pre-compaction kept entries. Pre-compaction
|
||||
// signatures are bound to the original context prefix; the successor file has a different
|
||||
// prefix so those signatures would cause Anthropic "Invalid signature in thinking block".
|
||||
// Post-compaction entries were generated in the new context and have valid signatures.
|
||||
// Move the compaction boundary back to the preserved turn so its complete
|
||||
// assistant/tool-result sequence is included in the successor context.
|
||||
let transformed: SessionEntry = reparented;
|
||||
if (reparented.type === "message" && preCompactionKeptBranchIds.has(reparented.id)) {
|
||||
transformed = {
|
||||
...reparented,
|
||||
message: stripThinkingSignaturesFromMessage(reparented.message),
|
||||
};
|
||||
}
|
||||
if (
|
||||
reparented.type === "compaction" &&
|
||||
reparented.id === compaction.id &&
|
||||
preservedAssistantId &&
|
||||
reparented.firstKeptEntryId !== reparented.id
|
||||
) {
|
||||
transformed = { ...reparented, firstKeptEntryId: preservedAssistantId };
|
||||
}
|
||||
keptEntries.push(transformed);
|
||||
}
|
||||
|
||||
return orderSuccessorEntries({
|
||||
entries: keptEntries,
|
||||
activeBranchIds,
|
||||
originalIndexById,
|
||||
});
|
||||
}
|
||||
|
||||
function collectLatestStateEntryIds(entries: SessionEntry[]): Set<string> {
|
||||
const latestByType = new Map<string, SessionEntry>();
|
||||
for (const entry of entries) {
|
||||
if (isDedupedStateEntry(entry)) {
|
||||
latestByType.set(entry.type, entry);
|
||||
}
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const entry of latestByType.values()) {
|
||||
ids.add(entry.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function isDedupedStateEntry(entry: SessionEntry): boolean {
|
||||
return (
|
||||
entry.type === "model_change" ||
|
||||
entry.type === "thinking_level_change" ||
|
||||
entry.type === "session_info"
|
||||
);
|
||||
}
|
||||
|
||||
function orderSuccessorEntries(params: {
|
||||
entries: SessionEntry[];
|
||||
activeBranchIds: Set<string>;
|
||||
originalIndexById: Map<string, number>;
|
||||
}): SessionEntry[] {
|
||||
const { entries, activeBranchIds, originalIndexById } = params;
|
||||
const entryIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
entryIds.add(entry.id);
|
||||
}
|
||||
const childrenByParentId = new Map<string | null, SessionEntry[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const parentId =
|
||||
entry.parentId !== null && entryIds.has(entry.parentId) ? entry.parentId : null;
|
||||
const children = childrenByParentId.get(parentId) ?? [];
|
||||
children.push(parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry));
|
||||
childrenByParentId.set(parentId, children);
|
||||
}
|
||||
|
||||
const sortForActiveLeaf = (left: SessionEntry, right: SessionEntry) => {
|
||||
const leftActive = activeBranchIds.has(left.id);
|
||||
const rightActive = activeBranchIds.has(right.id);
|
||||
if (leftActive !== rightActive) {
|
||||
return leftActive ? 1 : -1;
|
||||
}
|
||||
return (originalIndexById.get(left.id) ?? 0) - (originalIndexById.get(right.id) ?? 0);
|
||||
};
|
||||
|
||||
const ordered: SessionEntry[] = [];
|
||||
const emittedIds = new Set<string>();
|
||||
const emitSubtree = (entry: SessionEntry) => {
|
||||
if (emittedIds.has(entry.id)) {
|
||||
return;
|
||||
}
|
||||
emittedIds.add(entry.id);
|
||||
ordered.push(entry);
|
||||
for (const child of (childrenByParentId.get(entry.id) ?? []).toSorted(sortForActiveLeaf)) {
|
||||
emitSubtree(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of (childrenByParentId.get(null) ?? []).toSorted(sortForActiveLeaf)) {
|
||||
emitSubtree(root);
|
||||
}
|
||||
|
||||
// Defensive fallback for malformed transcripts with cycles or broken parents.
|
||||
for (const entry of entries.toSorted(sortForActiveLeaf)) {
|
||||
emitSubtree(entry);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function buildSuccessorHeader(params: {
|
||||
previousHeader: SessionHeader | null;
|
||||
sessionId: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
parentSession: string;
|
||||
}): SessionHeader {
|
||||
return {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: params.sessionId,
|
||||
timestamp: params.timestamp,
|
||||
cwd: params.previousHeader?.cwd || params.cwd,
|
||||
parentSession: params.parentSession,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSuccessorSessionFile(params: {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
timestamp: string;
|
||||
}): string {
|
||||
const fileTimestamp = params.timestamp.replace(/[:.]/g, "-");
|
||||
return path.join(path.dirname(params.sessionFile), `${fileTimestamp}_${params.sessionId}.jsonl`);
|
||||
}
|
||||
@@ -241,7 +241,7 @@ describe("runContextEngineMaintenance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forces background maintenance rewrites through the session file even when a session manager exists", async () => {
|
||||
it("forces background maintenance rewrites through the runtime target even when a session manager exists", async () => {
|
||||
const maintain = vi.fn(async (params?: unknown) => {
|
||||
await (
|
||||
params as { runtimeContext?: ContextEngineRuntimeContext } | undefined
|
||||
@@ -277,6 +277,12 @@ describe("runContextEngineMaintenance", () => {
|
||||
},
|
||||
sessionId: "session-background-file-rewrite",
|
||||
sessionKey: "agent:main:session-background-file-rewrite",
|
||||
sessionTarget: {
|
||||
agentId: "custom-agent",
|
||||
sessionId: "custom-session",
|
||||
sessionKey: "agent:custom-agent:custom-session",
|
||||
storePath: "/tmp/custom-agent.sqlite",
|
||||
},
|
||||
sessionFile: "/tmp/session-background-file-rewrite.jsonl",
|
||||
reason: "turn",
|
||||
executionMode: "background",
|
||||
@@ -287,9 +293,11 @@ describe("runContextEngineMaintenance", () => {
|
||||
expect(rewriteTranscriptEntriesInSessionManagerMock).not.toHaveBeenCalled();
|
||||
expect(rewriteTranscriptEntriesInRuntimeTranscriptMock).toHaveBeenCalledWith({
|
||||
scope: {
|
||||
sessionId: "session-background-file-rewrite",
|
||||
sessionKey: "agent:main:session-background-file-rewrite",
|
||||
agentId: "custom-agent",
|
||||
sessionId: "custom-session",
|
||||
sessionKey: "agent:custom-agent:custom-session",
|
||||
sessionFile: "/tmp/session-background-file-rewrite.jsonl",
|
||||
storePath: "/tmp/custom-agent.sqlite",
|
||||
},
|
||||
request: {
|
||||
replacements: [
|
||||
@@ -303,7 +311,6 @@ describe("runContextEngineMaintenance", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -460,7 +467,6 @@ describe("runContextEngineMaintenance", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
config: {},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { resolveContextEngineOwnerPluginId } from "../../context-engine/registry.js";
|
||||
import type {
|
||||
@@ -323,16 +324,22 @@ function buildContextEngineMaintenanceRuntimeContext(params: {
|
||||
? await params.withSessionManagerRewriteLock(rewriteSessionManagerEntries)
|
||||
: rewriteSessionManagerEntries();
|
||||
}
|
||||
const runtimeAgentId = params.sessionTarget?.agentId ?? params.agentId;
|
||||
const runtimeStorePath =
|
||||
params.sessionTarget?.storePath ??
|
||||
(runtimeAgentId
|
||||
? resolveStorePath(params.config?.session?.store, { agentId: runtimeAgentId })
|
||||
: undefined);
|
||||
const rewriteRuntimeTranscriptEntries = async () =>
|
||||
await rewriteTranscriptEntriesInRuntimeTranscript({
|
||||
scope: {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
sessionId: params.sessionTarget?.sessionId ?? params.sessionId,
|
||||
sessionKey: params.sessionTarget?.sessionKey ?? params.sessionKey ?? params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(runtimeAgentId ? { agentId: runtimeAgentId } : {}),
|
||||
...(runtimeStorePath ? { storePath: runtimeStorePath } : {}),
|
||||
},
|
||||
request,
|
||||
config: params.config,
|
||||
});
|
||||
return await rewriteRuntimeTranscriptEntries();
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
|
||||
import {
|
||||
createDiagnosticTraceContext,
|
||||
freezeDiagnosticTraceContext,
|
||||
@@ -15,7 +14,6 @@ import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.
|
||||
import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
|
||||
import { ensureSessionHeader } from "../embedded-agent-helpers.js";
|
||||
import { describeFailoverError } from "../failover-error.js";
|
||||
import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js";
|
||||
import { MissingProviderAuthError } from "../model-auth.js";
|
||||
@@ -322,14 +320,7 @@ export async function prepareDirectCompactionAttempt(
|
||||
}
|
||||
const effectiveCwd = sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace);
|
||||
await fs.mkdir(effectiveWorkspace, { recursive: true });
|
||||
const isSqliteSessionTranscript = Boolean(parseSqliteSessionFileMarker(params.sessionFile));
|
||||
if (!isSqliteSessionTranscript) {
|
||||
await ensureSessionHeader({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
cwd: effectiveCwd,
|
||||
});
|
||||
}
|
||||
const isSqliteSessionTranscript = true;
|
||||
const { sessionAgentId: effectiveSkillAgentId } = earlyAgentIds;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
// Coverage for hardening manual compaction checkpoints in transcript files.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js";
|
||||
|
||||
let tmpDir = "";
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "manual-compaction-boundary-"));
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
tmpDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
function createAssistantTextMessage(text: string, timestamp: number): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "responses",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function messageText(message: AgentMessage): string {
|
||||
// Tests compare rebuilt context text across plain and block-style messages.
|
||||
if (!("content" in message)) {
|
||||
return "";
|
||||
}
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const textBlocks: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
|
||||
textBlocks.push(block.text);
|
||||
}
|
||||
}
|
||||
return textBlocks.join(" ");
|
||||
}
|
||||
|
||||
function requireString(value: string | undefined, label: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
describe("hardenManualCompactionBoundary", () => {
|
||||
it("turns manual compaction into a true checkpoint for rebuilt context", async () => {
|
||||
// Manual compaction should make the latest summary the new root; otherwise
|
||||
// future context rebuilds can pull summarized turns back into the prompt.
|
||||
const dir = await makeTmpDir();
|
||||
const session = SessionManager.create(dir, dir);
|
||||
|
||||
session.appendMessage({ role: "user", content: "old question", timestamp: 1 });
|
||||
session.appendMessage(createAssistantTextMessage("very long old answer", 2));
|
||||
const firstKeepId = requireString(session.getBranch().at(-1)?.id, "first keep id");
|
||||
session.appendCompaction("old summary", firstKeepId, 100);
|
||||
|
||||
session.appendMessage({ role: "user", content: "new question", timestamp: 3 });
|
||||
session.appendMessage(
|
||||
createAssistantTextMessage("detailed new answer that should be summarized away", 4),
|
||||
);
|
||||
const secondKeepId = requireString(session.getBranch().at(-1)?.id, "second keep id");
|
||||
const latestCompactionId = session.appendCompaction("fresh summary", secondKeepId, 200);
|
||||
const sessionFile = requireString(session.getSessionFile(), "session file");
|
||||
|
||||
const before = SessionManager.open(sessionFile);
|
||||
const beforeTexts = before
|
||||
.buildSessionContext()
|
||||
.messages.map((message) => messageText(message));
|
||||
expect(beforeTexts.join("\n")).toContain("detailed new answer");
|
||||
|
||||
// Boundary hardening operates on file bytes after the session is persisted;
|
||||
// reopening a manager here would hide direct rewrite mistakes.
|
||||
const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => {
|
||||
throw new Error("SessionManager.open should not be used for boundary hardening");
|
||||
});
|
||||
const hardened = await hardenManualCompactionBoundary({ sessionFile });
|
||||
openSpy.mockRestore();
|
||||
expect(hardened.applied).toBe(true);
|
||||
expect(hardened.firstKeptEntryId).toBe(latestCompactionId);
|
||||
expect(hardened.messages.map((message) => message.role)).toEqual(["compactionSummary"]);
|
||||
|
||||
const reopened = SessionManager.open(sessionFile);
|
||||
const latest = reopened.getLeafEntry();
|
||||
expect(latest?.type).toBe("compaction");
|
||||
if (!latest || latest.type !== "compaction") {
|
||||
throw new Error("expected latest leaf to be a compaction entry");
|
||||
}
|
||||
expect(latest.firstKeptEntryId).toBe(latestCompactionId);
|
||||
|
||||
reopened.appendMessage({ role: "user", content: "what was happening?", timestamp: 5 });
|
||||
const after = SessionManager.open(sessionFile);
|
||||
const afterTexts = after.buildSessionContext().messages.map((message) => messageText(message));
|
||||
expect(after.buildSessionContext().messages.map((message) => message.role)).toEqual([
|
||||
"compactionSummary",
|
||||
"user",
|
||||
]);
|
||||
expect(afterTexts.join("\n")).not.toContain("detailed new answer");
|
||||
});
|
||||
|
||||
it("keeps the upstream recent tail when requested", async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const session = SessionManager.create(dir, dir);
|
||||
|
||||
session.appendMessage({ role: "user", content: "old question", timestamp: 1 });
|
||||
session.appendMessage(createAssistantTextMessage("old answer", 2));
|
||||
const keepId = requireString(session.getBranch().at(-1)?.id, "keep id");
|
||||
const latestCompactionId = session.appendCompaction("fresh summary", keepId, 200);
|
||||
const sessionFile = requireString(session.getSessionFile(), "session file");
|
||||
|
||||
const hardened = await hardenManualCompactionBoundary({
|
||||
sessionFile,
|
||||
preserveRecentTail: true,
|
||||
});
|
||||
expect(hardened.applied).toBe(false);
|
||||
expect(hardened.firstKeptEntryId).toBe(keepId);
|
||||
|
||||
const reopened = SessionManager.open(sessionFile);
|
||||
const latest = reopened.getLeafEntry();
|
||||
expect(latest?.type).toBe("compaction");
|
||||
if (!latest || latest.type !== "compaction") {
|
||||
throw new Error("expected latest leaf to be a compaction entry");
|
||||
}
|
||||
expect(latest.id).toBe(latestCompactionId);
|
||||
expect(latest.firstKeptEntryId).toBe(keepId);
|
||||
expect(reopened.buildSessionContext().messages.map((message) => message.role)).toEqual([
|
||||
"compactionSummary",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the recent tail when manual compaction produced an empty summary", async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const session = SessionManager.create(dir, dir);
|
||||
|
||||
session.appendMessage({ role: "user", content: "old question", timestamp: 1 });
|
||||
session.appendMessage(createAssistantTextMessage("old answer", 2));
|
||||
session.appendMessage({ role: "user", content: "fresh question", timestamp: 3 });
|
||||
const keepId = requireString(session.getBranch().at(-1)?.id, "keep id");
|
||||
session.appendMessage(createAssistantTextMessage("fresh answer", 4));
|
||||
session.appendCompaction("", keepId, 200);
|
||||
const sessionFile = requireString(session.getSessionFile(), "session file");
|
||||
|
||||
const hardened = await hardenManualCompactionBoundary({ sessionFile });
|
||||
expect(hardened.applied).toBe(false);
|
||||
expect(hardened.firstKeptEntryId).toBe(keepId);
|
||||
expect(hardened.messages.map((message) => message.role)).toEqual([
|
||||
"compactionSummary",
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
expect(hardened.messages.map((message) => messageText(message)).join("\n")).toContain(
|
||||
"fresh question",
|
||||
);
|
||||
|
||||
const reopened = SessionManager.open(sessionFile);
|
||||
const latest = reopened.getLeafEntry();
|
||||
expect(latest?.type).toBe("compaction");
|
||||
if (!latest || latest.type !== "compaction") {
|
||||
throw new Error("expected latest leaf to be a compaction entry");
|
||||
}
|
||||
expect(latest.firstKeptEntryId).toBe(keepId);
|
||||
});
|
||||
|
||||
it("keeps the recent tail when manual compaction had no messages to summarize", async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const session = SessionManager.create(dir, dir);
|
||||
|
||||
session.appendMessage({ role: "user", content: "fresh question", timestamp: 1 });
|
||||
const keepId = requireString(session.getBranch().at(-1)?.id, "keep id");
|
||||
session.appendMessage(createAssistantTextMessage("fresh answer", 2));
|
||||
session.appendCompaction("No prior history.", keepId, 200);
|
||||
const sessionFile = requireString(session.getSessionFile(), "session file");
|
||||
|
||||
const hardened = await hardenManualCompactionBoundary({ sessionFile });
|
||||
expect(hardened.applied).toBe(false);
|
||||
expect(hardened.firstKeptEntryId).toBe(keepId);
|
||||
expect(hardened.messages.map((message) => message.role)).toEqual([
|
||||
"compactionSummary",
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
|
||||
const reopened = SessionManager.open(sessionFile);
|
||||
const latest = reopened.getLeafEntry();
|
||||
expect(latest?.type).toBe("compaction");
|
||||
if (!latest || latest.type !== "compaction") {
|
||||
throw new Error("expected latest leaf to be a compaction entry");
|
||||
}
|
||||
expect(latest.firstKeptEntryId).toBe(keepId);
|
||||
});
|
||||
|
||||
it("is a no-op when the latest leaf is not a compaction entry", async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const session = SessionManager.create(dir, dir);
|
||||
session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
session.appendMessage(createAssistantTextMessage("hi", 2));
|
||||
const sessionFile = requireString(session.getSessionFile(), "session file");
|
||||
|
||||
const result = await hardenManualCompactionBoundary({ sessionFile });
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Hardens manual compaction transcript boundaries after explicit `/compact`.
|
||||
*/
|
||||
import type { AgentMessage } from "../runtime/index.js";
|
||||
import type { SessionEntry } from "../sessions/index.js";
|
||||
import {
|
||||
readTranscriptFileState,
|
||||
TranscriptFileState,
|
||||
writeTranscriptFileAtomic,
|
||||
} from "./transcript-file-state.js";
|
||||
|
||||
type CompactionEntry = Extract<SessionEntry, { type: "compaction" }>;
|
||||
|
||||
type HardenedManualCompactionBoundary = {
|
||||
applied: boolean;
|
||||
firstKeptEntryId?: string;
|
||||
leafId?: string;
|
||||
messages: AgentMessage[];
|
||||
};
|
||||
|
||||
function replaceLatestCompactionBoundary(params: {
|
||||
entries: SessionEntry[];
|
||||
compactionEntryId: string;
|
||||
}): SessionEntry[] {
|
||||
return params.entries.map((entry) => {
|
||||
if (entry.type !== "compaction" || entry.id !== params.compactionEntryId) {
|
||||
return entry;
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
// Manual /compact is an explicit checkpoint request, so make the
|
||||
// rebuilt context start from the summary itself instead of preserving
|
||||
// an upstream "recent tail" that can keep large prior turns alive.
|
||||
firstKeptEntryId: entry.id,
|
||||
} satisfies CompactionEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function entryCreatesCompactionInputMessage(entry: SessionEntry): boolean {
|
||||
return (
|
||||
entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary"
|
||||
);
|
||||
}
|
||||
|
||||
function hasMessagesToSummarizeBeforeKeptTail(params: {
|
||||
branch: SessionEntry[];
|
||||
compaction: CompactionEntry;
|
||||
}): boolean {
|
||||
const compactionIndex = params.branch.findIndex((entry) => entry.id === params.compaction.id);
|
||||
const firstKeptIndex = params.branch.findIndex(
|
||||
(entry) => entry.id === params.compaction.firstKeptEntryId,
|
||||
);
|
||||
if (compactionIndex <= 0 || firstKeptIndex < 0 || firstKeptIndex >= compactionIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let boundaryStartIndex = 0;
|
||||
for (let i = compactionIndex - 1; i >= 0; i -= 1) {
|
||||
const entry = params.branch[i];
|
||||
if (entry?.type !== "compaction") {
|
||||
continue;
|
||||
}
|
||||
const previousFirstKeptIndex = params.branch.findIndex(
|
||||
(candidate) => candidate.id === entry.firstKeptEntryId,
|
||||
);
|
||||
boundaryStartIndex = previousFirstKeptIndex >= 0 ? previousFirstKeptIndex : i + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return params.branch
|
||||
.slice(boundaryStartIndex, firstKeptIndex)
|
||||
.some((entry) => entryCreatesCompactionInputMessage(entry));
|
||||
}
|
||||
|
||||
/** Rewrite the latest manual compaction leaf so replay starts from its summary. */
|
||||
export async function hardenManualCompactionBoundary(params: {
|
||||
sessionFile: string;
|
||||
preserveRecentTail?: boolean;
|
||||
}): Promise<HardenedManualCompactionBoundary> {
|
||||
const state = await readTranscriptFileState(params.sessionFile);
|
||||
const header = state.getHeader();
|
||||
if (!header) {
|
||||
return {
|
||||
applied: false,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
const leaf = state.getLeafEntry();
|
||||
if (leaf?.type !== "compaction") {
|
||||
const sessionContext = state.buildSessionContext();
|
||||
return {
|
||||
applied: false,
|
||||
leafId: state.getLeafId() ?? undefined,
|
||||
messages: sessionContext.messages,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionContext = state.buildSessionContext();
|
||||
if (params.preserveRecentTail) {
|
||||
return {
|
||||
applied: false,
|
||||
firstKeptEntryId: leaf.firstKeptEntryId,
|
||||
leafId: state.getLeafId() ?? undefined,
|
||||
messages: sessionContext.messages,
|
||||
};
|
||||
}
|
||||
|
||||
if (leaf.firstKeptEntryId === leaf.id) {
|
||||
return {
|
||||
applied: false,
|
||||
firstKeptEntryId: leaf.id,
|
||||
leafId: state.getLeafId() ?? undefined,
|
||||
messages: sessionContext.messages,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!leaf.summary.trim() ||
|
||||
!hasMessagesToSummarizeBeforeKeptTail({
|
||||
branch: state.getBranch(leaf.id),
|
||||
compaction: leaf,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
applied: false,
|
||||
firstKeptEntryId: leaf.firstKeptEntryId,
|
||||
leafId: state.getLeafId() ?? undefined,
|
||||
messages: sessionContext.messages,
|
||||
};
|
||||
}
|
||||
|
||||
const replacedEntries = replaceLatestCompactionBoundary({
|
||||
entries: state.getEntries(),
|
||||
compactionEntryId: leaf.id,
|
||||
});
|
||||
const replacedState = new TranscriptFileState({
|
||||
header,
|
||||
entries: replacedEntries,
|
||||
});
|
||||
await writeTranscriptFileAtomic(params.sessionFile, [header, ...replacedEntries]);
|
||||
|
||||
const replacedSessionContext = replacedState.buildSessionContext();
|
||||
return {
|
||||
applied: true,
|
||||
firstKeptEntryId: leaf.id,
|
||||
leafId: replacedState.getLeafId() ?? undefined,
|
||||
messages: replacedSessionContext.messages,
|
||||
};
|
||||
}
|
||||
@@ -545,8 +545,7 @@ describe("normalizeAssistantReplayContent", () => {
|
||||
});
|
||||
|
||||
it("drops a trailing assistant turn that already carries the persisted sentinel content (#77228)", () => {
|
||||
// Covers the case where session-file-repair persisted the sentinel to
|
||||
// disk; on the next turn the loaded transcript ends with a non-empty
|
||||
// Covers a doctor-imported legacy sentinel; on the next turn the loaded transcript ends with a non-empty
|
||||
// assistant turn whose only content is the sentinel text. Provider
|
||||
// request must still end with user.
|
||||
const persistedSentinel = bedrockAssistant([{ type: "text", text: FALLBACK_TEXT }], "error");
|
||||
|
||||
@@ -427,9 +427,8 @@ function isReplayDroppableTrailingAssistant(message: AgentMessage | undefined):
|
||||
const stopReason = (message as { stopReason?: unknown }).stopReason;
|
||||
return stopReason === "error" || isZeroUsageEmptyStopAssistantTurn(message);
|
||||
}
|
||||
// Sentinel-text content is the post-rewrite shape produced by either
|
||||
// session-file-repair.rewriteAssistantEntryWithEmptyContent (always
|
||||
// stopReason="error") or the in-memory rewrite earlier in this same
|
||||
// Sentinel-text content is the post-rewrite shape produced by either a
|
||||
// doctor-imported legacy repair (always stopReason="error") or the in-memory rewrite earlier in this same
|
||||
// normalizeAssistantReplayContent loop (preserves the original
|
||||
// stopReason — "error" or zero-usage "stop"). Drop only when the trailing
|
||||
// turn carries that synthetic provenance: without this guard, a real
|
||||
|
||||
@@ -116,10 +116,11 @@ async function runEmbeddedAgentInternal(
|
||||
});
|
||||
let params: RunEmbeddedAgentParamsWithSessionFile = withExecutionPhaseDiagnostics({
|
||||
...paramsBase,
|
||||
agentId: paramsBase.agentId ?? runSessionTarget.agentId,
|
||||
agentId: runSessionTarget.agentId,
|
||||
sessionId: runSessionTarget.sessionId,
|
||||
sessionKey: normalizeOptionalString(effectiveSessionKey ?? runSessionTarget.sessionKey),
|
||||
sessionFile: runSessionTarget.sessionFile,
|
||||
sessionKey: runSessionTarget.sessionKey,
|
||||
sessionTarget: runSessionTarget,
|
||||
sessionFile: runSessionTarget.sessionKey,
|
||||
skillWorkshopProposalMutationBudget,
|
||||
});
|
||||
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
||||
|
||||
@@ -86,7 +86,7 @@ export const ACTIVE_EMBEDDED_RUNS =
|
||||
export const ACTIVE_EMBEDDED_RUNS_BY_RUN_ID =
|
||||
embeddedRunState.activeRunsByRunId ??
|
||||
(embeddedRunState.activeRunsByRunId = new Map<string, EmbeddedAgentQueueHandle>());
|
||||
export const ACTIVE_EMBEDDED_RUN_LIFECYCLE_GENERATIONS =
|
||||
const ACTIVE_EMBEDDED_RUN_LIFECYCLE_GENERATIONS =
|
||||
embeddedRunState.activeRunLifecycleGenerations ??
|
||||
(embeddedRunState.activeRunLifecycleGenerations = new WeakMap<
|
||||
EmbeddedAgentQueueHandle,
|
||||
|
||||
@@ -419,7 +419,6 @@ export const overflowBaseRunParams = {
|
||||
agentId: "main",
|
||||
sessionId: "test-session",
|
||||
sessionKey: "test-key",
|
||||
sessionFile: "/tmp/session.json",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
prompt: "hello",
|
||||
timeoutMs: 30000,
|
||||
|
||||
@@ -823,7 +823,7 @@ describe("overflow compaction in run loop", () => {
|
||||
expect(
|
||||
requireMockCallArg(mockedSessionLikelyHasOversizedToolResults, 0).contextWindowTokens,
|
||||
).toBe(200000);
|
||||
expectTruncationScopeSessionFile(0, "/tmp/session.json");
|
||||
expectTruncationScopeSessionFile(0, baseParams.sessionKey);
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
|
||||
expectLogIncludes(mockedLog.info, "Truncated 1 tool result(s)");
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
@@ -869,7 +869,7 @@ describe("overflow compaction in run loop", () => {
|
||||
const oversizedArgs = requireMockCallArg(mockedSessionLikelyHasOversizedToolResults, 0);
|
||||
const messages = oversizedArgs.messages as Array<{ role?: string }>;
|
||||
expect(messages.filter((message) => message.role === "toolResult")).toHaveLength(3);
|
||||
expectTruncationScopeSessionFile(0, "/tmp/session.json");
|
||||
expectTruncationScopeSessionFile(0, baseParams.sessionKey);
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
|
||||
expectLogIncludes(mockedLog.info, "Truncated 2 tool result(s)");
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
@@ -1010,7 +1010,7 @@ describe("overflow compaction in run loop", () => {
|
||||
const result = await runEmbeddedAgent(baseParams);
|
||||
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
expectTruncationScopeSessionFile(0, "/tmp/session.json");
|
||||
expectTruncationScopeSessionFile(0, baseParams.sessionKey);
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
|
||||
expectLogIncludes(mockedLog.info, "post-compaction tool-result truncation succeeded");
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
|
||||
@@ -3719,7 +3719,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
|
||||
|
||||
expectRecordFields(mockCallArg(mockedGlobalHookRunner.runBeforeCompaction), {
|
||||
messageCount: -1,
|
||||
sessionFile: "/tmp/session.json",
|
||||
sessionFile: "test-key",
|
||||
});
|
||||
expectRecordFields(mockCallArg(mockedGlobalHookRunner.runBeforeCompaction, 0, 1), {
|
||||
sessionKey: "test-key",
|
||||
@@ -3728,7 +3728,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
|
||||
messageCount: -1,
|
||||
compactedCount: -1,
|
||||
tokenCount: 50,
|
||||
sessionFile: "/tmp/session.json",
|
||||
sessionFile: "test-key",
|
||||
});
|
||||
expectRecordFields(mockCallArg(mockedGlobalHookRunner.runAfterCompaction, 0, 1), {
|
||||
sessionKey: "test-key",
|
||||
@@ -3755,7 +3755,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
|
||||
contextEngine: mockedContextEngine,
|
||||
sessionId: "test-session",
|
||||
sessionKey: "test-key",
|
||||
sessionFile: "/tmp/session.json",
|
||||
sessionFile: "test-key",
|
||||
reason: "compaction",
|
||||
});
|
||||
expectRecordFields(maintenanceParams.runtimeContext, {
|
||||
@@ -3867,13 +3867,13 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
|
||||
mockedRunEmbeddedAttempt,
|
||||
{
|
||||
sessionId: "rotated-session",
|
||||
sessionFile: `sqlite:main:rotated-session:${rotatedStorePath}`,
|
||||
sessionFile: "test-key",
|
||||
},
|
||||
1,
|
||||
);
|
||||
expectMockCallFields(mockedRunContextEngineMaintenance, {
|
||||
sessionId: "rotated-session",
|
||||
sessionFile: `sqlite:main:rotated-session:${rotatedStorePath}`,
|
||||
sessionFile: "test-key",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -607,7 +607,10 @@ describe("timeout-triggered compaction", () => {
|
||||
await runEmbeddedAgent(overflowBaseRunParams);
|
||||
|
||||
const [beforeEvent, beforeContext] = hookCallAt(0, "before");
|
||||
expect(beforeEvent).toEqual({ messageCount: -1, sessionFile: "/tmp/session.json" });
|
||||
expect(beforeEvent).toEqual({
|
||||
messageCount: -1,
|
||||
sessionFile: overflowBaseRunParams.sessionKey,
|
||||
});
|
||||
expect(beforeContext.sessionKey).toBe("test-key");
|
||||
const [afterEvent, afterContext] = hookCallAt(0, "after");
|
||||
expect(afterEvent).toEqual({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createEmbeddedAttemptRunAbort,
|
||||
type EmbeddedAttemptAbortStatePort,
|
||||
} from "./attempt-abort.js";
|
||||
import { SESSIONS_YIELD_ABORT_REASON } from "./attempt.sessions-yield.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
countActiveToolExecutions: vi.fn(() => 0),
|
||||
@@ -160,6 +161,31 @@ describe("createEmbeddedAttemptExternalAbortController", () => {
|
||||
});
|
||||
|
||||
describe("createEmbeddedAttemptRunAbort", () => {
|
||||
it("releases session ownership non-terminally for sessions_yield handoff", async () => {
|
||||
const releaseHeldLockForAbort = vi.fn(async () => {});
|
||||
const abortRun = createEmbeddedAttemptRunAbort({
|
||||
abortActiveSession: vi.fn(async () => {}),
|
||||
activeSession: { abortCompaction: vi.fn(), isCompacting: false },
|
||||
attempt: {
|
||||
runId: "run-yield",
|
||||
sessionFile: "agent:main:main",
|
||||
sessionId: "session-yield",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
getQueueHandle: () => undefined,
|
||||
isProbeSession: false,
|
||||
log: { warn: vi.fn() },
|
||||
runAbortController: new AbortController(),
|
||||
sessionLockController: { releaseHeldLockForAbort },
|
||||
state: createAbortState().port,
|
||||
});
|
||||
|
||||
abortRun(false, SESSIONS_YIELD_ABORT_REASON);
|
||||
await vi.waitFor(() => {
|
||||
expect(releaseHeldLockForAbort).toHaveBeenCalledWith({ terminal: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("settles timeout state, session work, queue ownership, and the lock", async () => {
|
||||
const state = createAbortState();
|
||||
const timeoutReason = new Error("attempt deadline");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isSignalTimeoutReason } from "../../failover-error.js";
|
||||
import type { AgentSession } from "../../sessions/index.js";
|
||||
import { markActiveEmbeddedRunAbandoned, type EmbeddedAgentQueueHandle } from "../runs.js";
|
||||
import type { EmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
|
||||
import { isSessionsYieldAbortError, isSessionsYieldAbortReason } from "./attempt.sessions-yield.js";
|
||||
import { shouldFlagCompactionTimeout } from "./compaction-timeout.js";
|
||||
import type { EmbeddedRunAttemptParams } from "./types.js";
|
||||
|
||||
@@ -220,6 +221,8 @@ export function createEmbeddedAttemptRunAbort(input: {
|
||||
log: input.log,
|
||||
runId: input.attempt.runId,
|
||||
abortKind: isTimeout ? "timeout abort" : "abort",
|
||||
terminal:
|
||||
isTimeout || (!isSessionsYieldAbortError(reason) && !isSessionsYieldAbortReason(reason)),
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -234,10 +237,13 @@ function releaseEmbeddedAttemptSessionLockForAbort(params: {
|
||||
log: AbortLockReleaseLog;
|
||||
runId: string;
|
||||
abortKind: "abort" | "timeout abort";
|
||||
terminal: boolean;
|
||||
}): void {
|
||||
void params.sessionLockController.releaseHeldLockForAbort().catch((err: unknown) => {
|
||||
params.log.warn(
|
||||
`failed to release session lock on ${params.abortKind}: runId=${params.runId} ${String(err)}`,
|
||||
);
|
||||
});
|
||||
void params.sessionLockController
|
||||
.releaseHeldLockForAbort({ terminal: params.terminal })
|
||||
.catch((err: unknown) => {
|
||||
params.log.warn(
|
||||
`failed to release session lock on ${params.abortKind}: runId=${params.runId} ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../../context-engine/host-compat.js";
|
||||
import type { ContextEngine } from "../../../context-engine/types.js";
|
||||
import { captureAgentRunLifecycleGeneration } from "../../../infra/agent-events.js";
|
||||
import { freezeDiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js";
|
||||
import { formatErrorMessage } from "../../../infra/errors.js";
|
||||
import type { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
@@ -15,13 +14,8 @@ import { runAgentEndSideEffects } from "../../harness/agent-end-side-effects.js"
|
||||
import type { AgentMessage } from "../../runtime/index.js";
|
||||
import type { AgentSession, SessionManager } from "../../sessions/index.js";
|
||||
import type { NormalizedUsage } from "../../usage.js";
|
||||
import {
|
||||
rotateTranscriptAfterCompaction,
|
||||
shouldRotateCompactionTranscript,
|
||||
} from "../compaction-successor-transcript.js";
|
||||
import { runContextEngineMaintenance } from "../context-engine-maintenance.js";
|
||||
import { log } from "../logger.js";
|
||||
import { updateActiveEmbeddedRunSessionFile } from "../runs.js";
|
||||
import { buildEmbeddedAgentEndContext } from "./agent-end-context.js";
|
||||
import {
|
||||
finalizeAttemptContextEngineTurn,
|
||||
@@ -86,8 +80,8 @@ type CompleteEmbeddedAttemptAfterTurnInput = {
|
||||
export async function completeEmbeddedAttemptAfterTurn(
|
||||
input: CompleteEmbeddedAttemptAfterTurnInput,
|
||||
): Promise<{ sessionIdUsed: string; sessionFileUsed?: string }> {
|
||||
const { attempt, activeContextEngine, activeSession, sessionManager, state, runtime } = input;
|
||||
let { sessionIdUsed, sessionFileUsed } = state;
|
||||
const { attempt, activeContextEngine, sessionManager, state, runtime } = input;
|
||||
const { sessionIdUsed, sessionFileUsed } = state;
|
||||
|
||||
// Context-engine hooks may call runtime LLM capabilities. Only the transcript
|
||||
// rewrite callback reacquires the synchronous session write boundary.
|
||||
@@ -146,7 +140,6 @@ export async function completeEmbeddedAttemptAfterTurn(
|
||||
}
|
||||
|
||||
if (!state.beforeAgentFinalizeRevisionReason) {
|
||||
await input.sessionLockController.waitForSessionEvents(activeSession);
|
||||
await input.withOwnedSessionWriteLock(async () => {
|
||||
const lifecycleState = input.readLifecycleState();
|
||||
if (
|
||||
@@ -168,40 +161,6 @@ export async function completeEmbeddedAttemptAfterTurn(
|
||||
log.warn(`failed to persist bootstrap completion entry: ${String(entryErr)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
state.compactionOccurredThisAttempt &&
|
||||
!state.promptError &&
|
||||
!lifecycleState.aborted &&
|
||||
!lifecycleState.timedOut &&
|
||||
!lifecycleState.idleTimedOut &&
|
||||
!lifecycleState.timedOutDuringCompaction &&
|
||||
shouldRotateCompactionTranscript(attempt.config)
|
||||
) {
|
||||
try {
|
||||
const rotation = await rotateTranscriptAfterCompaction({
|
||||
sessionManager,
|
||||
sessionFile: attempt.sessionFile,
|
||||
});
|
||||
if (rotation.rotated) {
|
||||
sessionIdUsed = rotation.sessionId ?? sessionIdUsed;
|
||||
sessionFileUsed = rotation.sessionFile ?? sessionFileUsed;
|
||||
updateActiveEmbeddedRunSessionFile(
|
||||
attempt.sessionId,
|
||||
sessionFileUsed,
|
||||
attempt.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(attempt.runId),
|
||||
);
|
||||
log.info(
|
||||
`[compaction] rotated active transcript after automatic compaction ` +
|
||||
`(sessionKey=${attempt.sessionKey ?? attempt.sessionId})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("[compaction] automatic transcript rotation failed", {
|
||||
errorMessage: formatErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +104,16 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: {
|
||||
runtimeAuthState: runtime.runtimeAuthState,
|
||||
});
|
||||
const attemptFastMode = resolveAttemptFastModeParam();
|
||||
const trajectorySessionFile = resolvedSessionKey
|
||||
? (
|
||||
await resolveSessionTranscriptRuntimeReadTarget({
|
||||
const existingSessionTarget = sessionPromptState.sessionTarget;
|
||||
const reusableSessionTarget =
|
||||
existingSessionTarget?.sessionKey === resolvedSessionKey ||
|
||||
sessionPromptState.sessionTargetAdopted
|
||||
? existingSessionTarget
|
||||
: undefined;
|
||||
const resolvedTranscriptTarget =
|
||||
reusableSessionTarget ??
|
||||
(resolvedSessionKey
|
||||
? await resolveSessionTranscriptRuntimeReadTarget({
|
||||
agentId: workspaceResolution.agentId,
|
||||
sessionId: sessionPromptState.sessionId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
@@ -114,8 +121,11 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: {
|
||||
agentId: workspaceResolution.agentId,
|
||||
}),
|
||||
})
|
||||
).sessionFile
|
||||
: sessionPromptState.sessionFile;
|
||||
: undefined);
|
||||
const resolvedSessionTarget = resolvedTranscriptTarget
|
||||
? { ...sessionPromptState.sessionTarget, ...resolvedTranscriptTarget }
|
||||
: sessionPromptState.sessionTarget;
|
||||
const trajectorySessionFile = resolvedSessionTarget?.sessionKey ?? sessionPromptState.sessionFile;
|
||||
if (!input.startupStagesEmitted) {
|
||||
startupStages.mark(EMBEDDED_RUN_ATTEMPT_DISPATCH_STAGE.prompt);
|
||||
}
|
||||
@@ -151,6 +161,19 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: {
|
||||
sessionId: sessionPromptState.sessionId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
sessionFile: trajectorySessionFile,
|
||||
...(resolvedSessionTarget?.agentId &&
|
||||
resolvedSessionTarget.sessionId &&
|
||||
resolvedSessionTarget.sessionKey &&
|
||||
resolvedSessionTarget.storePath
|
||||
? {
|
||||
sessionTarget: {
|
||||
agentId: resolvedSessionTarget.agentId,
|
||||
sessionId: resolvedSessionTarget.sessionId,
|
||||
sessionKey: resolvedSessionTarget.sessionKey,
|
||||
storePath: resolvedSessionTarget.storePath,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
provider: trajectoryAttribution.provider,
|
||||
modelId: trajectoryAttribution.modelId,
|
||||
modelApi: trajectoryAttribution.modelApi,
|
||||
@@ -170,7 +193,7 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: {
|
||||
runtime: {
|
||||
sessionId: sessionPromptState.sessionId,
|
||||
sessionFile: sessionPromptState.sessionFile,
|
||||
sessionTarget: sessionPromptState.sessionTarget,
|
||||
sessionTarget: resolvedSessionTarget,
|
||||
sessionKey: resolvedSessionKey,
|
||||
trajectorySessionFile,
|
||||
trajectoryRecorder: trajectoryRecorder ?? undefined,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { applyEmbeddedAttemptSessionIdentity } from "./attempt-session-identity.js";
|
||||
import { buildContextEngineCompactionSessionTarget } from "./session-bootstrap.js";
|
||||
|
||||
const sessionAccessorMocks = vi.hoisted(() => ({
|
||||
listSessionEntries: vi.fn(() => []),
|
||||
loadSessionEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../config/sessions/session-accessor.js", () => sessionAccessorMocks);
|
||||
|
||||
beforeEach(() => {
|
||||
sessionAccessorMocks.listSessionEntries.mockReset().mockReturnValue([]);
|
||||
sessionAccessorMocks.loadSessionEntry.mockReset();
|
||||
});
|
||||
|
||||
describe("buildContextEngineCompactionSessionTarget", () => {
|
||||
it("uses the marker session id as the fallback compatibility key", () => {
|
||||
expect(
|
||||
buildContextEngineCompactionSessionTarget({
|
||||
sessionFile: "sqlite:main:marker-session:/tmp/sessions.json",
|
||||
sessionId: "stale-outer-session",
|
||||
}),
|
||||
).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "marker-session",
|
||||
sessionKey: "marker-session",
|
||||
storePath: "/tmp/sessions.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the configured default agent for an unscoped compatibility key", () => {
|
||||
expect(
|
||||
buildContextEngineCompactionSessionTarget({
|
||||
config: {
|
||||
agents: { list: [{ id: "main" }, { id: "worker", default: true }] },
|
||||
session: { store: "/tmp/{agentId}/sessions.json" },
|
||||
},
|
||||
sessionFile: "compat-session",
|
||||
sessionId: "compat-session",
|
||||
}),
|
||||
).toMatchObject({
|
||||
agentId: "worker",
|
||||
sessionId: "compat-session",
|
||||
sessionKey: "compat-session",
|
||||
storePath: "/tmp/worker/sessions.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an adopted target session id when no session key is available", () => {
|
||||
expect(
|
||||
buildContextEngineCompactionSessionTarget({
|
||||
sessionFile: "",
|
||||
sessionId: "previous-session",
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: "adopted-session",
|
||||
storePath: "/tmp/sessions.json",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "adopted-session",
|
||||
sessionKey: "adopted-session",
|
||||
storePath: "/tmp/sessions.json",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function promptState(storePath = "/tmp/sessions.json") {
|
||||
return {
|
||||
sessionId: "session-before",
|
||||
sessionFile: "agent:main:main",
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: "session-before",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath,
|
||||
},
|
||||
adoptSessionId: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyEmbeddedAttemptSessionIdentity", () => {
|
||||
it("rejects a legacy successor file that cannot map to SQLite", () => {
|
||||
const state = promptState();
|
||||
|
||||
expect(() =>
|
||||
applyEmbeddedAttemptSessionIdentity({
|
||||
sessionPromptState: state,
|
||||
sessionIdUsed: "session-after",
|
||||
sessionFileUsed: "/tmp/session-after.jsonl",
|
||||
}),
|
||||
).toThrow("successor files are unsupported");
|
||||
expect(state.adoptSessionId).not.toHaveBeenCalled();
|
||||
expect(state.sessionTarget).toMatchObject({ sessionId: "session-before" });
|
||||
});
|
||||
|
||||
it("resolves a legacy SQLite marker successor", () => {
|
||||
const state = promptState();
|
||||
|
||||
applyEmbeddedAttemptSessionIdentity({
|
||||
sessionPromptState: state,
|
||||
sessionIdUsed: "session-after",
|
||||
sessionFileUsed: "sqlite:main:session-after:/tmp/sessions.json",
|
||||
});
|
||||
|
||||
expect(state.sessionTarget).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "session-after",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/sessions.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("rebinds a legacy SQLite marker successor over the retained active entry", () => {
|
||||
sessionAccessorMocks.loadSessionEntry.mockReturnValue({
|
||||
sessionId: "session-before",
|
||||
updatedAt: 1,
|
||||
});
|
||||
const state = promptState();
|
||||
|
||||
applyEmbeddedAttemptSessionIdentity({
|
||||
sessionPromptState: state,
|
||||
sessionIdUsed: "session-after",
|
||||
sessionFileUsed: "sqlite:main:session-after:/tmp/sessions.json",
|
||||
});
|
||||
|
||||
expect(state.sessionTarget).toEqual({
|
||||
agentId: "main",
|
||||
sessionId: "session-after",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/sessions.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a legacy marker successor already mapped to another key", () => {
|
||||
sessionAccessorMocks.loadSessionEntry.mockReturnValue({
|
||||
sessionId: "session-before",
|
||||
updatedAt: 1,
|
||||
});
|
||||
sessionAccessorMocks.listSessionEntries.mockReturnValue([
|
||||
{
|
||||
sessionKey: "agent:main:other",
|
||||
entry: { sessionId: "session-after", updatedAt: 2 },
|
||||
},
|
||||
] as never);
|
||||
const state = promptState();
|
||||
|
||||
expect(() =>
|
||||
applyEmbeddedAttemptSessionIdentity({
|
||||
sessionPromptState: state,
|
||||
sessionIdUsed: "session-after",
|
||||
sessionFileUsed: "sqlite:main:session-after:/tmp/sessions.json",
|
||||
}),
|
||||
).toThrow("successor target changed the active session binding");
|
||||
});
|
||||
|
||||
it("rejects a legacy SQLite marker outside the active store", () => {
|
||||
const state = promptState();
|
||||
|
||||
expect(() =>
|
||||
applyEmbeddedAttemptSessionIdentity({
|
||||
sessionPromptState: state,
|
||||
sessionIdUsed: "session-after",
|
||||
sessionFileUsed: "sqlite:main:session-after:/tmp/other-sessions.json",
|
||||
}),
|
||||
).toThrow("successor target changed the active session binding");
|
||||
});
|
||||
|
||||
it.each(["sqlite:other:session-after:/tmp/sessions.json", "agent:other:main"])(
|
||||
"rejects a cross-agent legacy successor identity: %s",
|
||||
(sessionFileUsed) => {
|
||||
const state = promptState();
|
||||
|
||||
expect(() =>
|
||||
applyEmbeddedAttemptSessionIdentity({
|
||||
sessionPromptState: state,
|
||||
sessionIdUsed: "session-after",
|
||||
sessionFileUsed,
|
||||
}),
|
||||
).toThrow(/successor (identity is inconsistent|files are unsupported)/u);
|
||||
},
|
||||
);
|
||||
|
||||
it("retargets an id-only successor without discarding its SQLite identity", () => {
|
||||
const state = promptState();
|
||||
|
||||
applyEmbeddedAttemptSessionIdentity({
|
||||
sessionPromptState: state,
|
||||
sessionIdUsed: "session-after",
|
||||
});
|
||||
|
||||
expect(state.sessionTarget).toMatchObject({ sessionId: "session-after" });
|
||||
});
|
||||
|
||||
it("refreshes a legacy marker for an id-only successor", () => {
|
||||
const state = promptState();
|
||||
state.sessionFile = "sqlite:main:session-before:/tmp/sessions.json";
|
||||
|
||||
applyEmbeddedAttemptSessionIdentity({
|
||||
sessionPromptState: state,
|
||||
sessionIdUsed: "session-after",
|
||||
});
|
||||
|
||||
expect(state.sessionFile).toBe("sqlite:main:session-after:/tmp/sessions.json");
|
||||
expect(state.sessionTarget).toMatchObject({ sessionId: "session-after" });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parseSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js";
|
||||
import { projectAgentRunAttemptTerminal } from "../../agent-run-terminal-outcome.js";
|
||||
import { formatAssistantErrorText } from "../../embedded-agent-helpers.js";
|
||||
import { createAgentRunDirectAbortError } from "../../run-termination.js";
|
||||
@@ -9,6 +8,7 @@ import { createEmbeddedRunReplayState, observeReplayMetadata } from "../replay-s
|
||||
import type { EmbeddedAgentRunResult } from "../types.js";
|
||||
import type { createUsageAccumulator } from "../usage-accumulator.js";
|
||||
import { mergeUsageIntoAccumulator } from "../usage-accumulator.js";
|
||||
import { applyEmbeddedAttemptSessionIdentity } from "./attempt-session-identity.js";
|
||||
import type { createEmbeddedRunContextRecoveryState } from "./context-recovery-state.js";
|
||||
import type { PreparedEmbeddedRunInput } from "./execution-context.js";
|
||||
import { resolveRunFailoverDecision } from "./failover-policy.js";
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
|
||||
type PreparedRuntime = Awaited<ReturnType<typeof prepareEmbeddedRunRuntime>>;
|
||||
type SessionPromptState = ReturnType<typeof createEmbeddedRunSessionPromptState>;
|
||||
|
||||
type ReplayState = ReturnType<typeof createEmbeddedRunReplayState>;
|
||||
|
||||
export async function normalizeEmbeddedRunAttempt(input: {
|
||||
@@ -144,26 +145,7 @@ export async function normalizeEmbeddedRunAttempt(input: {
|
||||
aborted: terminalAborted,
|
||||
});
|
||||
};
|
||||
const previousSessionId = sessionPromptState.sessionId;
|
||||
const previousSessionFile = sessionPromptState.sessionFile;
|
||||
sessionPromptState.adoptSessionId(sessionIdUsed);
|
||||
if (sessionFileUsed && sessionFileUsed !== sessionPromptState.sessionFile) {
|
||||
sessionPromptState.sessionFile = sessionFileUsed;
|
||||
}
|
||||
if (
|
||||
(sessionIdUsed && sessionIdUsed !== previousSessionId) ||
|
||||
(sessionFileUsed && sessionFileUsed !== previousSessionFile)
|
||||
) {
|
||||
const marker = parseSqliteSessionFileMarker(sessionPromptState.sessionFile);
|
||||
sessionPromptState.sessionTarget = marker
|
||||
? {
|
||||
agentId: marker.agentId,
|
||||
sessionId: marker.sessionId,
|
||||
sessionKey: runInput.resolvedSessionKey,
|
||||
storePath: marker.storePath,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
applyEmbeddedAttemptSessionIdentity({ sessionPromptState, sessionFileUsed, sessionIdUsed });
|
||||
const bootstrapPromptWarningSignaturesSeen =
|
||||
attempt.bootstrapPromptWarningSignaturesSeen ??
|
||||
(attempt.bootstrapPromptWarningSignature
|
||||
|
||||
@@ -49,9 +49,7 @@ describe("embedded attempt phase lifecycle state", () => {
|
||||
} as never,
|
||||
activeSession: activeSession as never,
|
||||
sessionManager: sessionManager as never,
|
||||
sessionLockController: {
|
||||
waitForSessionEvents: async () => {},
|
||||
} as never,
|
||||
sessionLockController: {} as never,
|
||||
withOwnedSessionWriteLock: async (operation) => await operation(),
|
||||
subscription: {
|
||||
toolMetas: [],
|
||||
@@ -97,7 +95,7 @@ describe("embedded attempt phase lifecycle state", () => {
|
||||
expect(removeTrailingEntries).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("re-reads abort state after post-turn session draining", async () => {
|
||||
it("re-reads abort state inside the post-turn session write", async () => {
|
||||
let aborted = false;
|
||||
await completeEmbeddedAttemptAfterTurn({
|
||||
attempt: {
|
||||
@@ -107,12 +105,11 @@ describe("embedded attempt phase lifecycle state", () => {
|
||||
} as never,
|
||||
activeSession: {} as never,
|
||||
sessionManager: { appendCustomEntry: vi.fn() } as never,
|
||||
sessionLockController: {
|
||||
waitForSessionEvents: async () => {
|
||||
aborted = true;
|
||||
},
|
||||
} as never,
|
||||
withOwnedSessionWriteLock: async (operation) => await operation(),
|
||||
sessionLockController: {} as never,
|
||||
withOwnedSessionWriteLock: async (operation) => {
|
||||
aborted = true;
|
||||
return await operation();
|
||||
},
|
||||
state: {
|
||||
promptError: null,
|
||||
yieldAborted: false,
|
||||
@@ -161,7 +158,7 @@ describe("embedded attempt phase lifecycle state", () => {
|
||||
} as never,
|
||||
activeSession: {} as never,
|
||||
sessionManager: { appendCustomEntry: vi.fn() } as never,
|
||||
sessionLockController: { waitForSessionEvents: async () => undefined } as never,
|
||||
sessionLockController: {} as never,
|
||||
withOwnedSessionWriteLock: async (operation) => await operation(),
|
||||
state: {
|
||||
promptError: null,
|
||||
|
||||
@@ -9,7 +9,6 @@ const hoisted = vi.hoisted(() => ({
|
||||
releaseHeldLockForAbort: vi.fn(async () => undefined),
|
||||
releaseLeasedSteering: vi.fn(),
|
||||
stripSessionsYieldArtifacts: vi.fn(),
|
||||
waitForSessionEvents: vi.fn(async () => undefined),
|
||||
waitForSessionsYieldAbortSettle: vi.fn(async () => undefined),
|
||||
withOwnedSessionWriteLock: vi.fn(async (operation: () => unknown) => await operation()),
|
||||
}));
|
||||
@@ -38,7 +37,6 @@ function createInput(overrides: Partial<PromptErrorInput> = {}): PromptErrorInpu
|
||||
releaseLeasedSteering: hoisted.releaseLeasedSteering,
|
||||
sessionLockController: {
|
||||
releaseHeldLockForAbort: hoisted.releaseHeldLockForAbort,
|
||||
waitForSessionEvents: hoisted.waitForSessionEvents,
|
||||
},
|
||||
withOwnedSessionWriteLock: hoisted.withOwnedSessionWriteLock,
|
||||
yieldAbortSettled: null,
|
||||
@@ -63,7 +61,6 @@ describe("handleEmbeddedAttemptPromptError", () => {
|
||||
});
|
||||
|
||||
expect(hoisted.releaseLeasedSteering).toHaveBeenCalledWith(error);
|
||||
expect(hoisted.waitForSessionEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes mid-turn prechecks under the owned session lock", async () => {
|
||||
@@ -80,7 +77,6 @@ describe("handleEmbeddedAttemptPromptError", () => {
|
||||
|
||||
await expect(handleEmbeddedAttemptPromptError(createInput({ error }))).resolves.toEqual({});
|
||||
|
||||
expect(hoisted.waitForSessionEvents).toHaveBeenCalledOnce();
|
||||
expect(hoisted.withOwnedSessionWriteLock).toHaveBeenCalledOnce();
|
||||
expect(hoisted.handleMidTurnPrecheckRequest).toHaveBeenCalledWith(request);
|
||||
});
|
||||
@@ -105,7 +101,6 @@ describe("handleEmbeddedAttemptPromptError", () => {
|
||||
sessionId: "session-1",
|
||||
});
|
||||
expect(hoisted.releaseHeldLockForAbort).toHaveBeenCalledOnce();
|
||||
expect(hoisted.waitForSessionEvents).toHaveBeenCalledWith(input.activeSession);
|
||||
expect(hoisted.stripSessionsYieldArtifacts).toHaveBeenCalledWith(input.activeSession);
|
||||
expect(hoisted.persistSessionsYieldContextMessage).toHaveBeenCalledWith(
|
||||
input.activeSession,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { EmbeddedRunAttemptParams } from "./types.js";
|
||||
type PromptErrorAttempt = Pick<EmbeddedRunAttemptParams, "runId" | "sessionId">;
|
||||
type PromptErrorSessionLockController = Pick<
|
||||
EmbeddedAttemptSessionLockController,
|
||||
"releaseHeldLockForAbort" | "waitForSessionEvents"
|
||||
"releaseHeldLockForAbort"
|
||||
>;
|
||||
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
|
||||
|
||||
@@ -47,8 +47,7 @@ export async function handleEmbeddedAttemptPromptError(input: {
|
||||
runId: input.attempt.runId,
|
||||
sessionId: input.attempt.sessionId,
|
||||
});
|
||||
await input.sessionLockController.releaseHeldLockForAbort();
|
||||
await input.sessionLockController.waitForSessionEvents(input.activeSession);
|
||||
await input.sessionLockController.releaseHeldLockForAbort({ terminal: false });
|
||||
await input.withOwnedSessionWriteLock(async () => {
|
||||
stripSessionsYieldArtifacts(input.activeSession);
|
||||
if (input.yieldMessage) {
|
||||
@@ -60,7 +59,6 @@ export async function handleEmbeddedAttemptPromptError(input: {
|
||||
|
||||
if (isMidTurnPrecheckSignal(input.error)) {
|
||||
const request = input.error.request;
|
||||
await input.sessionLockController.waitForSessionEvents(input.activeSession);
|
||||
await input.withOwnedSessionWriteLock(() => {
|
||||
input.handleMidTurnPrecheckRequest(request);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ const hoisted = vi.hoisted(() => ({
|
||||
reacquireAfterPrompt: vi.fn(async () => undefined),
|
||||
releaseForPrompt: vi.fn(async () => undefined),
|
||||
resolveImageSanitizationLimits: vi.fn(() => ({ maxDimensionPx: 2048 })),
|
||||
waitForSessionEvents: vi.fn(async () => undefined),
|
||||
withSessionWriteLock: vi.fn(async (operation: () => unknown) => await operation()),
|
||||
}));
|
||||
|
||||
@@ -60,7 +59,6 @@ function createInput(overrides: Partial<PromptExecutionInput> = {}): PromptExecu
|
||||
publishOwnedSessionFileSnapshot: hoisted.publishOwnedSessionFileSnapshot,
|
||||
reacquireAfterPrompt: hoisted.reacquireAfterPrompt,
|
||||
releaseForPrompt: hoisted.releaseForPrompt,
|
||||
waitForSessionEvents: hoisted.waitForSessionEvents,
|
||||
withSessionWriteLock: hoisted.withSessionWriteLock,
|
||||
},
|
||||
skipPromptSubmission: false,
|
||||
@@ -119,13 +117,10 @@ describe("prepareEmbeddedAttemptPromptExecution", () => {
|
||||
| {
|
||||
reacquireAfterPrompt: () => Promise<void>;
|
||||
releaseForPrompt: () => Promise<void>;
|
||||
waitForSessionEvents: (session: unknown) => Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
await lockHandoff?.waitForSessionEvents(input.session);
|
||||
await lockHandoff?.releaseForPrompt();
|
||||
await lockHandoff?.reacquireAfterPrompt();
|
||||
expect(hoisted.waitForSessionEvents).toHaveBeenCalledWith(input.session);
|
||||
expect(hoisted.releaseForPrompt).toHaveBeenCalledOnce();
|
||||
expect(hoisted.reacquireAfterPrompt).toHaveBeenCalledOnce();
|
||||
expect(hoisted.detectAndLoadPromptImages).toHaveBeenCalledWith({
|
||||
|
||||
@@ -22,6 +22,7 @@ type PromptExecutionAttempt = Pick<
|
||||
| "model"
|
||||
| "sessionFile"
|
||||
| "sessionKey"
|
||||
| "sessionTarget"
|
||||
| "userTurnTranscriptRecorder"
|
||||
>;
|
||||
type PromptImageResult = Awaited<ReturnType<typeof detectAndLoadPromptImages>>;
|
||||
@@ -54,12 +55,11 @@ export async function prepareEmbeddedAttemptPromptExecution(input: {
|
||||
const { attempt } = input;
|
||||
installPromptSubmissionLockRelease({
|
||||
session: input.session,
|
||||
waitForSessionEvents: (sessionToDrain) =>
|
||||
input.sessionLockController.waitForSessionEvents(sessionToDrain),
|
||||
releaseForPrompt: () => input.sessionLockController.releaseForPrompt(),
|
||||
reacquireAfterPrompt: () => input.sessionLockController.reacquireAfterPrompt(),
|
||||
sessionKey: attempt.sessionKey,
|
||||
sessionFile: attempt.sessionFile,
|
||||
sessionTarget: attempt.sessionTarget,
|
||||
withSessionWriteLock: (run, options) =>
|
||||
input.sessionLockController.withSessionWriteLock(run, options),
|
||||
canAdvanceSessionEntryCache: (snapshot: OwnedSessionTranscriptCacheSnapshot) =>
|
||||
|
||||
@@ -164,9 +164,7 @@ function createFixture() {
|
||||
appendCustomEntry: vi.fn(),
|
||||
getEntries: vi.fn(() => []),
|
||||
};
|
||||
const sessionLockController = {
|
||||
waitForSessionEvents: vi.fn(async () => undefined),
|
||||
};
|
||||
const sessionLockController = {};
|
||||
const input = {
|
||||
attempt: {
|
||||
model: { id: "model-1", provider: "test" },
|
||||
|
||||
@@ -295,7 +295,6 @@ export async function runEmbeddedAttemptPromptPhase(input: {
|
||||
|
||||
const pendingMidTurnPrecheckRequest = input.lifecycle.takePendingMidTurnPrecheckRequest();
|
||||
if (pendingMidTurnPrecheckRequest) {
|
||||
await input.sessionLockController.waitForSessionEvents(activeSession);
|
||||
await input.withOwnedSessionWriteLock(() => {
|
||||
removeTrailingMidTurnPrecheckAssistantError({ activeSession, sessionManager });
|
||||
const state = input.lifecycle.readState();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
formatSqliteSessionFileMarker,
|
||||
parseSqliteSessionFileMarker,
|
||||
} from "../../../config/sessions/legacy-sqlite-marker.js";
|
||||
import { listSessionEntries, loadSessionEntry } from "../../../config/sessions/session-accessor.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
|
||||
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../../sessions/session-id-resolution.js";
|
||||
import type { createEmbeddedRunSessionPromptState } from "./session-prompt-state.js";
|
||||
|
||||
type SessionPromptState = ReturnType<typeof createEmbeddedRunSessionPromptState>;
|
||||
|
||||
export function applyEmbeddedAttemptSessionIdentity(params: {
|
||||
sessionPromptState: Pick<
|
||||
SessionPromptState,
|
||||
"adoptSessionId" | "sessionFile" | "sessionId" | "sessionTarget"
|
||||
>;
|
||||
sessionFileUsed?: string;
|
||||
sessionIdUsed: string;
|
||||
}): void {
|
||||
const { sessionPromptState, sessionFileUsed, sessionIdUsed } = params;
|
||||
const previousSessionId = sessionPromptState.sessionId;
|
||||
let adoptedSessionId = sessionIdUsed;
|
||||
const sessionFileChanged = Boolean(
|
||||
sessionFileUsed && sessionFileUsed !== sessionPromptState.sessionFile,
|
||||
);
|
||||
let nextSessionTarget = sessionPromptState.sessionTarget;
|
||||
if (sessionFileUsed && sessionFileChanged) {
|
||||
const marker = parseSqliteSessionFileMarker(sessionFileUsed);
|
||||
if (marker) {
|
||||
const retainedSessionKey = sessionPromptState.sessionTarget?.sessionKey;
|
||||
const retainedEntry = retainedSessionKey
|
||||
? loadSessionEntry({
|
||||
agentId: marker.agentId,
|
||||
sessionKey: retainedSessionKey,
|
||||
storePath: marker.storePath,
|
||||
})
|
||||
: undefined;
|
||||
const markerMatches = listSessionEntries({
|
||||
agentId: marker.agentId,
|
||||
storePath: marker.storePath,
|
||||
}).filter(({ entry }) => entry.sessionId === marker.sessionId);
|
||||
const preferredMarkerSessionKey = resolvePreferredSessionKeyForSessionIdMatches(
|
||||
markerMatches.map(({ sessionKey, entry }) => [sessionKey, entry]),
|
||||
marker.sessionId,
|
||||
);
|
||||
const markerMappedToRetainedKey = markerMatches.some(
|
||||
({ sessionKey }) => sessionKey === retainedSessionKey,
|
||||
);
|
||||
const successorSessionKey =
|
||||
retainedEntry?.sessionId === marker.sessionId ||
|
||||
(retainedEntry?.sessionId === previousSessionId &&
|
||||
(markerMatches.length === 0 || markerMappedToRetainedKey))
|
||||
? retainedSessionKey
|
||||
: (preferredMarkerSessionKey ??
|
||||
(markerMatches.length === 0 && !retainedEntry ? retainedSessionKey : undefined));
|
||||
if (
|
||||
(marker.sessionId !== sessionIdUsed && sessionIdUsed !== previousSessionId) ||
|
||||
!successorSessionKey ||
|
||||
marker.agentId !== sessionPromptState.sessionTarget?.agentId
|
||||
) {
|
||||
throw new Error("Legacy context-engine successor identity is inconsistent");
|
||||
}
|
||||
adoptedSessionId = marker.sessionId;
|
||||
nextSessionTarget = {
|
||||
...marker,
|
||||
sessionKey: successorSessionKey,
|
||||
};
|
||||
} else if (
|
||||
sessionFileUsed.startsWith("agent:") &&
|
||||
sessionPromptState.sessionTarget &&
|
||||
resolveAgentIdFromSessionKey(sessionFileUsed) === sessionPromptState.sessionTarget.agentId
|
||||
) {
|
||||
const keyedEntry = loadSessionEntry({
|
||||
agentId: sessionPromptState.sessionTarget.agentId,
|
||||
sessionKey: sessionFileUsed,
|
||||
storePath: sessionPromptState.sessionTarget.storePath,
|
||||
});
|
||||
if (!keyedEntry?.sessionId || keyedEntry.sessionId !== sessionIdUsed) {
|
||||
throw new Error("Legacy context-engine successor identity is inconsistent");
|
||||
}
|
||||
nextSessionTarget = {
|
||||
...sessionPromptState.sessionTarget,
|
||||
sessionId: sessionIdUsed,
|
||||
sessionKey: sessionFileUsed,
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
"Legacy context-engine successor files are unsupported; return a structured sessionTarget",
|
||||
);
|
||||
}
|
||||
} else if (sessionIdUsed && sessionIdUsed !== previousSessionId) {
|
||||
nextSessionTarget = sessionPromptState.sessionTarget
|
||||
? { ...sessionPromptState.sessionTarget, sessionId: sessionIdUsed }
|
||||
: undefined;
|
||||
}
|
||||
if (
|
||||
sessionFileChanged &&
|
||||
nextSessionTarget &&
|
||||
sessionPromptState.sessionTarget &&
|
||||
(!nextSessionTarget.agentId ||
|
||||
!nextSessionTarget.sessionKey ||
|
||||
!nextSessionTarget.storePath ||
|
||||
!sessionPromptState.sessionTarget.agentId ||
|
||||
!sessionPromptState.sessionTarget.sessionKey ||
|
||||
!sessionPromptState.sessionTarget.storePath ||
|
||||
nextSessionTarget.agentId !== sessionPromptState.sessionTarget.agentId ||
|
||||
nextSessionTarget.sessionKey !== sessionPromptState.sessionTarget.sessionKey ||
|
||||
path.resolve(nextSessionTarget.storePath) !==
|
||||
path.resolve(sessionPromptState.sessionTarget.storePath))
|
||||
) {
|
||||
throw new Error("Legacy context-engine successor target changed the active session binding");
|
||||
}
|
||||
sessionPromptState.adoptSessionId(adoptedSessionId);
|
||||
if (sessionFileUsed && sessionFileChanged) {
|
||||
sessionPromptState.sessionFile = sessionFileUsed;
|
||||
} else if (adoptedSessionId !== previousSessionId && nextSessionTarget) {
|
||||
const marker = parseSqliteSessionFileMarker(sessionPromptState.sessionFile);
|
||||
if (marker) {
|
||||
sessionPromptState.sessionFile = formatSqliteSessionFileMarker({
|
||||
agentId: nextSessionTarget.agentId ?? marker.agentId,
|
||||
sessionId: nextSessionTarget.sessionId ?? marker.sessionId,
|
||||
storePath: nextSessionTarget.storePath ?? marker.storePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
sessionPromptState.sessionTarget = nextSessionTarget;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
|
||||
acquireSessionFileOwner: vi.fn(),
|
||||
acquireSessionWriteLock: vi.fn(),
|
||||
createSessionLockController: vi.fn(),
|
||||
resolveSessionWriteLockTargetKey: vi.fn(() => "canonical-lock-key"),
|
||||
resolveCompactionTimeoutMs: vi.fn(() => 30_000),
|
||||
resolveSessionWriteLockOptions: vi.fn(() => ({
|
||||
timeoutMs: 100,
|
||||
@@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../../session-write-lock.js", () => ({
|
||||
acquireSessionWriteLock: mocks.acquireSessionWriteLock,
|
||||
resolveSessionWriteLockTargetKey: mocks.resolveSessionWriteLockTargetKey,
|
||||
}));
|
||||
vi.mock("../compaction-safety-timeout.js", () => ({
|
||||
resolveCompactionTimeoutMs: mocks.resolveCompactionTimeoutMs,
|
||||
@@ -47,7 +49,7 @@ function createFixture(options?: { rejectPostArmFence?: Error }) {
|
||||
const sessionFileOwner = { release: vi.fn() };
|
||||
const sessionManager = {
|
||||
mergePromptReleasedSessionEntries: vi.fn(() => "merged"),
|
||||
setSessionFile: vi.fn(),
|
||||
reloadPersistedTranscript: vi.fn(),
|
||||
};
|
||||
const sessionLockController = {
|
||||
canAdvanceSessionEntryCache: vi.fn(() => true),
|
||||
@@ -83,7 +85,14 @@ function createFixture(options?: { rejectPostArmFence?: Error }) {
|
||||
abortSignal: new AbortController().signal,
|
||||
config: {},
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
},
|
||||
},
|
||||
externalAbortController,
|
||||
getSessionManager: () => sessionManager,
|
||||
@@ -141,19 +150,23 @@ describe("prepareEmbeddedAttemptSessionLock", () => {
|
||||
acquireSessionWriteLock: mocks.acquireSessionWriteLock,
|
||||
initialAcquireSignal: fixture.input.attempt.abortSignal,
|
||||
lockOptions: {
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionFile: "canonical-lock-key",
|
||||
targetKind: "session-key",
|
||||
timeoutMs: 100,
|
||||
staleMs: 200,
|
||||
maxHoldMs: 300,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mocks.resolveSessionWriteLockTargetKey).toHaveBeenCalledWith(
|
||||
fixture.input.attempt.sessionTarget,
|
||||
);
|
||||
expect(controllerInput?.mergePromptReleasedSessionEntries([])).toBe("merged");
|
||||
expect(fixture.sessionManager.mergePromptReleasedSessionEntries).toHaveBeenCalledWith([], {
|
||||
persistLeaf: true,
|
||||
});
|
||||
controllerInput?.reloadPromptReleasedSessionFile();
|
||||
expect(fixture.sessionManager.setSessionFile).toHaveBeenCalledWith("/tmp/session.jsonl");
|
||||
expect(fixture.sessionManager.reloadPersistedTranscript).toHaveBeenCalledOnce();
|
||||
|
||||
await expect(result.withOwnedSessionWriteLock(async () => "done")).resolves.toBe("done");
|
||||
expect(fixture.sessionLockController.withSessionWriteLock).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/** Acquires and publishes the session-write ownership used by one attempt. */
|
||||
import { withOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js";
|
||||
import { resolveAgentRunSessionTarget } from "../../run-session-target.js";
|
||||
import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
|
||||
import { acquireSessionWriteLock } from "../../session-write-lock.js";
|
||||
import {
|
||||
acquireSessionWriteLock,
|
||||
resolveSessionWriteLockTargetKey,
|
||||
} from "../../session-write-lock.js";
|
||||
import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js";
|
||||
import { resolveEmbeddedAttemptSessionWriteLockOptions } from "./attempt.run-decisions.js";
|
||||
import {
|
||||
@@ -18,7 +22,10 @@ type OwnedTranscriptWriteContext = Parameters<typeof withOwnedSessionTranscriptW
|
||||
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
|
||||
|
||||
export async function prepareEmbeddedAttemptSessionLock(input: {
|
||||
attempt: Pick<EmbeddedRunAttemptParams, "abortSignal" | "config" | "sessionFile" | "sessionKey">;
|
||||
attempt: Pick<
|
||||
EmbeddedRunAttemptParams,
|
||||
"abortSignal" | "config" | "sessionFile" | "sessionId" | "sessionKey" | "sessionTarget"
|
||||
>;
|
||||
externalAbortController: {
|
||||
arm: () => void;
|
||||
throwIfFiredAfterPrepCleanup: () => Promise<void>;
|
||||
@@ -38,6 +45,14 @@ export async function prepareEmbeddedAttemptSessionLock(input: {
|
||||
config: attempt.config,
|
||||
compactionTimeoutMs,
|
||||
});
|
||||
const sessionTarget = await resolveAgentRunSessionTarget({
|
||||
agentId: attempt.sessionTarget?.agentId,
|
||||
config: attempt.config,
|
||||
sessionFile: attempt.sessionFile,
|
||||
sessionId: attempt.sessionId,
|
||||
sessionKey: attempt.sessionKey,
|
||||
sessionTarget: attempt.sessionTarget,
|
||||
});
|
||||
|
||||
await externalAbortController.throwIfFiredAfterPrepCleanup();
|
||||
const sessionFileOwner = await acquireEmbeddedAttemptSessionFileOwner({
|
||||
@@ -60,7 +75,8 @@ export async function prepareEmbeddedAttemptSessionLock(input: {
|
||||
acquireSessionWriteLock,
|
||||
initialAcquireSignal: attempt.abortSignal,
|
||||
lockOptions: {
|
||||
sessionFile: attempt.sessionFile,
|
||||
sessionFile: resolveSessionWriteLockTargetKey(sessionTarget),
|
||||
targetKind: "session-key",
|
||||
...sessionWriteLockOptions,
|
||||
},
|
||||
mergePromptReleasedSessionEntries: (entries) =>
|
||||
@@ -68,7 +84,7 @@ export async function prepareEmbeddedAttemptSessionLock(input: {
|
||||
persistLeaf: true,
|
||||
}),
|
||||
reloadPromptReleasedSessionFile: () => {
|
||||
getSessionManager("file reload").setSessionFile(attempt.sessionFile);
|
||||
getSessionManager("file reload").reloadPersistedTranscript();
|
||||
},
|
||||
});
|
||||
input.onSessionLockReleaseReady(() => sessionLockController.dispose());
|
||||
@@ -76,6 +92,7 @@ export async function prepareEmbeddedAttemptSessionLock(input: {
|
||||
const ownedTranscriptWriteContext: OwnedTranscriptWriteContext = {
|
||||
sessionFile: attempt.sessionFile,
|
||||
sessionKey: attempt.sessionKey,
|
||||
sessionTarget: attempt.sessionTarget,
|
||||
canAdvanceSessionEntryCache: (snapshot) =>
|
||||
sessionLockController.canAdvanceSessionEntryCache(snapshot),
|
||||
publishSessionFileSnapshot: (snapshot) =>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import type { SessionTranscriptRuntimeTarget } from "../../../config/sessions/session-accessor.types.js";
|
||||
/**
|
||||
* Prepares the durable session manager before embedded-agent session creation.
|
||||
*/
|
||||
import { parseSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js";
|
||||
import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../../context-engine/host-compat.js";
|
||||
import type { AgentMessage } from "../../runtime/index.js";
|
||||
import {
|
||||
invalidateSessionFileRepairCache,
|
||||
repairSessionFileIfNeeded,
|
||||
} from "../../session-file-repair.js";
|
||||
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
|
||||
import { SessionManager } from "../../sessions/index.js";
|
||||
import { runContextEngineMaintenance } from "../context-engine-maintenance.js";
|
||||
import { log } from "../logger.js";
|
||||
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
|
||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||
import { resolveExistingAttemptTranscriptState } from "./attempt-transcript-helpers.js";
|
||||
import {
|
||||
runAttemptContextEngineBootstrap,
|
||||
@@ -28,24 +22,6 @@ import type { EmbeddedRunAttemptParams } from "./types.js";
|
||||
|
||||
type AttemptSessionManager = ReturnType<typeof guardSessionManager>;
|
||||
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
|
||||
type CompactionPersistenceGuard = Pick<
|
||||
NonNullable<Parameters<typeof guardSessionManager>[1]>,
|
||||
"withCompactionPersistence"
|
||||
>;
|
||||
|
||||
function buildCompactionPersistenceGuard(params: {
|
||||
sessionFile: string;
|
||||
sessionLockController: Pick<EmbeddedAttemptSessionLockController, "withOwnedSessionFileWrite">;
|
||||
}): CompactionPersistenceGuard {
|
||||
if (parseSqliteSessionFileMarker(params.sessionFile)) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
withCompactionPersistence: (append, validateAppend) =>
|
||||
params.sessionLockController.withOwnedSessionFileWrite(append, validateAppend),
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareEmbeddedAttemptSessionManager(input: {
|
||||
attempt: EmbeddedRunAttemptParams;
|
||||
activeContextEngine?: AttemptContextEngine;
|
||||
@@ -60,20 +36,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: {
|
||||
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
|
||||
}) {
|
||||
const { attempt } = input;
|
||||
const trustedSessionFileSnapshot =
|
||||
await input.sessionLockController.readTrustedCurrentSessionFileSnapshot();
|
||||
const repairReport = await repairSessionFileIfNeeded({
|
||||
sessionFile: attempt.sessionFile,
|
||||
trustedSnapshot: trustedSessionFileSnapshot,
|
||||
debug: (message) => log.debug(message),
|
||||
warn: (message) => log.warn(message),
|
||||
});
|
||||
if (
|
||||
repairReport.validatedSnapshot &&
|
||||
!input.sessionLockController.publishValidatedSessionFileSnapshot(repairReport.validatedSnapshot)
|
||||
) {
|
||||
invalidateSessionFileRepairCache(attempt.sessionFile);
|
||||
}
|
||||
const transcriptState = await resolveExistingAttemptTranscriptState({
|
||||
agentId: input.sessionAgentId,
|
||||
config: attempt.config,
|
||||
@@ -99,7 +61,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: {
|
||||
attempt.model.api === "azure-openai-responses" ||
|
||||
attempt.model.api === "openai-chatgpt-responses";
|
||||
|
||||
await prewarmSessionFile(attempt.sessionFile);
|
||||
const preparedUserTurnMessage = attempt.skipPreparedUserTurnMessage
|
||||
? undefined
|
||||
: await attempt.userTurnTranscriptRecorder?.resolveMessage();
|
||||
@@ -107,56 +68,60 @@ export async function prepareEmbeddedAttemptSessionManager(input: {
|
||||
let latestRuntimeUserMessage: AgentMessage | undefined;
|
||||
let latestUserTurnTranscriptRecorder = attempt.userTurnTranscriptRecorder;
|
||||
const userTranscriptContextRegistry = createUserTranscriptContextRegistry();
|
||||
const sessionManager = guardSessionManager(SessionManager.open(attempt.sessionFile), {
|
||||
agentId: input.sessionAgentId,
|
||||
sessionKey: attempt.sessionKey,
|
||||
config: attempt.config,
|
||||
contextWindowTokens: attempt.contextTokenBudget,
|
||||
inputProvenance: attempt.inputProvenance,
|
||||
preparedUserTurnMessage,
|
||||
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||
missingToolResultText: isOpenAIResponsesApi ? "aborted" : undefined,
|
||||
allowedToolNames: input.replayAllowedToolNames,
|
||||
trigger: attempt.trigger,
|
||||
suppressNextUserMessagePersistence: attempt.suppressNextUserMessagePersistence,
|
||||
suppressTranscriptOnlyAssistantPersistence: attempt.suppressTranscriptOnlyAssistantPersistence,
|
||||
suppressAssistantErrorPersistence: attempt.suppressAssistantErrorPersistence,
|
||||
skipBeforeMessageWriteHooks: attempt.operation === "settled-tool-finalization",
|
||||
onMessagePersisted: () => {
|
||||
input.sessionLockController.refreshAfterOwnedSessionWrite();
|
||||
const sessionManager = guardSessionManager(
|
||||
attempt.sessionManager ??
|
||||
(attempt.sessionTarget
|
||||
? SessionManager.open(
|
||||
attempt.sessionTarget as SessionTranscriptRuntimeTarget,
|
||||
input.effectiveCwd,
|
||||
)
|
||||
: SessionManager.inMemory(input.effectiveCwd)),
|
||||
{
|
||||
agentId: input.sessionAgentId,
|
||||
sessionKey: attempt.sessionKey,
|
||||
config: attempt.config,
|
||||
contextWindowTokens: attempt.contextTokenBudget,
|
||||
inputProvenance: attempt.inputProvenance,
|
||||
preparedUserTurnMessage,
|
||||
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||
missingToolResultText: isOpenAIResponsesApi ? "aborted" : undefined,
|
||||
allowedToolNames: input.replayAllowedToolNames,
|
||||
trigger: attempt.trigger,
|
||||
suppressNextUserMessagePersistence: attempt.suppressNextUserMessagePersistence,
|
||||
suppressTranscriptOnlyAssistantPersistence:
|
||||
attempt.suppressTranscriptOnlyAssistantPersistence,
|
||||
suppressAssistantErrorPersistence: attempt.suppressAssistantErrorPersistence,
|
||||
skipBeforeMessageWriteHooks: attempt.operation === "settled-tool-finalization",
|
||||
onMessagePersisted: () => {
|
||||
input.sessionLockController.refreshAfterOwnedSessionWrite();
|
||||
},
|
||||
onUserMessagePreparingForPersistence: (_message, recorder, preparedMessage) => {
|
||||
latestPersistedUserMessage = undefined;
|
||||
latestUserTurnTranscriptRecorder =
|
||||
recorder ??
|
||||
(preparedMessage === preparedUserTurnMessage
|
||||
? attempt.userTurnTranscriptRecorder
|
||||
: undefined);
|
||||
},
|
||||
onUserMessagePersisted: (message, runtimeMessage) => {
|
||||
latestPersistedUserMessage = message;
|
||||
latestRuntimeUserMessage = runtimeMessage;
|
||||
if (runtimeMessage) {
|
||||
userTranscriptContextRegistry.record(runtimeMessage, message);
|
||||
}
|
||||
attempt.onUserMessagePersisted?.(message);
|
||||
},
|
||||
onUserMessagePersistenceSuppressed: (_message, runtimeMessage) => {
|
||||
latestRuntimeUserMessage = runtimeMessage;
|
||||
},
|
||||
onUserMessageBlocked: () => {
|
||||
attempt.userTurnTranscriptRecorder?.markBlocked();
|
||||
},
|
||||
onAssistantErrorMessagePersisted: (message) => {
|
||||
attempt.onAssistantErrorMessagePersisted?.(message);
|
||||
},
|
||||
},
|
||||
// SQLite owns compaction persistence and validation inside its transaction.
|
||||
// This append validator is a JSONL file-ownership fence, not a storage shim.
|
||||
...buildCompactionPersistenceGuard({
|
||||
sessionFile: attempt.sessionFile,
|
||||
sessionLockController: input.sessionLockController,
|
||||
}),
|
||||
onUserMessagePreparingForPersistence: (_message, recorder, preparedMessage) => {
|
||||
latestPersistedUserMessage = undefined;
|
||||
latestUserTurnTranscriptRecorder =
|
||||
recorder ??
|
||||
(preparedMessage === preparedUserTurnMessage
|
||||
? attempt.userTurnTranscriptRecorder
|
||||
: undefined);
|
||||
},
|
||||
onUserMessagePersisted: (message, runtimeMessage) => {
|
||||
latestPersistedUserMessage = message;
|
||||
latestRuntimeUserMessage = runtimeMessage;
|
||||
if (runtimeMessage) {
|
||||
userTranscriptContextRegistry.record(runtimeMessage, message);
|
||||
}
|
||||
attempt.onUserMessagePersisted?.(message);
|
||||
},
|
||||
onUserMessagePersistenceSuppressed: (_message, runtimeMessage) => {
|
||||
latestRuntimeUserMessage = runtimeMessage;
|
||||
},
|
||||
onUserMessageBlocked: () => {
|
||||
attempt.userTurnTranscriptRecorder?.markBlocked();
|
||||
},
|
||||
onAssistantErrorMessagePersisted: (message) => {
|
||||
attempt.onAssistantErrorMessagePersisted?.(message);
|
||||
},
|
||||
});
|
||||
);
|
||||
attempt.promptCacheKey = resolveSessionBoundaryPromptCacheKey({
|
||||
api: attempt.model.api,
|
||||
boundaryCount: sessionManager.getBoundaryCount(),
|
||||
@@ -166,7 +131,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: {
|
||||
// Publish ownership before async bootstrap. Outer cleanup must close this manager
|
||||
// even when a context-engine or transcript preparation step fails.
|
||||
input.onSessionManagerCreated(sessionManager);
|
||||
trackSessionManagerAccess(attempt.sessionFile);
|
||||
|
||||
await input.withOwnedSessionWriteLock(async () => {
|
||||
await runAttemptContextEngineBootstrap({
|
||||
@@ -208,14 +172,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: {
|
||||
}),
|
||||
warn: (message) => log.warn(message),
|
||||
});
|
||||
|
||||
await prepareSessionManagerForRun({
|
||||
sessionManager,
|
||||
sessionFile: attempt.sessionFile,
|
||||
hadSessionFile: transcriptState.hasFileTranscriptState,
|
||||
sessionId: attempt.sessionId,
|
||||
cwd: input.effectiveCwd,
|
||||
});
|
||||
});
|
||||
// Bootstrap may repair or migrate transcript rows. Only user writes after
|
||||
// preparation can be the active prompt source at the provider boundary.
|
||||
|
||||
@@ -42,9 +42,6 @@ function createFixture(overrides?: Partial<FinalizeInput>) {
|
||||
buildSessionContext: () => ({ messages: repairedMessages }),
|
||||
},
|
||||
sessionLockController: {
|
||||
waitForSessionEvents: vi.fn(async () => {
|
||||
order.push("session-events");
|
||||
}),
|
||||
releaseForPrompt: vi.fn(async () => {
|
||||
order.push("release-prompt-lock");
|
||||
}),
|
||||
@@ -143,7 +140,6 @@ describe("finalizeEmbeddedAttemptStreamPhase", () => {
|
||||
|
||||
expect(fixture.activeSession.agent.state.messages).toBe(fixture.repairedMessages);
|
||||
expect(fixture.order).toEqual([
|
||||
"session-events",
|
||||
"pending-events",
|
||||
"release-prompt-lock",
|
||||
"settle",
|
||||
|
||||
@@ -42,7 +42,6 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: {
|
||||
}): Promise<{ sessionIdUsed: string; sessionFileUsed?: string }> {
|
||||
const { activeSession, sessionManager, sessionLockController, withOwnedSessionWriteLock } = input;
|
||||
|
||||
await sessionLockController.waitForSessionEvents(activeSession);
|
||||
await input.waitForPendingEvents();
|
||||
if (input.repairedRejectedThinkingReplay) {
|
||||
activeSession.agent.state.messages = sessionManager.buildSessionContext().messages;
|
||||
|
||||
@@ -160,8 +160,6 @@ export async function settleEmbeddedAttemptStream(input: {
|
||||
promptErrorSource = "prompt";
|
||||
state.promptError = promptError;
|
||||
state.promptErrorSource = promptErrorSource;
|
||||
} else if (asyncTaskWait.waitedRunIds.length > 0) {
|
||||
await input.sessionLockController.waitForSessionEvents(activeSession);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +233,6 @@ export async function settleEmbeddedAttemptStream(input: {
|
||||
let lastCallUsage: NormalizedUsage | undefined;
|
||||
let promptCache: EmbeddedRunAttemptResult["promptCache"];
|
||||
|
||||
await input.sessionLockController.waitForSessionEvents(activeSession);
|
||||
await input.withOwnedSessionWriteLock(async () => {
|
||||
const { timedOutDuringCompaction } = input.readLifecycleState();
|
||||
compactionOccurredThisAttempt = subscription.getCompactionCount() > 0;
|
||||
|
||||
@@ -31,6 +31,12 @@ function createInput(disableTrajectory = false) {
|
||||
runId: "run-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionTarget: {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
storePath: "/tmp/openclaw-agent.sqlite",
|
||||
},
|
||||
thinkLevel: "medium",
|
||||
trigger: "user",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
@@ -67,6 +73,12 @@ describe("prepareEmbeddedAttemptTrajectory", () => {
|
||||
runId: "run-1",
|
||||
sessionFile: "/tmp/trajectory.jsonl",
|
||||
sessionId: "session-1",
|
||||
sessionTarget: expect.objectContaining({
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
storePath: "/tmp/openclaw-agent.sqlite",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(recorder.recordEvent).toHaveBeenNthCalledWith(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user