Compare commits

..

28 Commits

Author SHA1 Message Date
Peter Steinberger
ddaa973bda fix: refresh preflight rotated runs 2026-04-27 02:00:03 +01:00
Peter Steinberger
bdd9e2617f fix: align rotated compaction metadata 2026-04-27 01:42:41 +01:00
pash
5646f316cc test: align node exec cwd assertions with direct run path 2026-04-26 17:37:52 -07:00
pash
c94f200659 fix: break node exec host helper import cycle 2026-04-26 17:33:44 -07:00
pash
fffb8d7b19 Merge remote-tracking branch 'origin/main' into HEAD 2026-04-26 17:32:03 -07:00
Peter Steinberger
cfbf156444 fix: reuse delegated compaction rotation 2026-04-27 01:28:29 +01:00
Peter Steinberger
acd1bd7d31 fix(exec): skip node approval prepare in yolo mode 2026-04-27 01:27:58 +01:00
Peter Steinberger
11e17793e1 ci: include node22 compat in manual full ci 2026-04-27 01:27:27 +01:00
pash
0bec32fd0d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	src/cli/update-cli.test.ts
#	test/vitest/vitest.shared.config.ts
2026-04-26 17:27:05 -07:00
Peter Steinberger
90b3cdb6a7 test(docker): fix update fixture pnpm patch config 2026-04-27 01:25:00 +01:00
Peter Steinberger
7ca2f9fed5 test(docker): align package harness image 2026-04-27 01:22:58 +01:00
Vincent Koc
732a5842ee fix(gateway): defer implicit qmd memory startup 2026-04-26 17:21:50 -07:00
pash
8cad8e6f31 Preserve surviving labels in successor transcripts 2026-04-26 17:21:45 -07:00
Vincent Koc
d7c173b694 fix(gateway): harden macOS launchd service startup 2026-04-26 17:18:49 -07:00
Peter Steinberger
6fe99bd184 test: fix changed target helper typing 2026-04-27 01:16:59 +01:00
Peter Steinberger
6fed787297 test: align release boundary expectations 2026-04-27 01:16:15 +01:00
Vincent Koc
7cecbe1002 test(plugins): guard cold status snapshots
Add a reusable cold plugin fixture and status snapshot guard proving read-only plugin metadata paths do not import plugin runtime entries.
2026-04-26 17:15:39 -07:00
Peter Steinberger
296fafd716 fix: carry compacted transcript rotation through retries 2026-04-27 01:15:34 +01:00
pash
ec3210e359 Merge remote-tracking branch 'origin/main' into fix/compact-successor-transcripts
# Conflicts:
#	src/scripts/test-projects.test.ts
2026-04-26 17:11:01 -07:00
Peter Steinberger
0f672dcc73 fix(ollama): align web search endpoint routing 2026-04-27 01:10:41 +01:00
Peter Steinberger
b825c8d34b test: fix full ci suite follow-ups 2026-04-27 01:10:32 +01:00
pash
d71ac48060 Fix successor transcript PR CI and review feedback 2026-04-26 17:10:25 -07:00
Peter Steinberger
3b514ad5f3 test(docker): run mounted harnesses with image tsx 2026-04-27 01:05:20 +01:00
Peter Steinberger
82b928232e test(docker): stabilize package update lanes 2026-04-27 01:02:36 +01:00
Peter Steinberger
30d9e70988 test(gateway): stabilize session cleanup gates 2026-04-27 01:02:13 +01:00
Peter Steinberger
a3e0674261 fix(ollama): harden native provider routing 2026-04-27 01:02:13 +01:00
Peter Steinberger
be56f172ab fix: scope qmd root memory collection 2026-04-27 01:01:58 +01:00
Peter Steinberger
c88b1e8737 fix: rotate compacted session transcripts 2026-04-27 01:01:41 +01:00
102 changed files with 2985 additions and 1881 deletions

View File

@@ -1087,7 +1087,7 @@ jobs:
contents: read
name: checks-node-compat-node22
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'push'
if: needs.preflight.outputs.run_build_artifacts == 'true' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:

View File

@@ -6,6 +6,9 @@ Docs: https://docs.openclaw.ai
### Fixes
- macOS Gateway: write launchd services with a state-dir `WorkingDirectory`, use a durable state-dir temp path instead of freezing macOS session `TMPDIR`, create that temp directory before bootstrap, and label abort-shaped launchd exits as `SIGABRT/abort` in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius.
- Exec/node: skip approval-plan preparation for full-trust `host=node` runs so interpreter and script commands no longer fail with `SYSTEM_RUN_DENIED: approval cannot safely bind` when effective policy is `security=full` and `ask=off`. Fixes #48457 and duplicate #69251. Thanks @ajtran303, @jaserNo1, @Blakeshannon, @lesliefag, and @AvIsBeastMC.
- Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Thanks @codex.
- Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq.
- Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han.
- Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67.
@@ -18,6 +21,10 @@ Docs: https://docs.openclaw.ai
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
- Providers/Ollama: expose native Ollama thinking effort levels so `/think max` is accepted for reasoning-capable Ollama models and maps to Ollama's highest supported `think` effort. Fixes #71584. Thanks @g0st1n.
- Providers/Ollama: strip the active custom Ollama provider prefix before native chat and embedding requests, so custom provider ids like `ollama-spark/qwen3:32b` reach Ollama as the real model name. Fixes #72353. Thanks @maximus-dss and @hclsys.
- Providers/Ollama: move memory embeddings to Ollama's current `/api/embed` endpoint with batched `input` requests while preserving vector normalization and custom provider auth/header overrides. Fixes #39983. Thanks @sskkcc and @LiudengZhang.
- Providers/Ollama: route local web search through Ollama's signed `/api/experimental/web_search` daemon proxy, use hosted `/api/web_search` directly for `ollama.com`, and keep `OLLAMA_API_KEY` scoped to cloud fallback auth. Fixes #69132. Thanks @yoon1012 and @hyspacex.
- Agents/Ollama: apply provider-owned replay turn normalization to native Ollama chat so Cloud models no longer reject non-alternating replay history in agent/Gateway runs. Fixes #71697. Thanks @ismael-81.
- Agents/Ollama: validate explicit `--thinking max` against catalog-discovered Ollama reasoning metadata so local agent runs accept the same native thinking levels shown in the model catalog. Fixes #71584. Thanks @g0st1n.
- Docker/QA: add observability coverage to the normal Docker aggregate so QA-lab OTEL and Prometheus diagnostics run inside Docker. Thanks @vincentkoc.
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.

View File

@@ -1,4 +1,4 @@
3e6dd8292d9350b0ccc243f81f7b6e95494fc769c01c084d8d6d6e9e1f668a14 config-baseline.json
e040e5818afe66d71fc8a7ae1653f1e8c252cc5b51480ef3b4ae1269682b9ade config-baseline.core.json
79fa6b9b9df5e22ac56a7edb9bfc25550131e285ce9f4868f468d957a8768240 config-baseline.json
2722504ab6bd37eea9e7542689bd6dba5fb4e485c0eab9c1915427c49a5c5b66 config-baseline.core.json
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
74b74cb18ac37c0acaa765f398f1f9edbcee4c43567f02d45c89598a1e13afb4 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
21914ef8c5840e0defc36d571834dc28a92d6d5ca2d42a088c33b4de681e836a plugin-sdk-api-baseline.json
3f22e6af0dad3433d25d996802d7436a3cc0e68bc86ecaf813a22e2b4e5333eb plugin-sdk-api-baseline.jsonl
ba5191d586958233c69921928e4d13ae6e8af61e26cf57eec6f50c5d551d8b43 plugin-sdk-api-baseline.json
e6fc8ea33cfc6251a080c3a49d0db2e7d82c117f412902c79da359ebbc9197cc plugin-sdk-api-baseline.jsonl

View File

@@ -21,12 +21,8 @@ calls paired with their matching `toolResult` entries. If a split point lands
inside a tool block, OpenClaw moves the boundary so the pair stays together and
the current unsummarized tail is preserved.
By default, OpenClaw also rewrites the session transcript after compaction and
removes the message entries that were summarized. The persisted summary and
recent unsummarized tail remain on disk. Set
`agents.defaults.compaction.truncateAfterCompaction` to `false` if you need the
older behavior where compaction only changed what the model saw on the next
turn and left the full transcript intact.
The full conversation history stays on disk. Compaction only changes what the
model sees on the next turn.
## Auto-compaction
@@ -122,6 +118,12 @@ honors that Pi 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.
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 keeps the previous JSONL as the archived checkpoint
source.
## Using a different model
By default, compaction uses your agent's primary model. You can use a more

View File

@@ -194,6 +194,10 @@ Required members:
Prepended to the system prompt.
</ParamField>
`compact` returns a `CompactResult`. When compaction rotates the active
transcript, `result.sessionId` and `result.sessionFile` identify the successor
session that the next retry or turn must use.
Optional members:
| Member | Kind | Purpose |

View File

@@ -65,14 +65,10 @@ model calls must not export `StreamAbandoned` on successful turns; raw diagnosti
`openclaw.content.*` attributes must stay out of the trace. It writes
`otel-smoke-summary.json` next to the QA suite artifacts.
The normal Docker aggregate and release-path core chunk also run an
observability lane. It reuses the shared package-installed functional Docker
image, mounts the QA harness files read-only, runs the OTEL trace smoke inside
the container, then runs the `docker-prometheus-smoke` QA scenario with the
`diagnostics-prometheus` plugin enabled. Set
`OPENCLAW_DOCKER_OBSERVABILITY_LOOPS=<count>` to repeat both checks inside one
Docker run while preserving per-loop artifacts under
`.artifacts/docker-observability/...`.
Observability QA stays source-checkout only. The npm tarball intentionally omits
QA Lab, so package Docker release lanes do not run `qa` commands. Use
`pnpm qa:otel:smoke` from a built source checkout when changing diagnostics
instrumentation.
For a transport-real Matrix smoke lane, run:

View File

@@ -617,7 +617,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
- CLI backend smoke: `pnpm test:docker:live-cli-backend` (script: `scripts/test-live-cli-backend-docker.sh`)
- Codex app-server harness smoke: `pnpm test:docker:live-codex-harness` (script: `scripts/test-live-codex-harness-docker.sh`)
- Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`)
- Docker observability smoke: included in `pnpm test:docker:all`, `pnpm test:docker:local:all`, and the release-path `core` chunk (script: `scripts/e2e/docker-observability-smoke.sh`). It runs QA-lab OTEL and Prometheus diagnostics checks inside the shared package-installed functional Docker image, with only QA harness files mounted read-only. Set `OPENCLAW_DOCKER_OBSERVABILITY_LOOPS=<count>` to repeat both checks in one container run.
- Observability smoke: `pnpm qa:otel:smoke` is a private QA source-checkout lane. It is intentionally not part of package Docker release lanes because the npm tarball omits QA Lab.
- Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`)
- Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`)
- Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies doctor repairs activated plugin runtime deps, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`.

View File

@@ -318,6 +318,10 @@ Once configured, all your Ollama models are available:
}
```
Custom Ollama provider ids are also supported. When a model ref uses the active
provider prefix, such as `ollama-spark/qwen3:32b`, OpenClaw strips only that
prefix before calling Ollama so the server receives `qwen3:32b`.
## Ollama Web Search
OpenClaw supports **Ollama Web Search** as a bundled `web_search` provider.
@@ -437,7 +441,8 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
<Accordion title="Memory embeddings">
The bundled Ollama plugin registers a memory embedding provider for
[memory search](/concepts/memory). It uses the configured Ollama base URL
and API key.
and API key, calls Ollama's current `/api/embed` endpoint, and batches
multiple memory chunks into one `input` request when possible.
| Property | Value |
| ------------- | ------------------- |

View File

@@ -193,12 +193,7 @@ Notable entry types:
- `compaction`: persisted compaction summary with `firstKeptEntryId` and `tokensBefore`
- `branch_summary`: persisted summary when navigating a tree branch
OpenClaw uses `SessionManager` for normal transcript reads/writes. After
compaction, the Gateway now defaults to a bounded transcript rewrite that drops
message entries already covered by the persisted compaction summary while
keeping non-message session state and the recent unsummarized tail. Set
`agents.defaults.compaction.truncateAfterCompaction` to `false` to preserve the
legacy append-only behavior.
OpenClaw intentionally does **not** “fix up” transcripts; the Gateway uses `SessionManager` to read/write them.
---
@@ -290,6 +285,10 @@ OpenClaw also enforces a safety floor for embedded runs:
and keeps Pi's recent-tail cut point. Without an explicit keep budget,
manual compaction remains a hard checkpoint and rebuilt context starts from
the new summary.
- When `agents.defaults.compaction.truncateAfterCompaction` is enabled,
OpenClaw rotates the active transcript to a compacted successor JSONL after
compaction. The old full transcript remains archived and linked from the
compaction checkpoint instead of being rewritten in place.
Why: leave enough headroom for multi-turn “housekeeping” (like memory writes) before compaction becomes unavoidable.

View File

@@ -78,18 +78,24 @@ If no explicit Ollama base URL is set, OpenClaw uses `http://127.0.0.1:11434`.
If your Ollama host expects bearer auth, OpenClaw reuses
`models.providers.ollama.apiKey` (or the matching env-backed provider auth)
for web-search requests too.
for requests to that configured host.
## Notes
- No web-search-specific API key field is required for this provider.
- If the Ollama host is auth-protected, OpenClaw reuses the normal Ollama
provider API key when present.
- If the configured host does not expose web search and `OLLAMA_API_KEY` is set,
OpenClaw can fall back to `https://ollama.com/api/web_search` without sending
that env key to the local host.
- OpenClaw warns during setup if Ollama is unreachable or not signed in, but
it does not block selection.
- Runtime auto-detect can fall back to Ollama Web Search when no higher-priority
credentialed provider is configured.
- The provider uses Ollama's `/api/web_search` endpoint.
- Local Ollama daemon hosts use the local proxy endpoint
`/api/experimental/web_search`, which signs and forwards to Ollama Cloud.
- `https://ollama.com` hosts use the public hosted endpoint
`/api/web_search` directly with bearer API-key auth.
## Related

View File

@@ -2,12 +2,12 @@ import { describe, expect, it } from "vitest";
import { resolveQmdCollectionPatternFlags } from "./qmd-compat.js";
describe("resolveQmdCollectionPatternFlags", () => {
it("prefers modern --glob by default and falls back to legacy --mask", () => {
expect(resolveQmdCollectionPatternFlags(null)).toEqual(["--glob", "--mask"]);
expect(resolveQmdCollectionPatternFlags("--glob")).toEqual(["--glob", "--mask"]);
});
it("keeps preferring legacy --mask after a legacy-only qmd succeeds", () => {
it("prefers --mask by default and falls back to --glob", () => {
expect(resolveQmdCollectionPatternFlags(null)).toEqual(["--mask", "--glob"]);
expect(resolveQmdCollectionPatternFlags("--mask")).toEqual(["--mask", "--glob"]);
});
it("keeps preferring --glob after a glob-only qmd succeeds", () => {
expect(resolveQmdCollectionPatternFlags("--glob")).toEqual(["--glob", "--mask"]);
});
});

View File

@@ -3,5 +3,5 @@ export type QmdCollectionPatternFlag = "--glob" | "--mask";
export function resolveQmdCollectionPatternFlags(
preferredFlag: QmdCollectionPatternFlag | null,
): QmdCollectionPatternFlag[] {
return preferredFlag === "--mask" ? ["--mask", "--glob"] : ["--glob", "--mask"];
return preferredFlag === "--glob" ? ["--glob", "--mask"] : ["--mask", "--glob"];
}

View File

@@ -929,7 +929,12 @@ describe("QmdMemoryManager", () => {
const child = createMockChild({ autoClose: false });
const pathArg = args[2] ?? "";
const name = args[args.indexOf("--name") + 1] ?? "";
const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? "";
const patternIndex = args.includes("--glob")
? args.indexOf("--glob") + 1
: args.includes("--mask")
? args.indexOf("--mask") + 1
: -1;
const pattern = patternIndex >= 0 ? (args[patternIndex] ?? "") : "";
const hasConflict = [...listedCollections.entries()].some(
([existingName, info]) =>
existingName !== name && info.path === pathArg && info.pattern === pattern,
@@ -1023,7 +1028,12 @@ describe("QmdMemoryManager", () => {
if (args[0] === "collection" && args[1] === "add") {
const child = createMockChild({ autoClose: false });
const name = args[args.indexOf("--name") + 1] ?? "";
const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? "";
const patternIndex = args.includes("--glob")
? args.indexOf("--glob") + 1
: args.includes("--mask")
? args.indexOf("--mask") + 1
: -1;
const pattern = patternIndex >= 0 ? (args[patternIndex] ?? "") : "";
const attempts = addAttempts.get(name) ?? 0;
addAttempts.set(name, attempts + 1);
if (name === "memory-root-main" && attempts === 0) {
@@ -1097,7 +1107,12 @@ describe("QmdMemoryManager", () => {
if (args[0] === "collection" && args[1] === "add") {
const child = createMockChild({ autoClose: false });
const name = args[args.indexOf("--name") + 1] ?? "";
const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? "";
const patternIndex = args.includes("--glob")
? args.indexOf("--glob") + 1
: args.includes("--mask")
? args.indexOf("--mask") + 1
: -1;
const pattern = patternIndex >= 0 ? (args[patternIndex] ?? "") : "";
added.set(name, pattern);
queueMicrotask(() => child.closeWith(0));
return child;
@@ -1113,7 +1128,7 @@ describe("QmdMemoryManager", () => {
expect(removed).not.toContain("memory-dir-main");
});
it("falls back to --mask when qmd collection add rejects --glob", async () => {
it("falls back to --glob when qmd collection add rejects --mask", async () => {
cfg = {
...cfg,
memory: {
@@ -1137,8 +1152,8 @@ describe("QmdMemoryManager", () => {
const child = createMockChild({ autoClose: false });
const flag = args.includes("--glob") ? "--glob" : args.includes("--mask") ? "--mask" : "";
addFlagCalls.push(flag);
if (flag === "--glob") {
emitAndClose(child, "stderr", "unknown flag: --glob", 1);
if (flag === "--mask") {
emitAndClose(child, "stderr", "unknown flag: --mask", 1);
return child;
}
queueMicrotask(() => child.closeWith(0));
@@ -1150,7 +1165,7 @@ describe("QmdMemoryManager", () => {
const { manager } = await createManager({ mode: "full" });
await manager.close();
expect(addFlagCalls).toEqual(["--glob", "--mask", "--mask"]);
expect(addFlagCalls).toEqual(["--mask", "--glob", "--glob"]);
expect(logWarnMock).toHaveBeenCalledWith(
expect.stringContaining("retrying with legacy compatibility flag"),
);

View File

@@ -333,7 +333,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private attemptedNullByteCollectionRepair = false;
private attemptedDuplicateDocumentRepair = false;
private readonly sessionWarm = new Set<string>();
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--glob";
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask";
private constructor(params: {
agentId: string;

View File

@@ -429,7 +429,7 @@ describe("ollama plugin", () => {
).toBeUndefined();
});
it("owns replay policy for OpenAI-compatible Ollama routes only", () => {
it("owns replay policy for OpenAI-compatible and native Ollama routes", () => {
const provider = registerProvider();
expect(
@@ -466,7 +466,13 @@ describe("ollama plugin", () => {
modelApi: "ollama",
modelId: "qwen3.5:9b",
} as never),
).toBeUndefined();
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
});
it("routes createStreamFn to the correct provider baseUrl for ollama2", () => {

View File

@@ -8,7 +8,10 @@ import {
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth";
import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
import {
buildOpenAICompatibleReplayPolicy,
OPENAI_COMPATIBLE_REPLAY_HOOKS,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
buildOllamaProvider,
configureOllamaNonInteractive,
@@ -163,6 +166,10 @@ export default definePluginEntry({
});
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
buildReplayPolicy: (ctx) =>
ctx.modelApi === "ollama"
? buildOpenAICompatibleReplayPolicy("openai-completions")
: buildOpenAICompatibleReplayPolicy(ctx.modelApi),
contributeResolvedModelCompat: ({ model }) =>
usesOllamaOpenAICompatTransport(model) ? { supportsUsageInStreaming: true } : undefined,
resolveReasoningOutputMode: () => "native",
@@ -174,11 +181,12 @@ export default definePluginEntry({
defaultLevel: "off",
}),
wrapStreamFn: createConfiguredOllamaCompatStreamWrapper,
createEmbeddingProvider: async ({ config, model, remote }) => {
createEmbeddingProvider: async ({ config, model, provider: embeddingProvider, remote }) => {
const { provider, client } = await createOllamaEmbeddingProvider({
config,
remote,
model: model || DEFAULT_OLLAMA_EMBEDDING_MODEL,
provider: embeddingProvider || OLLAMA_PROVIDER_ID,
});
return {
...provider,

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { createOllamaEmbeddingProvider } from "./src/embedding-provider.js";
import { createOllamaStreamFn } from "./src/stream.js";
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
const LIVE = process.env.OPENCLAW_LIVE_TEST === "1" && process.env.OPENCLAW_LIVE_OLLAMA === "1";
const OLLAMA_BASE_URL =
process.env.OPENCLAW_LIVE_OLLAMA_BASE_URL?.trim() || "http://127.0.0.1:11434";
const CHAT_MODEL = process.env.OPENCLAW_LIVE_OLLAMA_MODEL?.trim() || "llama3.2:latest";
const EMBEDDING_MODEL =
process.env.OPENCLAW_LIVE_OLLAMA_EMBED_MODEL?.trim() || "embeddinggemma:latest";
const PROVIDER_ID = process.env.OPENCLAW_LIVE_OLLAMA_PROVIDER_ID?.trim() || "ollama-live-custom";
const RUN_WEB_SEARCH = process.env.OPENCLAW_LIVE_OLLAMA_WEB_SEARCH !== "0";
async function collectStreamEvents<T>(stream: AsyncIterable<T>): Promise<T[]> {
const events: T[] = [];
for await (const event of stream) {
events.push(event);
}
return events;
}
describe.skipIf(!LIVE)("ollama live", () => {
it("runs native chat with a custom provider prefix and normalized tool schemas", async () => {
const streamFn = createOllamaStreamFn(OLLAMA_BASE_URL);
let payload:
| {
model?: string;
tools?: Array<{
function?: {
parameters?: {
properties?: Record<string, { type?: string }>;
};
};
}>;
}
| undefined;
const stream = streamFn(
{
id: `${PROVIDER_ID}/${CHAT_MODEL}`,
api: "ollama",
provider: PROVIDER_ID,
contextWindow: 8192,
} as never,
{
messages: [{ role: "user", content: "Reply exactly OK." }],
tools: [
{
name: "lookup_weather",
description: "Lookup weather for a city.",
parameters: {
properties: {
city: { enum: ["London", "Vienna"] },
units: { enum: ["metric", "imperial"] },
options: {
properties: {
includeWind: { type: "boolean" },
},
},
},
required: ["city"],
},
},
],
} as never,
{
maxTokens: 32,
temperature: 0,
onPayload: (body: unknown) => {
payload = body as NonNullable<typeof payload>;
},
} as never,
);
const events = await collectStreamEvents(await Promise.resolve(stream));
const error = events.find((event) => (event as { type?: string }).type === "error");
expect(error).toBeUndefined();
expect(events.some((event) => (event as { type?: string }).type === "done")).toBe(true);
expect(payload?.model).toBe(CHAT_MODEL);
const properties = payload?.tools?.[0]?.function?.parameters?.properties;
expect(properties?.city?.type).toBe("string");
expect(properties?.units?.type).toBe("string");
expect(properties?.options?.type).toBe("object");
}, 60_000);
it("embeds a batch through the current Ollama endpoint for custom providers", async () => {
const { client } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
[PROVIDER_ID]: {
api: "ollama",
baseUrl: OLLAMA_BASE_URL,
apiKey: "ollama-local",
},
},
},
},
provider: PROVIDER_ID,
model: `${PROVIDER_ID}/${EMBEDDING_MODEL}`,
} as never);
const embeddings = await client.embedBatch(["hello", "world"]);
expect(embeddings).toHaveLength(2);
expect(embeddings[0]?.length ?? 0).toBeGreaterThan(0);
expect(embeddings[1]?.length).toBe(embeddings[0]?.length);
expect(Math.hypot(...embeddings[0])).toBeGreaterThan(0.99);
expect(Math.hypot(...embeddings[0])).toBeLessThan(1.01);
}, 45_000);
it.skipIf(!RUN_WEB_SEARCH)(
"searches through Ollama web search fallback endpoints",
async () => {
const provider = createOllamaWebSearchProvider();
const tool = provider.createTool({
config: {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: OLLAMA_BASE_URL,
apiKey: "ollama-local",
},
},
},
},
} as never);
if (!tool) {
throw new Error("Ollama web-search provider did not create a tool");
}
const result = (await tool.execute({
query: "OpenClaw documentation",
count: 1,
})) as {
provider?: string;
results?: Array<{ url?: string }>;
};
expect(result.provider).toBe("ollama");
expect(result.results?.length ?? 0).toBeGreaterThan(0);
expect(result.results?.[0]?.url).toMatch(/^https?:\/\//);
},
45_000,
);
});

View File

@@ -37,7 +37,7 @@ afterEach(() => {
function mockEmbeddingFetch(embedding: number[]) {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ embedding }), {
new Response(JSON.stringify({ embeddings: [embedding] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
@@ -47,7 +47,7 @@ function mockEmbeddingFetch(embedding: number[]) {
}
describe("ollama embedding provider", () => {
it("calls /api/embeddings and returns normalized vectors", async () => {
it("calls /api/embed and returns normalized vectors", async () => {
const fetchMock = mockEmbeddingFetch([3, 4]);
const { provider } = await createOllamaEmbeddingProvider({
@@ -61,6 +61,13 @@ describe("ollama embedding provider", () => {
const vector = await provider.embedQuery("hi");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embed",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ model: "nomic-embed-text", input: "hi" }),
}),
);
expect(vector[0]).toBeCloseTo(0.6, 5);
expect(vector[1]).toBeCloseTo(0.8, 5);
});
@@ -90,7 +97,7 @@ describe("ollama embedding provider", () => {
await provider.embedQuery("hello");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embeddings",
"http://127.0.0.1:11434/api/embed",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
@@ -141,7 +148,7 @@ describe("ollama embedding provider", () => {
await provider.embedQuery("hello");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embeddings",
"http://127.0.0.1:11434/api/embed",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer ollama-env",
@@ -150,22 +157,25 @@ describe("ollama embedding provider", () => {
);
});
it("serializes batch embeddings to avoid flooding local Ollama", async () => {
let inFlight = 0;
let maxInFlight = 0;
const prompts: string[] = [];
it("sends batch embeddings in one Ollama request", async () => {
const inputs: unknown[] = [];
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
const rawBody = typeof init?.body === "string" ? init.body : "{}";
const body = JSON.parse(rawBody) as { prompt?: string };
prompts.push(body.prompt ?? "");
await new Promise((resolve) => setTimeout(resolve, 0));
inFlight -= 1;
return new Response(JSON.stringify({ embedding: [1, 0] }), {
status: 200,
headers: { "content-type": "application/json" },
});
const body = JSON.parse(rawBody) as { input?: unknown };
inputs.push(body.input);
return new Response(
JSON.stringify({
embeddings: [
[1, 0],
[1, 0],
[1, 0],
],
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
});
vi.stubGlobal("fetch", fetchMock);
@@ -178,9 +188,45 @@ describe("ollama embedding provider", () => {
});
await expect(provider.embedBatch(["a", "bb", "ccc"])).resolves.toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(prompts).toEqual(["a", "bb", "ccc"]);
expect(maxInFlight).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(inputs).toEqual([["a", "bb", "ccc"]]);
});
it("uses custom Ollama provider config and strips that provider prefix", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
"ollama-spark": {
baseUrl: "http://spark.local:11434/v1",
apiKey: "spark-key",
headers: {
"X-Custom-Ollama": "spark",
},
models: [],
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama-spark",
model: "ollama-spark/qwen3-embedding:4b",
fallback: "none",
});
await provider.embedQuery("hello");
expect(provider.model).toBe("qwen3-embedding:4b");
expect(fetchMock).toHaveBeenCalledWith(
"http://spark.local:11434/api/embed",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer spark-key",
"X-Custom-Ollama": "spark",
}),
}),
);
});
it("marks inline memory batches as local-server timeout work", async () => {

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { normalizeOptionalSecretInput } from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
@@ -11,6 +12,7 @@ import {
ssrfPolicyFromHttpBaseUrlAllowedHostname,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOllamaWireModelId } from "./model-id.js";
import { resolveOllamaApiBase } from "./provider-models.js";
export type OllamaEmbeddingProvider = {
@@ -48,7 +50,6 @@ export type OllamaEmbeddingClient = {
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const OLLAMA_EMBEDDING_BATCH_CONCURRENCY = 1;
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
@@ -78,12 +79,31 @@ async function withRemoteHttpResponse<T>(params: {
}
}
function normalizeEmbeddingModel(model: string): string {
function normalizeEmbeddingModel(model: string, providerId?: string): string {
const trimmed = model.trim();
if (!trimmed) {
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
}
return trimmed.startsWith("ollama/") ? trimmed.slice("ollama/".length) : trimmed;
return normalizeOllamaWireModelId(trimmed, providerId);
}
function resolveConfiguredProvider(options: OllamaEmbeddingOptions) {
const providers = options.config.models?.providers;
if (!providers) {
return undefined;
}
const providerId = options.provider?.trim() || "ollama";
const direct = providers[providerId];
if (direct) {
return direct;
}
const normalized = normalizeProviderId(providerId);
for (const [candidateId, candidate] of Object.entries(providers)) {
if (normalizeProviderId(candidateId) === normalized) {
return candidate;
}
}
return providers.ollama;
}
function resolveMemorySecretInputString(params: {
@@ -107,9 +127,7 @@ function resolveOllamaApiKey(options: OllamaEmbeddingOptions): string | undefine
if (remoteApiKey) {
return remoteApiKey;
}
const providerApiKey = normalizeOptionalSecretInput(
options.config.models?.providers?.ollama?.apiKey,
);
const providerApiKey = normalizeOptionalSecretInput(resolveConfiguredProvider(options)?.apiKey);
if (providerApiKey) {
return providerApiKey;
}
@@ -119,10 +137,10 @@ function resolveOllamaApiKey(options: OllamaEmbeddingOptions): string | undefine
function resolveOllamaEmbeddingClient(
options: OllamaEmbeddingOptions,
): OllamaEmbeddingClientConfig {
const providerConfig = options.config.models?.providers?.ollama;
const providerConfig = resolveConfiguredProvider(options);
const rawBaseUrl = options.remote?.baseUrl?.trim() || providerConfig?.baseUrl?.trim();
const baseUrl = resolveOllamaApiBase(rawBaseUrl);
const model = normalizeEmbeddingModel(options.model);
const model = normalizeEmbeddingModel(options.model, options.provider);
const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -144,42 +162,54 @@ export async function createOllamaEmbeddingProvider(
options: OllamaEmbeddingOptions,
): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> {
const client = resolveOllamaEmbeddingClient(options);
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embeddings`;
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`;
const embedOne = async (text: string): Promise<number[]> => {
const embedMany = async (input: string | string[]): Promise<number[][]> => {
const json = await withRemoteHttpResponse({
url: embedUrl,
ssrfPolicy: client.ssrfPolicy,
init: {
method: "POST",
headers: client.headers,
body: JSON.stringify({ model: client.model, prompt: text }),
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(`Ollama embeddings HTTP ${response.status}: ${await response.text()}`);
throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`);
}
return (await response.json()) as { embedding?: number[] };
return (await response.json()) as { embeddings?: unknown };
},
});
if (!Array.isArray(json.embedding)) {
throw new Error("Ollama embeddings response missing embedding[]");
if (!Array.isArray(json.embeddings)) {
throw new Error("Ollama embed response missing embeddings[]");
}
return sanitizeAndNormalizeEmbedding(json.embedding);
const expectedCount = Array.isArray(input) ? input.length : 1;
if (json.embeddings.length !== expectedCount) {
throw new Error(
`Ollama embed response returned ${json.embeddings.length} embeddings for ${expectedCount} inputs`,
);
}
return json.embeddings.map((embedding) => {
if (!Array.isArray(embedding)) {
throw new Error("Ollama embed response contains a non-array embedding");
}
return sanitizeAndNormalizeEmbedding(embedding);
});
};
const embedOne = async (text: string): Promise<number[]> => {
const [embedding] = await embedMany(text);
if (!embedding) {
throw new Error("Ollama embed response returned no embedding");
}
return embedding;
};
const provider: OllamaEmbeddingProvider = {
id: "ollama",
model: client.model,
embedQuery: embedOne,
embedBatch: async (texts) => {
const embeddings: number[][] = [];
for (let index = 0; index < texts.length; index += OLLAMA_EMBEDDING_BATCH_CONCURRENCY) {
const batch = texts.slice(index, index + OLLAMA_EMBEDDING_BATCH_CONCURRENCY);
embeddings.push(...(await Promise.all(batch.map(embedOne))));
}
return embeddings;
},
embedBatch: async (texts) => (texts.length === 0 ? [] : await embedMany(texts)),
};
return {

View File

@@ -0,0 +1,24 @@
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
export const OLLAMA_PROVIDER_ID = "ollama";
function uniqueModelPrefixCandidates(providerId?: string): string[] {
const candidates = [providerId, normalizeProviderId(providerId ?? ""), OLLAMA_PROVIDER_ID]
.map((candidate) => candidate?.trim())
.filter((candidate): candidate is string => Boolean(candidate));
return [...new Set(candidates)];
}
export function normalizeOllamaWireModelId(modelId: string, providerId?: string): string {
const trimmed = modelId.trim();
if (!trimmed) {
return trimmed;
}
for (const candidate of uniqueModelPrefixCandidates(providerId)) {
const prefix = `${candidate}/`;
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length);
}
}
return trimmed;
}

View File

@@ -56,6 +56,30 @@ describe("buildOllamaChatRequest", () => {
model: "qwen3:14b-q8_0",
});
});
it("strips the active custom provider prefix from chat model ids", () => {
expect(
buildOllamaChatRequest({
modelId: "ollama-spark/qwen3:32b",
providerId: "ollama-spark",
messages: [{ role: "user", content: "hello" }],
}),
).toMatchObject({
model: "qwen3:32b",
});
});
it("keeps unrelated slash-containing Ollama model ids intact", () => {
expect(
buildOllamaChatRequest({
modelId: "library/qwen3:32b",
providerId: "ollama-spark",
messages: [{ role: "user", content: "hello" }],
}),
).toMatchObject({
model: "library/qwen3:32b",
});
});
});
describe("createConfiguredOllamaCompatStreamWrapper", () => {
@@ -255,6 +279,109 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
},
);
});
it("sends custom-provider Ollama chat requests with the bare Ollama model id", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
],
async (fetchMock) => {
const streamFn = createOllamaStreamFn("http://ollama-host:11434");
const model = {
api: "ollama",
provider: "ollama-spark",
id: "ollama-spark/qwen3:32b",
contextWindow: 131072,
};
const stream = await Promise.resolve(
streamFn(
model as never,
{
messages: [{ role: "user", content: "hello" }],
} as never,
{} as never,
),
);
await collectStreamEvents(stream);
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
const requestBody = JSON.parse(requestInit.body) as { model?: string };
expect(requestBody.model).toBe("qwen3:32b");
},
);
});
it("adds direct type hints to native Ollama tool schemas before sending them", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
],
async (fetchMock) => {
const streamFn = createOllamaStreamFn("http://ollama-host:11434");
const model = {
api: "ollama",
provider: "ollama",
id: "qwen3:32b",
contextWindow: 131072,
};
const stream = await Promise.resolve(
streamFn(
model as never,
{
messages: [{ role: "user", content: "hello" }],
tools: [
{
name: "search",
description: "search",
parameters: {
properties: {
query: {
anyOf: [{ type: "string" }, { type: "null" }],
},
tags: {
items: { type: "string" },
},
},
required: ["query"],
},
},
],
} as never,
{} as never,
),
);
await collectStreamEvents(stream);
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
const requestBody = JSON.parse(requestInit.body) as {
tools?: Array<{
function?: {
parameters?: {
type?: string;
properties?: Record<string, { type?: string }>;
};
};
}>;
};
const parameters = requestBody.tools?.[0]?.function?.parameters;
expect(parameters?.type).toBe("object");
expect(parameters?.properties?.query?.type).toBe("string");
expect(parameters?.properties?.tags?.type).toBe("array");
},
);
});
});
describe("convertToOllamaMessages", () => {

View File

@@ -30,6 +30,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { normalizeOllamaWireModelId } from "./model-id.js";
import {
parseJsonObjectPreservingUnsafeIntegers,
parseJsonPreservingUnsafeIntegers,
@@ -239,20 +240,16 @@ export function createConfiguredOllamaCompatStreamWrapper(
// Ollama compat wrapper now owns more than num_ctx injection.
export const createConfiguredOllamaCompatNumCtxWrapper = createConfiguredOllamaCompatStreamWrapper;
function normalizeOllamaWireModelId(modelId: string): string {
const trimmed = modelId.trim();
return trimmed.startsWith("ollama/") ? trimmed.slice("ollama/".length) : trimmed;
}
export function buildOllamaChatRequest(params: {
modelId: string;
providerId?: string;
messages: OllamaChatMessage[];
tools?: OllamaTool[];
options?: Record<string, unknown>;
stream?: boolean;
}): OllamaChatRequest {
return {
model: normalizeOllamaWireModelId(params.modelId),
model: normalizeOllamaWireModelId(params.modelId, params.providerId),
messages: params.messages,
stream: params.stream ?? true,
...(params.tools && params.tools.length > 0 ? { tools: params.tools } : {}),
@@ -449,6 +446,105 @@ function normalizeOllamaCompatMessageToolArgs(payloadRecord: Record<string, unkn
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function inferOllamaSchemaType(schema: Record<string, unknown>): string | undefined {
if (schema.properties && isRecord(schema.properties)) {
return "object";
}
if (schema.items) {
return "array";
}
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
const values = schema.enum.filter((value) => value !== null);
if (values.length > 0 && values.every((value) => typeof value === "string")) {
return "string";
}
if (values.length > 0 && values.every((value) => typeof value === "number")) {
return "number";
}
if (values.length > 0 && values.every((value) => typeof value === "boolean")) {
return "boolean";
}
}
for (const unionKey of ["anyOf", "oneOf"] as const) {
const variants = schema[unionKey];
if (!Array.isArray(variants)) {
continue;
}
for (const variant of variants) {
if (!isRecord(variant)) {
continue;
}
const variantType = variant.type;
if (typeof variantType === "string" && variantType !== "null") {
return variantType;
}
if (Array.isArray(variantType)) {
const firstType = variantType.find(
(entry): entry is string => typeof entry === "string" && entry !== "null",
);
if (firstType) {
return firstType;
}
}
const inferred = inferOllamaSchemaType(variant);
if (inferred) {
return inferred;
}
}
}
return undefined;
}
function normalizeOllamaToolSchema(schema: unknown, isRoot = false): Record<string, unknown> {
if (!isRecord(schema)) {
return {
type: "object",
properties: {},
};
}
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
if (key === "properties" && isRecord(value)) {
normalized.properties = Object.fromEntries(
Object.entries(value).map(([propertyName, propertySchema]) => [
propertyName,
normalizeOllamaToolSchema(propertySchema),
]),
);
continue;
}
if (key === "items") {
normalized.items = Array.isArray(value)
? value.map((entry) => normalizeOllamaToolSchema(entry))
: normalizeOllamaToolSchema(value);
continue;
}
if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
normalized[key] = value.map((entry) => normalizeOllamaToolSchema(entry));
continue;
}
normalized[key] = value;
}
const schemaType = normalized.type;
if (
typeof schemaType !== "string" &&
(!Array.isArray(schemaType) ||
!schemaType.some((entry) => typeof entry === "string" && entry !== "null"))
) {
normalized.type = inferOllamaSchemaType(normalized) ?? (isRoot ? "object" : "string");
}
if (normalized.type === "object" && !isRecord(normalized.properties)) {
normalized.properties = {};
}
return normalized;
}
function extractToolCalls(content: unknown): OllamaToolCall[] {
if (!Array.isArray(content)) {
return [];
@@ -529,7 +625,7 @@ function extractOllamaTools(tools: Tool[] | undefined): OllamaTool[] {
function: {
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
parameters: normalizeOllamaToolSchema(tool.parameters, true),
},
});
}
@@ -653,6 +749,7 @@ export function createOllamaStreamFn(
const body = buildOllamaChatRequest({
modelId: model.id,
providerId: model.provider,
messages: ollamaMessages,
stream: true,
tools: ollamaTools,

View File

@@ -125,7 +125,7 @@ describe("ollama web search provider", () => {
).toBe("https://ollama.com");
});
it("maps generic search args into the Ollama search endpoint", async () => {
it("maps generic search args into the local Ollama proxy endpoint", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
@@ -157,7 +157,7 @@ describe("ollama web search provider", () => {
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://ollama.local:11434/api/web_search",
url: "http://ollama.local:11434/api/experimental/web_search",
auditContext: "ollama-web-search.search",
}),
);
@@ -184,6 +184,126 @@ describe("ollama web search provider", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("tries the future local direct endpoint when the local proxy endpoint is missing", async () => {
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Legacy", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({ config: createOllamaConfig(), query: "openclaw" }),
).resolves.toMatchObject({
count: 1,
results: [{ url: "https://example.com" }],
});
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
"http://ollama.local:11434/api/experimental/web_search",
"http://ollama.local:11434/api/web_search",
]);
});
it("uses only the hosted endpoint for Ollama Cloud base URLs", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Cloud", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({
config: createOllamaConfig({
baseUrl: "https://ollama.com",
apiKey: "cloud-config-secret",
}),
query: "openclaw",
}),
).resolves.toMatchObject({ count: 1 });
expect(fetchWithSsrFGuardMock.mock.calls).toHaveLength(1);
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0].url).toBe("https://ollama.com/api/web_search");
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0].init?.headers).toMatchObject({
Authorization: "Bearer cloud-config-secret",
});
});
it("uses an env Ollama key only for the cloud fallback from a local host", async () => {
const original = process.env.OLLAMA_API_KEY;
try {
process.env.OLLAMA_API_KEY = "cloud-secret";
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Cloud", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({ config: createOllamaConfig(), query: "openclaw" }),
).resolves.toMatchObject({
count: 1,
});
const firstHeaders = fetchWithSsrFGuardMock.mock.calls[0]?.[0].init?.headers as
| Record<string, string>
| undefined;
const cloudHeaders = fetchWithSsrFGuardMock.mock.calls[2]?.[0].init?.headers as
| Record<string, string>
| undefined;
expect(firstHeaders?.Authorization).toBeUndefined();
expect(cloudHeaders?.Authorization).toBe("Bearer cloud-secret");
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
"http://ollama.local:11434/api/experimental/web_search",
"http://ollama.local:11434/api/web_search",
"https://ollama.com/api/web_search",
]);
expect(fetchWithSsrFGuardMock.mock.calls[2]?.[0].url).toBe(
"https://ollama.com/api/web_search",
);
} finally {
if (original === undefined) {
delete process.env.OLLAMA_API_KEY;
} else {
process.env.OLLAMA_API_KEY = original;
}
}
});
it("surfaces Ollama signin guidance for 401 responses", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("", { status: 401 }),

View File

@@ -41,7 +41,9 @@ const OLLAMA_WEB_SEARCH_SCHEMA = Type.Object(
{ additionalProperties: false },
);
const OLLAMA_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_HOSTED_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH = "/api/experimental/web_search";
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
const DEFAULT_OLLAMA_WEB_SEARCH_COUNT = 5;
const DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS = 15_000;
const OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS = 300;
@@ -56,14 +58,37 @@ type OllamaWebSearchResponse = {
results?: OllamaWebSearchResult[];
};
function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
type OllamaWebSearchAttempt = {
baseUrl: string;
path: string;
apiKey?: string;
};
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "https:" && parsed.hostname === "ollama.com";
} catch {
return false;
}
}
function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
const providerApiKey = normalizeOptionalSecretInput(config?.models?.providers?.ollama?.apiKey);
if (providerApiKey && !isNonSecretApiKeyMarker(providerApiKey)) {
return providerApiKey;
}
return undefined;
}
function resolveEnvOllamaWebSearchApiKey(): string | undefined {
return resolveEnvApiKey("ollama")?.apiKey;
}
function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
return resolveConfiguredOllamaWebSearchApiKey(config) ?? resolveEnvOllamaWebSearchApiKey();
}
function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string {
const pluginBaseUrl = normalizeOptionalString(
resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl,
@@ -92,6 +117,43 @@ function normalizeOllamaWebSearchResult(
};
}
function buildOllamaWebSearchAttempts(params: {
baseUrl: string;
configuredApiKey?: string;
envApiKey?: string;
}): OllamaWebSearchAttempt[] {
if (isOllamaCloudBaseUrl(params.baseUrl)) {
return [
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey ?? params.envApiKey,
},
];
}
const attempts: OllamaWebSearchAttempt[] = [
{
baseUrl: params.baseUrl,
path: OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH,
apiKey: params.configuredApiKey,
},
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey,
},
];
if (params.envApiKey) {
attempts.push({
baseUrl: OLLAMA_CLOUD_BASE_URL,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.envApiKey,
});
}
return attempts;
}
export async function runOllamaWebSearch(params: {
config?: OpenClawConfig;
query: string;
@@ -103,71 +165,97 @@ export async function runOllamaWebSearch(params: {
}
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
const apiKey = resolveOllamaWebSearchApiKey(params.config);
const configuredApiKey = resolveConfiguredOllamaWebSearchApiKey(params.config);
const envApiKey = resolveEnvOllamaWebSearchApiKey();
const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT);
const startedAt = Date.now();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const { response, release } = await fetchWithSsrFGuard({
url: `${baseUrl}${OLLAMA_WEB_SEARCH_PATH}`,
init: {
method: "POST",
headers,
body: JSON.stringify({ query, max_results: count }),
signal: AbortSignal.timeout(DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS),
},
policy: buildOllamaBaseUrlSsrFPolicy(baseUrl),
auditContext: "ollama-web-search.search",
});
const body = JSON.stringify({ query, max_results: count });
const attempts = buildOllamaWebSearchAttempts({ baseUrl, configuredApiKey, envApiKey });
try {
if (response.status === 401) {
throw new Error("Ollama web search authentication failed. Run `ollama signin`.");
let payload: OllamaWebSearchResponse | undefined;
let lastError: Error | undefined;
for (const attempt of attempts) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (attempt.apiKey) {
headers.Authorization = `Bearer ${attempt.apiKey}`;
}
if (response.status === 403) {
throw new Error(
"Ollama web search is unavailable. Ensure cloud-backed web search is enabled on the Ollama host.",
);
}
if (!response.ok) {
const detail = await readResponseText(response, { maxBytes: 64_000 });
throw new Error(`Ollama web search failed (${response.status}): ${detail.text || ""}`.trim());
}
const payload = (await response.json()) as OllamaWebSearchResponse;
const results = Array.isArray(payload.results)
? payload.results
.map(normalizeOllamaWebSearchResult)
.filter((result): result is NonNullable<typeof result> => result !== null)
.slice(0, count)
: [];
return {
query,
provider: "ollama",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "ollama",
wrapped: true,
const { response, release } = await fetchWithSsrFGuard({
url: `${attempt.baseUrl}${attempt.path}`,
init: {
method: "POST",
headers,
body,
signal: AbortSignal.timeout(DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS),
},
results: results.map((result) => {
const snippet = truncateText(result.content, OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS).text;
return {
title: result.title ? wrapWebContent(result.title, "web_search") : "",
url: result.url,
snippet: snippet ? wrapWebContent(snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
};
}),
};
} finally {
await release();
policy: buildOllamaBaseUrlSsrFPolicy(attempt.baseUrl),
auditContext: "ollama-web-search.search",
});
try {
if (response.status === 401) {
throw new Error("Ollama web search authentication failed. Run `ollama signin`.");
}
if (response.status === 403) {
throw new Error(
"Ollama web search is unavailable. Ensure cloud-backed web search is enabled on the Ollama host.",
);
}
if (!response.ok) {
const detail = await readResponseText(response, { maxBytes: 64_000 });
const message =
`Ollama web search failed (${response.status}): ${detail.text || ""}`.trim();
if (response.status === 404) {
lastError = new Error(message);
continue;
}
throw new Error(message);
}
payload = (await response.json()) as OllamaWebSearchResponse;
break;
} catch (error) {
if (error instanceof Error) {
lastError = error;
} else {
lastError = new Error(String(error));
}
throw lastError;
} finally {
await release();
}
}
if (!payload) {
throw lastError ?? new Error("Ollama web search failed");
}
const results = Array.isArray(payload.results)
? payload.results
.map(normalizeOllamaWebSearchResult)
.filter((result): result is NonNullable<typeof result> => result !== null)
.slice(0, count)
: [];
return {
query,
provider: "ollama",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "ollama",
wrapped: true,
},
results: results.map((result) => {
const snippet = truncateText(result.content, OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS).text;
return {
title: result.title ? wrapWebContent(result.title, "web_search") : "",
url: result.url,
snippet: snippet ? wrapWebContent(snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
};
}),
};
}
async function warnOllamaWebSearchPrereqs(params: {
@@ -240,8 +328,12 @@ export function createOllamaWebSearchProvider(): WebSearchProviderPlugin {
}
export const __testing = {
buildOllamaWebSearchAttempts,
normalizeOllamaWebSearchResult,
resolveConfiguredOllamaWebSearchApiKey,
resolveEnvOllamaWebSearchApiKey,
resolveOllamaWebSearchApiKey,
resolveOllamaWebSearchBaseUrl,
isOllamaCloudBaseUrl,
warnOllamaWebSearchPrereqs,
};

View File

@@ -350,7 +350,8 @@ const payloads =
[];
const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean);
const match = texts.find((text) => text === expected);
process.stdout.write(match ?? texts[0] ?? "");
const containingMatch = texts.find((text) => text.includes(expected));
process.stdout.write(match ?? (containingMatch ? expected : texts[0]) ?? "");
NODE
}

View File

@@ -6,8 +6,10 @@
FROM node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb AS e2e-runner
# python3 covers package/plugin install paths that execute helper scripts while
# staying below a full build-essential toolchain.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& apt-get install -y --no-install-recommends ca-certificates git python3 \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
@@ -40,10 +42,14 @@ FROM bare AS functional
# The app under test enters through the named BuildKit context, not by copying
# checkout sources into the image.
COPY --from=openclaw_package --chown=appuser:appuser openclaw-current.tgz /tmp/openclaw-current.tgz
# Preserve package self-reference imports such as openclaw/plugin-sdk/* after
# copying the installed package out of npm's global node_modules tree.
RUN npm install -g --prefix /tmp/openclaw-prefix /tmp/openclaw-current.tgz --no-fund --no-audit \
&& cp -a /tmp/openclaw-prefix/lib/node_modules/openclaw/. /app/ \
&& mkdir -p "$HOME/.local/bin" \
&& ln -sf /app/openclaw.mjs "$HOME/.local/bin/openclaw" \
&& mkdir -p /app/node_modules \
&& ln -sf /app /app/node_modules/openclaw \
&& rm -rf /tmp/openclaw-prefix /tmp/openclaw-current.tgz
CMD ["bash"]

View File

@@ -28,7 +28,7 @@ docker run --rm \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
node --import tsx scripts/e2e/crestodian-first-run-docker-client.ts
tsx scripts/e2e/crestodian-first-run-docker-client.ts
" >"$RUN_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e

View File

@@ -28,7 +28,7 @@ docker run --rm \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
node --import tsx scripts/e2e/crestodian-planner-docker-client.ts
tsx scripts/e2e/crestodian-planner-docker-client.ts
" >"$RUN_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e

View File

@@ -28,7 +28,7 @@ docker run --rm \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
node --import tsx scripts/e2e/crestodian-rescue-docker-client.ts
tsx scripts/e2e/crestodian-rescue-docker-client.ts
" >"$RUN_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e

View File

@@ -48,7 +48,7 @@ docker run --rm \
export OPENCLAW_DOCKER_OPENAI_BASE_URL=\"http://127.0.0.1:\$MOCK_PORT/v1\"
node scripts/e2e/mock-openai-server.mjs >/tmp/cron-mcp-cleanup-mock-openai.log 2>&1 &
mock_pid=\$!
node --import tsx scripts/e2e/cron-mcp-cleanup-seed.ts >/tmp/cron-mcp-cleanup-seed.log
tsx scripts/e2e/cron-mcp-cleanup-seed.ts >/tmp/cron-mcp-cleanup-seed.log
node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/cron-mcp-cleanup-gateway.log 2>&1 &
gateway_pid=\$!
stop_process() {
@@ -101,7 +101,7 @@ docker run --rm \
tail -n 120 /tmp/cron-mcp-cleanup-gateway.log 2>/dev/null || true
exit 1
fi
node --import tsx scripts/e2e/cron-mcp-cleanup-docker-client.ts
tsx scripts/e2e/cron-mcp-cleanup-docker-client.ts
" >"$CLIENT_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e

View File

@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# Runs QA diagnostics smoke checks inside the shared package-installed Docker
# E2E image. The OpenClaw app under test comes from the prepared npm tarball;
# only QA harness files are mounted read-only.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-docker-observability-e2e:local" OPENCLAW_DOCKER_OBSERVABILITY_E2E_IMAGE OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE)"
SKIP_BUILD="${OPENCLAW_DOCKER_OBSERVABILITY_E2E_SKIP_BUILD:-0}"
LOOPS="${OPENCLAW_DOCKER_OBSERVABILITY_LOOPS:-1}"
OUTPUT_DIR="${OPENCLAW_DOCKER_OBSERVABILITY_OUTPUT_DIR:-$ROOT_DIR/.artifacts/docker-observability/$(date +%Y%m%d-%H%M%S)}"
if ! [[ "$LOOPS" =~ ^[1-9][0-9]*$ ]]; then
echo "OPENCLAW_DOCKER_OBSERVABILITY_LOOPS must be a positive integer, got: $LOOPS" >&2
exit 1
fi
mkdir -p "$OUTPUT_DIR"
docker_e2e_build_or_reuse "$IMAGE_NAME" docker-observability "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD"
docker_e2e_harness_mount_args
echo "Running Docker observability smoke with $LOOPS loop(s)..."
run_logged docker-observability docker run --rm \
-e "OPENCLAW_DOCKER_OBSERVABILITY_LOOPS=$LOOPS" \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
-v "$ROOT_DIR/scripts/qa-otel-smoke.ts:/app/scripts/qa-otel-smoke.ts:ro" \
-v "$ROOT_DIR/qa:/app/qa:ro" \
-v "$OUTPUT_DIR:/app/.artifacts/docker-observability-current" \
"$IMAGE_NAME" \
bash -lc '
set -euo pipefail
loops="${OPENCLAW_DOCKER_OBSERVABILITY_LOOPS:-1}"
artifact_root=".artifacts/docker-observability-current"
mkdir -p "$artifact_root"
for i in $(seq 1 "$loops"); do
iteration_dir="$artifact_root/loop-$i"
mkdir -p "$iteration_dir"
echo "== docker observability loop $i/$loops: otel =="
# The functional image has a global tsx runner for mounted harness files; the
# published package intentionally does not ship tsx as an app dependency.
tsx scripts/qa-otel-smoke.ts \
--provider-mode mock-openai \
--output-dir "$iteration_dir/otel"
echo "== docker observability loop $i/$loops: prometheus =="
pnpm openclaw qa suite \
--provider-mode mock-openai \
--scenario docker-prometheus-smoke \
--concurrency 1 \
--fast \
--output-dir "$iteration_dir/prometheus"
done
'
echo "Docker observability smoke passed. Artifacts: $OUTPUT_DIR"

View File

@@ -53,7 +53,7 @@ docker run --rm \
sleep 0.1
done
node -e \"fetch('http://127.0.0.1:' + process.argv[1] + '/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\" \"\$mock_port\"
node --import tsx scripts/e2e/mcp-channels-seed.ts >/tmp/mcp-channels-seed.log
tsx scripts/e2e/mcp-channels-seed.ts >/tmp/mcp-channels-seed.log
node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/mcp-channels-gateway.log 2>&1 &
gateway_pid=\$!
stop_process() {
@@ -96,7 +96,7 @@ docker run --rm \
tail -n 120 /tmp/mcp-channels-gateway.log 2>/dev/null || true
exit 1
fi
node --import tsx scripts/e2e/mcp-channels-docker-client.ts
tsx scripts/e2e/mcp-channels-docker-client.ts
" >"$CLIENT_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e

View File

@@ -207,7 +207,7 @@ fi
export OPENCLAW_NPM_TELEGRAM_SUT_COMMAND="$(command -v openclaw)"
trap - ERR
node --import tsx scripts/e2e/npm-telegram-live-runner.ts
tsx scripts/e2e/npm-telegram-live-runner.ts
EOF
echo "published npm Telegram live Docker E2E passed ($PACKAGE_SPEC)"

View File

@@ -27,5 +27,5 @@ export OPENCLAW_SKIP_GMAIL_WATCHER=1
export OPENCLAW_SKIP_CRON=1
export OPENCLAW_SKIP_CANVAS_HOST=1
node --import tsx scripts/e2e/openai-image-auth-docker-client.ts
tsx scripts/e2e/openai-image-auth-docker-client.ts
'

View File

@@ -27,7 +27,7 @@ docker run --rm \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
node --import tsx scripts/e2e/pi-bundle-mcp-tools-docker-client.ts
tsx scripts/e2e/pi-bundle-mcp-tools-docker-client.ts
" >"$RUN_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e

View File

@@ -27,7 +27,7 @@ docker run --rm \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
"$IMAGE_NAME" \
bash -lc 'set -euo pipefail; node --import tsx scripts/e2e/session-runtime-context-docker-client.ts' \
bash -lc 'set -euo pipefail; tsx scripts/e2e/session-runtime-context-docker-client.ts' \
>"$RUN_LOG" 2>&1
status=$?
set -e

View File

@@ -41,6 +41,9 @@ git_root="/tmp/openclaw-git"
mkdir -p "$git_root"
# Build the fake git install from the packed package contents, not the checkout.
tar -xzf "$package_tgz" -C "$git_root" --strip-components=1
# The package-derived fixture can carry patchedDependencies whose targets are
# absent from the trimmed tarball install; that should not block update preflight.
printf "\nallowUnusedPatches=true\n" >>"$git_root/.npmrc"
(
cd "$git_root"
npm install --omit=optional --no-fund --no-audit >/tmp/openclaw-git-install.log 2>&1

View File

@@ -184,13 +184,6 @@ export const mainLanes = [
{ resources: ["service"], weight: 3 },
),
serviceLane("gateway-network", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:gateway-network"),
serviceLane(
"observability",
"OPENCLAW_SKIP_DOCKER_BUILD=1 bash scripts/e2e/docker-observability-smoke.sh",
{
weight: 3,
},
),
serviceLane(
"agents-delete-shared-workspace",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:agents-delete-shared-workspace",
@@ -345,13 +338,6 @@ const releasePathChunks = {
"pi-bundle-mcp-tools",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools",
),
serviceLane(
"observability",
"OPENCLAW_SKIP_DOCKER_BUILD=1 bash scripts/e2e/docker-observability-smoke.sh",
{
weight: 3,
},
),
serviceLane("mcp-channels", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-channels", {
resources: ["npm"],
weight: 3,

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env -S node --import tsx
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createRequire } from "node:module";
@@ -285,15 +286,15 @@ function startLocalOtlpTraceReceiver() {
};
}
function spawnPnpm(args: string[], env: NodeJS.ProcessEnv): ChildProcess {
const npmExecPath = process.env.npm_execpath?.trim();
if (npmExecPath) {
return spawn(process.execPath, [npmExecPath, ...args], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
function openClawEntryArgs(): string[] {
if (existsSync(path.join(process.cwd(), "scripts", "run-node.mjs"))) {
return ["scripts/run-node.mjs"];
}
return spawn(process.platform === "win32" ? "pnpm.cmd" : "pnpm", args, {
return ["openclaw.mjs"];
}
function spawnOpenClaw(args: string[], env: NodeJS.ProcessEnv): ChildProcess {
return spawn(process.execPath, [...openClawEntryArgs(), ...args], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -321,7 +322,6 @@ function buildQaEnv(port: number): NodeJS.ProcessEnv {
function buildQaArgs(options: CliOptions): string[] {
const args = [
"openclaw",
"qa",
"suite",
"--provider-mode",
@@ -434,7 +434,7 @@ async function main() {
let childExitCode = 1;
try {
const child = spawnPnpm(buildQaArgs(options), buildQaEnv(port));
const child = spawnOpenClaw(buildQaArgs(options), buildQaEnv(port));
child.stdout?.on("data", (chunk) => process.stdout.write(chunk));
child.stderr?.on("data", (chunk) => process.stderr.write(chunk));
childExitCode = await waitForChild(child);

View File

@@ -0,0 +1,312 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
type ExecApprovalsFile,
type ExecAsk,
type ExecSecurity,
type SystemRunApprovalPlan,
evaluateShellAllowlist,
hasDurableExecApproval,
resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js";
import {
describeInterpreterInlineEval,
detectInterpreterInlineEvalArgv,
} from "../infra/exec-inline-eval.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
export type NodeExecutionTarget = {
nodeId: string;
platform?: string | null;
argv: string[];
env: Record<string, string> | undefined;
invokeTimeoutMs: number;
};
export type PreparedNodeRun = {
plan: SystemRunApprovalPlan;
argv: string[];
rawCommand: string;
cwd: string | undefined;
agentId: string | undefined;
sessionKey: string | undefined;
};
export type NodeApprovalAnalysis = {
analysisOk: boolean;
allowlistSatisfied: boolean;
durableApprovalSatisfied: boolean;
inlineEvalHit: ReturnType<typeof detectInterpreterInlineEvalArgv>;
};
export function shouldSkipNodeApprovalPrepare(params: {
hostSecurity: ExecSecurity;
hostAsk: ExecAsk;
strictInlineEval?: boolean;
}): boolean {
return (
params.hostSecurity === "full" && params.hostAsk === "off" && params.strictInlineEval !== true
);
}
export function formatNodeRunToolResult(params: {
raw: unknown;
startedAt: number;
cwd: string | undefined;
}): AgentToolResult<ExecToolDetails> {
const payload =
params.raw && typeof params.raw === "object"
? (params.raw as { payload?: unknown }).payload
: undefined;
const payloadObj =
payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const stdout = typeof payloadObj.stdout === "string" ? payloadObj.stdout : "";
const stderr = typeof payloadObj.stderr === "string" ? payloadObj.stderr : "";
const errorText = typeof payloadObj.error === "string" ? payloadObj.error : "";
const success = typeof payloadObj.success === "boolean" ? payloadObj.success : false;
const exitCode = typeof payloadObj.exitCode === "number" ? payloadObj.exitCode : null;
return {
content: [
{
type: "text",
text: stdout || stderr || errorText || "",
},
],
details: {
status: success ? "completed" : "failed",
exitCode,
durationMs: Date.now() - params.startedAt,
aggregated: [stdout, stderr, errorText].filter(Boolean).join("\n"),
cwd: params.cwd,
} satisfies ExecToolDetails,
};
}
export async function resolveNodeExecutionTarget(
params: ExecuteNodeHostCommandParams,
): Promise<NodeExecutionTarget> {
if (params.boundNode && params.requestedNode && params.boundNode !== params.requestedNode) {
throw new Error(`exec node not allowed (bound to ${params.boundNode})`);
}
const nodeQuery = params.boundNode || params.requestedNode;
const nodes = await listNodes({});
if (nodes.length === 0) {
throw new Error(
"exec host=node requires a paired node (none available). This requires a companion app or node host.",
);
}
let nodeId: string;
try {
nodeId = resolveNodeIdFromList(nodes, nodeQuery, !nodeQuery);
} catch (err) {
if (!nodeQuery && String(err).includes("node required")) {
throw new Error(
"exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node).",
{ cause: err },
);
}
throw err;
}
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
? nodeInfo?.commands?.includes("system.run")
: false;
if (!supportsSystemRun) {
throw new Error(
"exec host=node requires a node that supports system.run (companion app or node host).",
);
}
return {
nodeId,
platform: nodeInfo?.platform,
argv: buildNodeShellCommand(params.command, nodeInfo?.platform),
env: params.requestedEnv ? { ...params.requestedEnv } : undefined,
invokeTimeoutMs: Math.max(
10_000,
(typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec) *
1000 +
5_000,
),
};
}
export function buildNodeSystemRunInvoke(params: {
target: NodeExecutionTarget;
command: string[];
rawCommand: string;
cwd: string | undefined;
timeoutSec: number | undefined;
agentId: string | undefined;
sessionKey: string | undefined;
approved?: boolean;
approvalDecision?: "allow-once" | "allow-always" | null;
runId?: string;
suppressNotifyOnExit?: boolean;
notifyOnExit?: boolean;
systemRunPlan?: SystemRunApprovalPlan;
}): Record<string, unknown> {
return {
nodeId: params.target.nodeId,
command: "system.run",
params: {
command: params.command,
rawCommand: params.rawCommand,
...(params.systemRunPlan ? { systemRunPlan: params.systemRunPlan } : {}),
...(params.cwd != null ? { cwd: params.cwd } : {}),
env: params.target.env,
timeoutMs: typeof params.timeoutSec === "number" ? params.timeoutSec * 1000 : undefined,
agentId: params.agentId,
sessionKey: params.sessionKey,
approved: params.approved,
approvalDecision: params.approvalDecision ?? undefined,
runId: params.runId ?? undefined,
suppressNotifyOnExit:
params.suppressNotifyOnExit === true || params.notifyOnExit === false ? true : undefined,
},
idempotencyKey: crypto.randomUUID(),
};
}
export async function invokeNodeSystemRunDirect(params: {
request: ExecuteNodeHostCommandParams;
target: NodeExecutionTarget;
}): Promise<AgentToolResult<ExecToolDetails>> {
const startedAt = Date.now();
const raw = await callGatewayTool(
"node.invoke",
{ timeoutMs: params.target.invokeTimeoutMs },
buildNodeSystemRunInvoke({
target: params.target,
command: params.target.argv,
rawCommand: params.request.command,
cwd: params.request.workdir,
timeoutSec: params.request.timeoutSec,
agentId: params.request.agentId,
sessionKey: params.request.sessionKey,
notifyOnExit: params.request.notifyOnExit,
}),
);
return formatNodeRunToolResult({ raw, startedAt, cwd: params.request.workdir });
}
export async function prepareNodeSystemRun(params: {
request: ExecuteNodeHostCommandParams;
target: NodeExecutionTarget;
}): Promise<PreparedNodeRun> {
const prepareRaw = await callGatewayTool(
"node.invoke",
{ timeoutMs: 15_000 },
{
nodeId: params.target.nodeId,
command: "system.run.prepare",
params: {
command: params.target.argv,
rawCommand: params.request.command,
...(params.request.workdir != null ? { cwd: params.request.workdir } : {}),
agentId: params.request.agentId,
sessionKey: params.request.sessionKey,
},
idempotencyKey: crypto.randomUUID(),
},
);
const prepared = parsePreparedSystemRunPayload(prepareRaw?.payload);
if (!prepared) {
throw new Error("invalid system.run.prepare response");
}
return {
plan: prepared.plan,
argv: prepared.plan.argv,
rawCommand: prepared.plan.commandText,
cwd: prepared.plan.cwd ?? params.request.workdir,
agentId: prepared.plan.agentId ?? params.request.agentId,
sessionKey: prepared.plan.sessionKey ?? params.request.sessionKey,
};
}
export async function analyzeNodeApprovalRequirement(params: {
request: ExecuteNodeHostCommandParams;
target: NodeExecutionTarget;
prepared: PreparedNodeRun;
hostSecurity: ExecSecurity;
hostAsk: ExecAsk;
}): Promise<NodeApprovalAnalysis> {
const baseAllowlistEval = evaluateShellAllowlist({
command: params.request.command,
allowlist: [],
safeBins: new Set(),
cwd: params.request.workdir,
env: params.request.env,
platform: params.target.platform,
trustedSafeBinDirs: params.request.trustedSafeBinDirs,
});
let analysisOk = baseAllowlistEval.analysisOk;
let allowlistSatisfied = false;
let durableApprovalSatisfied = false;
const inlineEvalHit =
params.request.strictInlineEval === true
? (baseAllowlistEval.segments
.map((segment) =>
detectInterpreterInlineEvalArgv(segment.resolution?.effectiveArgv ?? segment.argv),
)
.find((entry) => entry !== null) ?? null)
: null;
if (inlineEvalHit) {
params.request.warnings.push(
`Warning: strict inline-eval mode requires explicit approval for ${describeInterpreterInlineEval(
inlineEvalHit,
)}.`,
);
}
if ((params.hostAsk === "always" || params.hostSecurity === "allowlist") && analysisOk) {
try {
const approvalsSnapshot = await callGatewayTool<{ file: string }>(
"exec.approvals.node.get",
{ timeoutMs: 10_000 },
{ nodeId: params.target.nodeId },
);
const approvalsFile =
approvalsSnapshot && typeof approvalsSnapshot === "object"
? approvalsSnapshot.file
: undefined;
if (approvalsFile && typeof approvalsFile === "object") {
const resolved = resolveExecApprovalsFromFile({
file: approvalsFile as ExecApprovalsFile,
agentId: params.request.agentId,
overrides: { security: "full" },
});
// Allowlist-only precheck; safe bins are node-local and may diverge.
const allowlistEval = evaluateShellAllowlist({
command: params.request.command,
allowlist: resolved.allowlist,
safeBins: new Set(),
cwd: params.request.workdir,
env: params.request.env,
platform: params.target.platform,
trustedSafeBinDirs: params.request.trustedSafeBinDirs,
});
durableApprovalSatisfied = hasDurableExecApproval({
analysisOk: allowlistEval.analysisOk,
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
allowlist: resolved.allowlist,
commandText: params.prepared.rawCommand,
});
allowlistSatisfied = allowlistEval.allowlistSatisfied;
analysisOk = allowlistEval.analysisOk;
}
} catch {
// Fall back to requiring approval if node approvals cannot be fetched.
}
}
return {
analysisOk,
allowlistSatisfied,
durableApprovalSatisfied,
inlineEvalHit,
};
}

View File

@@ -238,6 +238,13 @@ describe("executeNodeHostCommand", () => {
});
it("forwards prepared systemRunPlan on async node invoke after approval", async () => {
resolveExecHostApprovalContextMock.mockReturnValue({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
hostSecurity: "full",
hostAsk: "always",
askFallback: "deny",
});
const result = await executeNodeHostCommand({
command: "bun ./script.ts",
workdir: "/tmp/work",
@@ -259,11 +266,11 @@ describe("executeNodeHostCommand", () => {
);
await vi.waitFor(() => {
expect(callGatewayToolMock).toHaveBeenCalledTimes(2);
expect(callGatewayToolMock).toHaveBeenCalledTimes(3);
});
expect(callGatewayToolMock).toHaveBeenNthCalledWith(
2,
3,
"node.invoke",
expect.anything(),
expect.objectContaining({
@@ -277,9 +284,7 @@ describe("executeNodeHostCommand", () => {
);
});
it("suppresses node completion events when notifyOnExit is disabled", async () => {
requiresExecApprovalMock.mockReturnValue(false);
it("skips approval prepare in full/off mode", async () => {
await executeNodeHostCommand({
command: "bun ./script.ts",
workdir: "/tmp/work",
@@ -294,17 +299,28 @@ describe("executeNodeHostCommand", () => {
notifyOnExit: false,
});
expect(callGatewayToolMock).toHaveBeenNthCalledWith(
2,
expect(callGatewayToolMock).toHaveBeenCalledTimes(1);
expect(callGatewayToolMock).toHaveBeenCalledWith(
"node.invoke",
expect.anything(),
expect.objectContaining({
command: "system.run",
params: expect.objectContaining({
command: ["bash", "-lc", "bun ./script.ts"],
rawCommand: "bun ./script.ts",
suppressNotifyOnExit: true,
}),
}),
);
expect(callGatewayToolMock).toHaveBeenCalledWith(
"node.invoke",
expect.anything(),
expect.objectContaining({
params: expect.not.objectContaining({
systemRunPlan: expect.anything(),
}),
}),
);
});
it("denies timed-out inline-eval requests instead of invoking the node", async () => {

View File

@@ -1,26 +1,23 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
type ExecApprovalsFile,
type ExecAsk,
type ExecSecurity,
evaluateShellAllowlist,
hasDurableExecApproval,
requiresExecApproval,
resolveExecApprovalAllowedDecisions,
resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js";
import {
describeInterpreterInlineEval,
detectInterpreterInlineEvalArgv,
} from "../infra/exec-inline-eval.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import {
analyzeNodeApprovalRequirement,
buildNodeSystemRunInvoke,
formatNodeRunToolResult,
invokeNodeSystemRunDirect,
prepareNodeSystemRun,
resolveNodeExecutionTarget,
shouldSkipNodeApprovalPrepare,
} from "./bash-tools.exec-host-node-phases.js";
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
import * as execHostShared from "./bash-tools.exec-host-shared.js";
import {
DEFAULT_NOTIFY_TAIL_CHARS,
@@ -29,33 +26,8 @@ import {
} from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
export type ExecuteNodeHostCommandParams = {
command: string;
workdir: string | undefined;
env: Record<string, string>;
requestedEnv?: Record<string, string>;
requestedNode?: string;
boundNode?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
trigger?: string;
agentId?: string;
security: ExecSecurity;
ask: ExecAsk;
strictInlineEval?: boolean;
timeoutSec?: number;
defaultTimeoutSec: number;
approvalRunningNoticeMs: number;
warnings: string[];
notifySessionKey?: string;
notifyOnExit?: boolean;
trustedSafeBinDirs?: ReadonlySet<string>;
};
export type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
export async function executeNodeHostCommand(
params: ExecuteNodeHostCommandParams,
@@ -66,132 +38,27 @@ export async function executeNodeHostCommand(
ask: params.ask,
host: "node",
});
if (params.boundNode && params.requestedNode && params.boundNode !== params.requestedNode) {
throw new Error(`exec node not allowed (bound to ${params.boundNode})`);
const target = await resolveNodeExecutionTarget(params);
if (
shouldSkipNodeApprovalPrepare({
hostSecurity,
hostAsk,
strictInlineEval: params.strictInlineEval,
})
) {
return await invokeNodeSystemRunDirect({ request: params, target });
}
const nodeQuery = params.boundNode || params.requestedNode;
const nodes = await listNodes({});
if (nodes.length === 0) {
throw new Error(
"exec host=node requires a paired node (none available). This requires a companion app or node host.",
);
}
let nodeId: string;
try {
nodeId = resolveNodeIdFromList(nodes, nodeQuery, !nodeQuery);
} catch (err) {
if (!nodeQuery && String(err).includes("node required")) {
throw new Error(
"exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node).",
{ cause: err },
);
}
throw err;
}
const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId);
const supportsSystemRun = Array.isArray(nodeInfo?.commands)
? nodeInfo?.commands?.includes("system.run")
: false;
if (!supportsSystemRun) {
throw new Error(
"exec host=node requires a node that supports system.run (companion app or node host).",
);
}
const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
const prepareRaw = await callGatewayTool(
"node.invoke",
{ timeoutMs: 15_000 },
{
nodeId,
command: "system.run.prepare",
params: {
command: argv,
rawCommand: params.command,
...(params.workdir != null ? { cwd: params.workdir } : {}),
agentId: params.agentId,
sessionKey: params.sessionKey,
},
idempotencyKey: crypto.randomUUID(),
},
);
const prepared = parsePreparedSystemRunPayload(prepareRaw?.payload);
if (!prepared) {
throw new Error("invalid system.run.prepare response");
}
const runArgv = prepared.plan.argv;
const runRawCommand = prepared.plan.commandText;
const runCwd = prepared.plan.cwd ?? params.workdir;
const runAgentId = prepared.plan.agentId ?? params.agentId;
const runSessionKey = prepared.plan.sessionKey ?? params.sessionKey;
const nodeEnv = params.requestedEnv ? { ...params.requestedEnv } : undefined;
const baseAllowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: [],
safeBins: new Set(),
cwd: params.workdir,
env: params.env,
platform: nodeInfo?.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
const prepared = await prepareNodeSystemRun({ request: params, target });
const approvalAnalysis = await analyzeNodeApprovalRequirement({
request: params,
target,
prepared,
hostSecurity,
hostAsk,
});
let analysisOk = baseAllowlistEval.analysisOk;
let allowlistSatisfied = false;
let durableApprovalSatisfied = false;
const inlineEvalHit =
params.strictInlineEval === true
? (baseAllowlistEval.segments
.map((segment) =>
detectInterpreterInlineEvalArgv(segment.resolution?.effectiveArgv ?? segment.argv),
)
.find((entry) => entry !== null) ?? null)
: null;
if (inlineEvalHit) {
params.warnings.push(
`Warning: strict inline-eval mode requires explicit approval for ${describeInterpreterInlineEval(
inlineEvalHit,
)}.`,
);
}
if ((hostAsk === "always" || hostSecurity === "allowlist") && analysisOk) {
try {
const approvalsSnapshot = await callGatewayTool<{ file: string }>(
"exec.approvals.node.get",
{ timeoutMs: 10_000 },
{ nodeId },
);
const approvalsFile =
approvalsSnapshot && typeof approvalsSnapshot === "object"
? approvalsSnapshot.file
: undefined;
if (approvalsFile && typeof approvalsFile === "object") {
const resolved = resolveExecApprovalsFromFile({
file: approvalsFile as ExecApprovalsFile,
agentId: params.agentId,
overrides: { security: "full" },
});
// Allowlist-only precheck; safe bins are node-local and may diverge.
const allowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: resolved.allowlist,
safeBins: new Set(),
cwd: params.workdir,
env: params.env,
platform: nodeInfo?.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
});
durableApprovalSatisfied = hasDurableExecApproval({
analysisOk: allowlistEval.analysisOk,
segmentAllowlistEntries: allowlistEval.segmentAllowlistEntries,
allowlist: resolved.allowlist,
commandText: runRawCommand,
});
allowlistSatisfied = allowlistEval.allowlistSatisfied;
analysisOk = allowlistEval.analysisOk;
}
} catch {
// Fall back to requiring approval if node approvals cannot be fetched.
}
}
const { analysisOk, allowlistSatisfied, durableApprovalSatisfied, inlineEvalHit } =
approvalAnalysis;
const requiresAsk =
requiresExecApproval({
ask: hostAsk,
@@ -200,40 +67,6 @@ export async function executeNodeHostCommand(
allowlistSatisfied,
durableApprovalSatisfied,
}) || inlineEvalHit !== null;
const invokeTimeoutMs = Math.max(
10_000,
(typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec) * 1000 +
5_000,
);
const buildInvokeParams = (
approvedByAsk: boolean,
approvalDecision: "allow-once" | "allow-always" | null,
runId?: string,
suppressNotifyOnExit?: boolean,
) =>
({
nodeId,
command: "system.run",
params: {
command: runArgv,
rawCommand: runRawCommand,
systemRunPlan: prepared.plan,
cwd: runCwd,
env: nodeEnv,
timeoutMs: typeof params.timeoutSec === "number" ? params.timeoutSec * 1000 : undefined,
agentId: runAgentId,
sessionKey: runSessionKey,
approved: approvedByAsk,
approvalDecision:
approvalDecision === "allow-always" && inlineEvalHit !== null
? "allow-once"
: (approvalDecision ?? undefined),
runId: runId ?? undefined,
suppressNotifyOnExit:
suppressNotifyOnExit === true || params.notifyOnExit === false ? true : undefined,
},
idempotencyKey: crypto.randomUUID(),
}) satisfies Record<string, unknown>;
let inlineApprovedByAsk = false;
let inlineApprovalDecision: "allow-once" | "allow-always" | null = null;
@@ -250,15 +83,15 @@ export async function executeNodeHostCommand(
await registerExecApprovalRequestForHostOrThrow({
approvalId,
systemRunPlan: prepared.plan,
env: nodeEnv,
workdir: runCwd,
env: target.env,
workdir: prepared.cwd,
host: "node",
nodeId,
nodeId: target.nodeId,
security: hostSecurity,
ask: hostAsk,
...buildExecApprovalRequesterContext({
agentId: runAgentId,
sessionKey: runSessionKey,
agentId: prepared.agentId,
sessionKey: prepared.sessionKey,
}),
...buildExecApprovalTurnSourceContext(params),
});
@@ -324,7 +157,7 @@ export async function executeNodeHostCommand(
onFailure: () =>
void execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
`Exec denied (node=${target.nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
),
});
if (decision === undefined) {
@@ -366,7 +199,7 @@ export async function executeNodeHostCommand(
if (deniedReason) {
await execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
`Exec denied (node=${target.nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
);
return;
}
@@ -374,8 +207,25 @@ export async function executeNodeHostCommand(
try {
const raw = await callGatewayTool(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true),
{ timeoutMs: target.invokeTimeoutMs },
buildNodeSystemRunInvoke({
target,
command: prepared.argv,
rawCommand: prepared.rawCommand,
cwd: prepared.cwd,
timeoutSec: params.timeoutSec,
agentId: prepared.agentId,
sessionKey: prepared.sessionKey,
approved: approvedByAsk,
approvalDecision:
approvalDecision === "allow-always" && inlineEvalHit !== null
? "allow-once"
: approvalDecision,
runId: approvalId,
suppressNotifyOnExit: true,
notifyOnExit: params.notifyOnExit,
systemRunPlan: prepared.plan,
}),
);
const payload =
raw?.payload && typeof raw.payload === "object"
@@ -393,13 +243,13 @@ export async function executeNodeHostCommand(
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
const summary = output
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
? `Exec finished (node=${target.nodeId} id=${approvalId}, ${exitLabel})\n${output}`
: `Exec finished (node=${target.nodeId} id=${approvalId}, ${exitLabel})`;
await execHostShared.sendExecApprovalFollowupResult(followupTarget, summary);
} catch {
await execHostShared.sendExecApprovalFollowupResult(
followupTarget,
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
`Exec denied (node=${target.nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
);
}
})();
@@ -416,7 +266,7 @@ export async function executeNodeHostCommand(
sentApproverDms,
unavailableReason,
allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: hostAsk }),
nodeId,
nodeId: target.nodeId,
});
}
}
@@ -424,31 +274,21 @@ export async function executeNodeHostCommand(
const startedAt = Date.now();
const raw = await callGatewayTool(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(inlineApprovedByAsk, inlineApprovalDecision, inlineApprovalId),
{ timeoutMs: target.invokeTimeoutMs },
buildNodeSystemRunInvoke({
target,
command: prepared.argv,
rawCommand: prepared.rawCommand,
cwd: prepared.cwd,
timeoutSec: params.timeoutSec,
agentId: prepared.agentId,
sessionKey: prepared.sessionKey,
approved: inlineApprovedByAsk,
approvalDecision: inlineApprovalDecision,
runId: inlineApprovalId,
notifyOnExit: params.notifyOnExit,
systemRunPlan: prepared.plan,
}),
);
const payload =
raw && typeof raw === "object" ? (raw as { payload?: unknown }).payload : undefined;
const payloadObj =
payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const stdout = typeof payloadObj.stdout === "string" ? payloadObj.stdout : "";
const stderr = typeof payloadObj.stderr === "string" ? payloadObj.stderr : "";
const errorText = typeof payloadObj.error === "string" ? payloadObj.error : "";
const success = typeof payloadObj.success === "boolean" ? payloadObj.success : false;
const exitCode = typeof payloadObj.exitCode === "number" ? payloadObj.exitCode : null;
return {
content: [
{
type: "text",
text: stdout || stderr || errorText || "",
},
],
details: {
status: success ? "completed" : "failed",
exitCode,
durationMs: Date.now() - startedAt,
aggregated: [stdout, stderr, errorText].filter(Boolean).join("\n"),
cwd: params.workdir,
} satisfies ExecToolDetails,
};
return formatNodeRunToolResult({ raw, startedAt, cwd: params.workdir });
}

View File

@@ -0,0 +1,27 @@
import type { ExecAsk, ExecSecurity } from "../infra/exec-approvals.js";
export type ExecuteNodeHostCommandParams = {
command: string;
workdir: string | undefined;
env: Record<string, string>;
requestedEnv?: Record<string, string>;
requestedNode?: string;
boundNode?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
trigger?: string;
agentId?: string;
security: ExecSecurity;
ask: ExecAsk;
strictInlineEval?: boolean;
timeoutSec?: number;
defaultTimeoutSec: number;
approvalRunningNoticeMs: number;
warnings: string[];
notifySessionKey?: string;
notifyOnExit?: boolean;
trustedSafeBinDirs?: ReadonlySet<string>;
};

View File

@@ -522,16 +522,16 @@ describe("exec approvals", () => {
it("preserves explicit workdir for node exec", async () => {
const remoteWorkdir = "/Users/vv";
let prepareCwd: string | undefined;
let runCwd: string | undefined;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "node.invoke") {
const invoke = params as { command?: string; params?: { cwd?: string } };
if (invoke.command === "system.run.prepare") {
prepareCwd = invoke.params?.cwd;
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run") {
runCwd = invoke.params?.cwd;
return { payload: { success: true, stdout: "ok" } };
}
}
@@ -551,23 +551,23 @@ describe("exec approvals", () => {
});
expect(result.details.status).toBe("completed");
expect(prepareCwd).toBe(remoteWorkdir);
expect(runCwd).toBe(remoteWorkdir);
});
it("does not forward the gateway default cwd to node exec when workdir is omitted", async () => {
const gatewayWorkspace = "/gateway/workspace";
let prepareHasCwd = false;
let prepareCwd: string | undefined;
let runHasCwd = false;
let runCwd: string | undefined;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "node.invoke") {
const invoke = params as { command?: string; params?: { cwd?: string } };
if (invoke.command === "system.run.prepare") {
prepareHasCwd = Object.hasOwn(invoke.params ?? {}, "cwd");
prepareCwd = invoke.params?.cwd;
return buildPreparedSystemRunPayload(params);
}
if (invoke.command === "system.run") {
runHasCwd = Object.hasOwn(invoke.params ?? {}, "cwd");
runCwd = invoke.params?.cwd;
return { payload: { success: true, stdout: "ok" } };
}
}
@@ -587,8 +587,8 @@ describe("exec approvals", () => {
});
expect(result.details.status).toBe("completed");
expect(prepareHasCwd).toBe(false);
expect(prepareCwd).toBeUndefined();
expect(runHasCwd).toBe(false);
expect(runCwd).toBeUndefined();
});
it("routes explicit host=node to node invoke when elevated default is on under auto host", async () => {

View File

@@ -87,10 +87,6 @@ function createDefaultSessionMessages(): unknown[] {
}
export const sessionMessages: unknown[] = createDefaultSessionMessages();
export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.fn();
export const truncateSessionAfterCompactionMock = vi.fn(async () => ({
truncated: false,
entriesRemoved: 0,
}));
export const createOpenClawCodingToolsMock = vi.fn(() => []);
export const resolveEmbeddedAgentStreamFnMock: Mock<
(params?: unknown) => MockEmbeddedAgentStreamFn
@@ -130,11 +126,6 @@ export function resetCompactSessionStateMocks(): void {
estimateTokensMock.mockReturnValue(10);
sessionMessages.splice(0, sessionMessages.length, ...createDefaultSessionMessages());
sessionAbortCompactionMock.mockReset();
truncateSessionAfterCompactionMock.mockReset();
truncateSessionAfterCompactionMock.mockResolvedValue({
truncated: false,
entriesRemoved: 0,
});
resolveEmbeddedAgentStreamFnMock.mockReset();
resolveEmbeddedAgentStreamFnMock.mockImplementation((_params?: unknown) => vi.fn());
registerProviderStreamForModelMock.mockReset();
@@ -324,10 +315,6 @@ export async function loadCompactHooksHarness(): Promise<{
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
}));
vi.doMock("./session-truncation.js", () => ({
truncateSessionAfterCompaction: truncateSessionAfterCompactionMock,
}));
vi.doMock("../../context-engine/init.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
}));

View File

@@ -23,7 +23,6 @@ import {
sessionMessages,
sessionCompactImpl,
triggerInternalHook,
truncateSessionAfterCompactionMock,
} from "./compact.hooks.harness.js";
let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect;
@@ -753,31 +752,36 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
);
});
it("runs after_compaction hooks before post-compaction transcript truncation", async () => {
const order: string[] = [];
it("passes the rotated session id to engine-owned after_compaction hooks", async () => {
hookRunner.hasHooks.mockReturnValue(true);
hookRunner.runAfterCompaction.mockImplementation(async () => {
order.push("after_compaction");
});
truncateSessionAfterCompactionMock.mockImplementation(async () => {
order.push("truncate");
return { truncated: true, entriesRemoved: 1 };
});
const rotatedSessionId = "rotated-session";
const rotatedSessionFile = "/tmp/rotated-session.jsonl";
contextEngineCompactMock.mockResolvedValue({
ok: true,
compacted: true,
reason: undefined,
result: {
summary: "engine-summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
tokensAfter: 50,
sessionId: rotatedSessionId,
sessionFile: rotatedSessionFile,
},
} as never);
const result = await compactEmbeddedPiSession(
wrappedCompactionArgs({
config: {
agents: {
defaults: {
compaction: {},
},
},
},
}),
);
const result = await compactEmbeddedPiSession(wrappedCompactionArgs());
expect(result.ok).toBe(true);
expect(order).toEqual(["after_compaction", "truncate"]);
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
expect.objectContaining({
sessionFile: rotatedSessionFile,
}),
expect.objectContaining({
sessionId: rotatedSessionId,
sessionKey: TEST_SESSION_KEY,
}),
);
});
it("emits a transcript update and post-compaction memory sync on the engine-owned path", async () => {
@@ -952,6 +956,58 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
}
});
it("reuses a delegated compaction successor transcript", async () => {
const maintain = vi.fn(async (_params?: unknown) => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}));
const delegatedSessionId = "delegated-session";
const delegatedSessionFile = "/tmp/delegated-session.jsonl";
resolveContextEngineMock.mockResolvedValue({
info: { ownsCompaction: false },
compact: contextEngineCompactMock,
maintain,
} as never);
contextEngineCompactMock.mockResolvedValue({
ok: true,
compacted: true,
reason: undefined,
result: {
summary: "engine-summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
tokensAfter: 50,
sessionId: delegatedSessionId,
sessionFile: delegatedSessionFile,
},
} as never);
const result = await compactEmbeddedPiSession(
wrappedCompactionArgs({
config: {
agents: {
defaults: {
compaction: {
truncateAfterCompaction: true,
},
},
},
},
}),
);
expect(result.ok).toBe(true);
expect(result.result?.sessionId).toBe(delegatedSessionId);
expect(result.result?.sessionFile).toBe(delegatedSessionFile);
expect(maintain).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: delegatedSessionId,
sessionFile: delegatedSessionFile,
}),
);
});
it("catches and logs hook exceptions without aborting compaction", async () => {
hookRunner.hasHooks.mockReturnValue(true);
hookRunner.runBeforeCompaction.mockRejectedValue(new Error("hook boom"));

View File

@@ -26,12 +26,15 @@ import {
buildEmbeddedCompactionRuntimeContext,
resolveEmbeddedCompactionTarget,
} from "./compaction-runtime-context.js";
import {
rotateTranscriptAfterCompaction,
shouldRotateCompactionTranscript,
} from "./compaction-successor-transcript.js";
import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { readPiModelContextTokens } from "./model-context-tokens.js";
import { resolveModelAsync } from "./model.js";
import { truncateSessionAfterCompaction } from "./session-truncation.js";
import type { EmbeddedPiCompactResult } from "./types.js";
/**
@@ -159,15 +162,44 @@ export async function compactEmbeddedPiSession(
force: params.trigger === "manual",
runtimeContext,
});
const delegatedSessionId = result.result?.sessionId;
const delegatedSessionFile = result.result?.sessionFile;
const delegatedRotatedTranscript = Boolean(delegatedSessionId || delegatedSessionFile);
let postCompactionSessionId = delegatedSessionId ?? params.sessionId;
let postCompactionSessionFile = delegatedSessionFile ?? params.sessionFile;
let postCompactionLeafId: string | undefined;
if (result.ok && result.compacted) {
if (shouldRotateCompactionTranscript(params.config) && !delegatedRotatedTranscript) {
try {
const rotation = await rotateTranscriptAfterCompaction({
sessionManager: SessionManager.open(params.sessionFile),
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),
});
}
}
if (params.config && params.sessionKey && checkpointSnapshot) {
try {
const postCompactionSession = SessionManager.open(params.sessionFile);
const postLeafId = postCompactionSession.getLeafId() ?? undefined;
const postLeafId =
postCompactionLeafId ??
SessionManager.open(postCompactionSessionFile).getLeafId() ??
undefined;
const storedCheckpoint = await persistSessionCompactionCheckpoint({
cfg: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
sessionId: postCompactionSessionId,
reason: resolveSessionCompactionCheckpointReason({
trigger: params.trigger,
}),
@@ -176,7 +208,7 @@ export async function compactEmbeddedPiSession(
firstKeptEntryId: result.result?.firstKeptEntryId,
tokensBefore: result.result?.tokensBefore,
tokensAfter: result.result?.tokensAfter,
postSessionFile: params.sessionFile,
postSessionFile: postCompactionSessionFile,
postLeafId,
postEntryId: postLeafId,
});
@@ -189,9 +221,9 @@ export async function compactEmbeddedPiSession(
}
await runContextEngineMaintenance({
contextEngine,
sessionId: params.sessionId,
sessionId: postCompactionSessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
sessionFile: postCompactionSessionFile,
reason: "compaction",
runtimeContext,
});
@@ -200,7 +232,7 @@ export async function compactEmbeddedPiSession(
await runPostCompactionSideEffects({
config: params.config,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
sessionFile: postCompactionSessionFile,
});
}
if (
@@ -210,14 +242,18 @@ export async function compactEmbeddedPiSession(
hookRunner.runAfterCompaction
) {
try {
const afterHookCtx = {
...hookCtx,
sessionId: postCompactionSessionId,
};
await hookRunner.runAfterCompaction(
{
messageCount: -1,
compactedCount: -1,
tokenCount: result.result?.tokensAfter,
sessionFile: params.sessionFile,
sessionFile: postCompactionSessionFile,
},
hookCtx,
afterHookCtx,
);
} catch (err) {
log.warn("after_compaction hook failed", {
@@ -225,29 +261,6 @@ export async function compactEmbeddedPiSession(
});
}
}
if (
result.ok &&
result.compacted &&
params.config &&
params.config?.agents?.defaults?.compaction?.truncateAfterCompaction !== false
) {
try {
const truncResult = await truncateSessionAfterCompaction({
sessionFile: params.sessionFile,
});
if (truncResult.truncated) {
log.info(
`[compaction] post-compaction truncation removed ${truncResult.entriesRemoved} entries ` +
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
);
}
} catch (err) {
log.warn("[compaction] post-compaction truncation failed", {
errorMessage: formatErrorMessage(err),
errorStack: err instanceof Error ? err.stack : undefined,
});
}
}
return {
ok: result.ok,
compacted: result.compacted,
@@ -259,6 +272,12 @@ export async function compactEmbeddedPiSession(
tokensBefore: result.result.tokensBefore,
tokensAfter: result.result.tokensAfter,
details: result.result.details,
...(postCompactionSessionId !== params.sessionId
? { sessionId: postCompactionSessionId }
: {}),
...(postCompactionSessionFile !== params.sessionFile
? { sessionFile: postCompactionSessionFile }
: {}),
}
: undefined,
};

View File

@@ -112,6 +112,11 @@ import {
compactWithSafetyTimeout,
resolveCompactionTimeoutMs,
} from "./compaction-safety-timeout.js";
import {
type CompactionTranscriptRotation,
rotateTranscriptAfterCompaction,
shouldRotateCompactionTranscript,
} from "./compaction-successor-transcript.js";
import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js";
import { buildEmbeddedExtensionFactories } from "./extensions.js";
import { applyExtraParamsToAgent } from "./extra-params.js";
@@ -125,7 +130,6 @@ import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js
import { shouldUseOpenAIWebSocketTransport } from "./run/attempt.thread-helpers.js";
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
import { truncateSessionAfterCompaction } from "./session-truncation.js";
import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js";
import {
resolveEmbeddedAgentApiKey,
@@ -1079,6 +1083,7 @@ export async function compactEmbeddedPiSessionDirect(
typeof sessionManager.getLeafId === "function"
? (sessionManager.getLeafId() ?? undefined)
: undefined;
let transcriptRotationSessionManager = sessionManager;
if (params.trigger === "manual") {
try {
const hardenedBoundary = await hardenManualCompactionBoundary({
@@ -1091,6 +1096,7 @@ export async function compactEmbeddedPiSessionDirect(
hardenedBoundary.firstKeptEntryId ?? effectiveFirstKeptEntryId;
postCompactionLeafId = hardenedBoundary.leafId ?? postCompactionLeafId;
session.agent.state.messages = hardenedBoundary.messages;
transcriptRotationSessionManager = SessionManager.open(params.sessionFile);
}
} catch (err) {
log.warn("[compaction] failed to harden manual compaction boundary", {
@@ -1107,12 +1113,40 @@ export async function compactEmbeddedPiSessionDirect(
});
const messageCountAfter = session.messages.length;
const compactedCount = Math.max(0, messageCountCompactionInput - messageCountAfter);
let transcriptRotation: CompactionTranscriptRotation = { rotated: false };
if (shouldRotateCompactionTranscript(params.config)) {
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})`,
);
await runPostCompactionSideEffects({
config: params.config,
sessionKey: params.sessionKey,
sessionFile: activeSessionFile,
});
}
if (params.config && params.sessionKey && checkpointSnapshot) {
try {
const storedCheckpoint = await persistSessionCompactionCheckpoint({
cfg: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
sessionId: activeSessionId,
reason: resolveSessionCompactionCheckpointReason({
trigger: params.trigger,
}),
@@ -1121,9 +1155,9 @@ export async function compactEmbeddedPiSessionDirect(
firstKeptEntryId: effectiveFirstKeptEntryId,
tokensBefore: observedTokenCount ?? result.tokensBefore,
tokensAfter,
postSessionFile: params.sessionFile,
postLeafId: postCompactionLeafId,
postEntryId: postCompactionLeafId,
postSessionFile: activeSessionFile,
postLeafId: activePostLeafId,
postEntryId: activePostLeafId,
createdAt: compactStartedAt,
});
checkpointSnapshotRetained = storedCheckpoint !== null;
@@ -1152,7 +1186,7 @@ export async function compactEmbeddedPiSessionDirect(
}
await runAfterCompactionHooks({
hookRunner,
sessionId: params.sessionId,
sessionId: activeSessionId,
sessionAgentId,
hookSessionKey,
missingSessionKey,
@@ -1161,33 +1195,11 @@ export async function compactEmbeddedPiSessionDirect(
messageCountAfter,
tokensAfter,
compactedCount,
sessionFile: params.sessionFile,
sessionFile: activeSessionFile,
summaryLength: typeof result.summary === "string" ? result.summary.length : undefined,
tokensBefore: result.tokensBefore,
firstKeptEntryId: effectiveFirstKeptEntryId,
});
// Truncate session file to remove compacted entries (#39953)
if (
params.config &&
params.config.agents?.defaults?.compaction?.truncateAfterCompaction !== false
) {
try {
const truncResult = await truncateSessionAfterCompaction({
sessionFile: params.sessionFile,
});
if (truncResult.truncated) {
log.info(
`[compaction] post-compaction truncation removed ${truncResult.entriesRemoved} entries ` +
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
);
}
} catch (err) {
log.warn("[compaction] post-compaction truncation failed", {
errorMessage: formatErrorMessage(err),
errorStack: err instanceof Error ? err.stack : undefined,
});
}
}
return {
ok: true,
compacted: true,
@@ -1197,6 +1209,8 @@ export async function compactEmbeddedPiSessionDirect(
tokensBefore: observedTokenCount ?? result.tokensBefore,
tokensAfter,
details: result.details,
sessionId: transcriptRotation.sessionId,
sessionFile: transcriptRotation.sessionFile,
},
};
} catch (err) {

View File

@@ -0,0 +1,177 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { afterEach, describe, expect, it } from "vitest";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
import {
rotateTranscriptAfterCompaction,
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 createCompactedSession(sessionDir: string): {
manager: SessionManager;
sessionFile: string;
firstKeptId: string;
oldUserId: string;
} {
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: manager.getSessionFile()!, firstKeptId, oldUserId };
}
describe("rotateTranscriptAfterCompaction", () => {
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);
expect(result.sessionId).toBeTruthy();
expect(result.sessionFile).toBeTruthy();
expect(result.sessionFile).not.toBe(sessionFile);
expect(await fs.readFile(sessionFile, "utf8")).toBe(originalBytes);
const successor = SessionManager.open(result.sessionFile!);
expect(successor.getHeader()).toMatchObject({
id: result.sessionId,
parentSession: sessionFile,
cwd: dir,
});
expect(successor.getEntries().length).toBeLessThan(originalEntryCount);
expect(successor.getBranch()[0]?.type).toBe("model_change");
expect(successor.getBranch()).toContainEqual(
expect.objectContaining({
type: "custom",
customType: "test-extension",
data: { 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("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: manager.getSessionFile()!,
});
expect(result).toMatchObject({
rotated: false,
reason: "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 = manager.getSessionFile();
expect(sessionFile).toBeTruthy();
const staleManager = SessionManager.open(sessionFile!);
const hardened = await hardenManualCompactionBoundary({ sessionFile: 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: sessionFile!,
now: () => new Date("2026-04-27T12:30:00.000Z"),
});
expect(result.rotated).toBe(true);
const successor = SessionManager.open(result.sessionFile!);
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);
expect(successorCompaction).toMatchObject({
firstKeptEntryId: compactionId,
});
});
});
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);
});
});

View File

@@ -0,0 +1,206 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
CURRENT_SESSION_VERSION,
SessionManager,
type CompactionEntry,
type SessionEntry,
type SessionHeader,
} from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
type ReadonlySessionManagerForRotation = Pick<
SessionManager,
"buildSessionContext" | "getBranch" | "getCwd" | "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 = (params.now?.() ?? new Date()).toISOString();
const sessionId = randomUUID();
const successorFile = resolveSuccessorSessionFile({
sessionFile,
sessionId,
timestamp,
});
const successorEntries = buildSuccessorEntries({
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 writeSessionFileAtomic(successorFile, [header, ...successorEntries]);
try {
SessionManager.open(successorFile).buildSessionContext();
} catch (err) {
await fs.unlink(successorFile).catch(() => undefined);
throw err;
}
return {
rotated: true,
sessionId,
sessionFile: successorFile,
compactionEntryId: compaction.id,
leafId: successorEntries[successorEntries.length - 1]?.id,
entriesWritten: successorEntries.length,
};
}
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: {
branch: SessionEntry[];
latestCompactionIndex: number;
}): SessionEntry[] {
const { branch, latestCompactionIndex } = params;
const compaction = branch[latestCompactionIndex] as CompactionEntry;
const firstKeptIndex = branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId);
const keptBeforeCompaction =
firstKeptIndex >= 0 && firstKeptIndex < latestCompactionIndex
? branch.slice(firstKeptIndex, latestCompactionIndex)
: [];
const afterCompaction = branch.slice(latestCompactionIndex + 1);
const statePrefix = collectLatestStatePrefix(branch.slice(0, latestCompactionIndex));
const successorEntries: SessionEntry[] = [];
const seenIds = new Set<string>();
let parentId: string | null = null;
const append = (entry: SessionEntry) => {
if (seenIds.has(entry.id)) {
return;
}
const nextEntry = { ...entry, parentId } as SessionEntry;
successorEntries.push(nextEntry);
seenIds.add(nextEntry.id);
parentId = nextEntry.id;
};
for (const entry of statePrefix) {
append(entry);
}
append(compaction);
for (const entry of [...keptBeforeCompaction, ...afterCompaction]) {
if (entry.type === "compaction" || entry.type === "label") {
continue;
}
append(entry);
}
const retainedIds = new Set(successorEntries.map((entry) => entry.id));
for (const entry of branch) {
if (entry.type !== "label" || !retainedIds.has(entry.targetId)) {
continue;
}
append(entry);
}
return successorEntries;
}
function collectLatestStatePrefix(entries: SessionEntry[]): SessionEntry[] {
const customEntries: Array<{ index: number; entry: SessionEntry }> = [];
const latestByType = new Map<string, { index: number; entry: SessionEntry }>();
for (const [index, entry] of entries.entries()) {
if (entry.type === "custom") {
customEntries.push({ index, entry });
} else if (
entry.type === "thinking_level_change" ||
entry.type === "model_change" ||
entry.type === "session_info"
) {
latestByType.set(entry.type, { index, entry });
}
}
return [...customEntries, ...latestByType.values()]
.toSorted((left, right) => left.index - right.index)
.map(({ entry }) => entry);
}
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`);
}
async function writeSessionFileAtomic(
filePath: string,
entries: Array<SessionHeader | SessionEntry>,
) {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tmpFile = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
const content = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
try {
await fs.writeFile(tmpFile, content, { encoding: "utf8", flag: "wx" });
await fs.rename(tmpFile, filePath);
} catch (err) {
await fs.unlink(tmpFile).catch(() => undefined);
throw err;
}
}

View File

@@ -13,6 +13,8 @@ export function makeCompactionSuccess(params: {
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
sessionId?: string;
sessionFile?: string;
}) {
return {
ok: true as const,
@@ -22,6 +24,8 @@ export function makeCompactionSuccess(params: {
...(params.firstKeptEntryId ? { firstKeptEntryId: params.firstKeptEntryId } : {}),
...(params.tokensBefore !== undefined ? { tokensBefore: params.tokensBefore } : {}),
...(params.tokensAfter !== undefined ? { tokensAfter: params.tokensAfter } : {}),
...(params.sessionId !== undefined ? { sessionId: params.sessionId } : {}),
...(params.sessionFile !== undefined ? { sessionFile: params.sessionFile } : {}),
},
};
}
@@ -83,6 +87,8 @@ type MockCompactDirect = {
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
sessionId?: string;
sessionFile?: string;
};
}) => unknown;
};

View File

@@ -22,6 +22,8 @@ type MockCompactionResult =
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
sessionId?: string;
sessionFile?: string;
};
reason?: string;
}

View File

@@ -611,6 +611,42 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
);
});
it("retries overflow recovery against the rotated compacted transcript", async () => {
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() }))
.mockResolvedValueOnce(
makeAttemptResult({
promptError: null,
sessionIdUsed: "rotated-session",
sessionFileUsed: "/tmp/rotated-session.json",
}),
);
mockedCompactDirect.mockResolvedValueOnce(
makeCompactionSuccess({
summary: "rotated overflow compaction",
tokensAfter: 50,
sessionId: "rotated-session",
sessionFile: "/tmp/rotated-session.json",
}),
);
await runEmbeddedPiAgent(overflowBaseRunParams);
expect(mockedRunEmbeddedAttempt).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
sessionId: "rotated-session",
sessionFile: "/tmp/rotated-session.json",
}),
);
expect(mockedRunContextEngineMaintenance).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "rotated-session",
sessionFile: "/tmp/rotated-session.json",
}),
);
});
it("guards thrown engine-owned overflow compaction attempts", async () => {
mockedContextEngine.info.ownsCompaction = true;
mockedGlobalHookRunner.hasHooks.mockImplementation(

View File

@@ -118,15 +118,30 @@ describe("timeout-triggered compaction", () => {
summary: "compacted for timeout",
tokensBefore: 160000,
tokensAfter: 60000,
sessionId: "timeout-rotated-session",
sessionFile: "/tmp/timeout-rotated-session.json",
}),
);
// Second attempt succeeds
mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
promptError: null,
sessionIdUsed: "timeout-rotated-session",
sessionFileUsed: "/tmp/timeout-rotated-session.json",
}),
);
const result = await runEmbeddedPiAgent(overflowBaseRunParams);
// Verify the loop continued (retry happened)
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(mockedRunEmbeddedAttempt).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
sessionId: "timeout-rotated-session",
sessionFile: "/tmp/timeout-rotated-session.json",
}),
);
expect(mockedRunPostCompactionSideEffects).not.toHaveBeenCalled();
expect(result.meta.error).toBeUndefined();
});

View File

@@ -706,6 +706,24 @@ export async function runEmbeddedPiAgent(
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
try {
let activeSessionId = params.sessionId;
let activeSessionFile = params.sessionFile;
const resolveActiveHookContext = () => ({
...hookCtx,
sessionId: activeSessionId,
});
const adoptCompactionTranscript = (
compactResult: Awaited<ReturnType<typeof contextEngine.compact>>,
) => {
const nextSessionId = compactResult.result?.sessionId;
const nextSessionFile = compactResult.result?.sessionFile;
if (nextSessionId && nextSessionId !== activeSessionId) {
activeSessionId = nextSessionId;
}
if (nextSessionFile && nextSessionFile !== activeSessionFile) {
activeSessionFile = nextSessionFile;
}
};
// When the engine owns compaction, compactEmbeddedPiSessionDirect is
// bypassed. Fire lifecycle hooks here so recovery paths still notify
// subscribers like memory extensions and usage trackers.
@@ -718,8 +736,8 @@ export async function runEmbeddedPiAgent(
}
try {
await hookRunner.runBeforeCompaction(
{ messageCount: -1, sessionFile: params.sessionFile },
hookCtx,
{ messageCount: -1, sessionFile: activeSessionFile },
resolveActiveHookContext(),
);
} catch (hookErr) {
log.warn(`before_compaction hook failed during ${reason}: ${String(hookErr)}`);
@@ -743,9 +761,9 @@ export async function runEmbeddedPiAgent(
messageCount: -1,
compactedCount: -1,
tokenCount: compactResult.result?.tokensAfter,
sessionFile: params.sessionFile,
sessionFile: compactResult.result?.sessionFile ?? activeSessionFile,
},
hookCtx,
resolveActiveHookContext(),
);
} catch (hookErr) {
log.warn(`after_compaction hook failed during ${reason}: ${String(hookErr)}`);
@@ -778,7 +796,7 @@ export async function runEmbeddedPiAgent(
profileId: lastProfileId,
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: params.sessionId,
sessionId: activeSessionId,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -836,7 +854,7 @@ export async function runEmbeddedPiAgent(
});
const attempt = await runEmbeddedAttemptWithBackend({
sessionId: params.sessionId,
sessionId: activeSessionId,
sessionKey: resolvedSessionKey,
sandboxSessionKey: params.sandboxSessionKey,
trigger: params.trigger,
@@ -862,7 +880,7 @@ export async function runEmbeddedPiAgent(
currentMessageId: params.currentMessageId,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
sessionFile: params.sessionFile,
sessionFile: activeSessionFile,
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
@@ -951,9 +969,16 @@ export async function runEmbeddedPiAgent(
idleTimedOut,
timedOutDuringCompaction,
sessionIdUsed,
sessionFileUsed,
lastAssistant: sessionLastAssistant,
currentAttemptAssistant,
} = attempt;
if (sessionIdUsed && sessionIdUsed !== activeSessionId) {
activeSessionId = sessionIdUsed;
}
if (sessionFileUsed && sessionFileUsed !== activeSessionFile) {
activeSessionFile = sessionFileUsed;
}
bootstrapPromptWarningSignaturesSeen =
attempt.bootstrapPromptWarningSignaturesSeen ??
(attempt.bootstrapPromptWarningSignature
@@ -1096,9 +1121,9 @@ export async function runEmbeddedPiAgent(
maxAttempts: MAX_TIMEOUT_COMPACTION_ATTEMPTS,
};
timeoutCompactResult = await contextEngine.compact({
sessionId: params.sessionId,
sessionId: activeSessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
sessionFile: activeSessionFile,
tokenBudget: ctxInfo.tokens,
force: true,
compactionTarget: "budget",
@@ -1114,6 +1139,9 @@ export async function runEmbeddedPiAgent(
reason: String(compactErr),
};
}
if (timeoutCompactResult.compacted) {
adoptCompactionTranscript(timeoutCompactResult);
}
await runOwnsCompactionAfterHook("timeout recovery", timeoutCompactResult);
if (timeoutCompactResult.compacted) {
autoCompactionCount += 1;
@@ -1121,7 +1149,7 @@ export async function runEmbeddedPiAgent(
await runPostCompactionSideEffects({
config: params.config,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
sessionFile: activeSessionFile,
});
}
log.info(
@@ -1165,7 +1193,7 @@ export async function runEmbeddedPiAgent(
log.warn(
`[context-overflow-diag] sessionKey=${params.sessionKey ?? params.sessionId} ` +
`provider=${provider}/${modelId} source=${contextOverflowError.source} ` +
`messages=${msgCount} sessionFile=${params.sessionFile} ` +
`messages=${msgCount} sessionFile=${activeSessionFile} ` +
`diagId=${overflowDiagId} compactionAttempts=${overflowCompactionAttempts} ` +
`observedTokens=${observedOverflowTokens ?? "unknown"} ` +
`error=${errorText.slice(0, 200)}`,
@@ -1241,9 +1269,9 @@ export async function runEmbeddedPiAgent(
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
};
compactResult = await contextEngine.compact({
sessionId: params.sessionId,
sessionId: activeSessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
sessionFile: activeSessionFile,
tokenBudget: ctxInfo.tokens,
...(observedOverflowTokens !== undefined
? { currentTokenCount: observedOverflowTokens }
@@ -1253,11 +1281,12 @@ export async function runEmbeddedPiAgent(
runtimeContext: overflowCompactionRuntimeContext,
});
if (compactResult.ok && compactResult.compacted) {
adoptCompactionTranscript(compactResult);
await runContextEngineMaintenance({
contextEngine,
sessionId: params.sessionId,
sessionId: activeSessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
sessionFile: activeSessionFile,
reason: "compaction",
runtimeContext: overflowCompactionRuntimeContext,
});
@@ -1274,16 +1303,17 @@ export async function runEmbeddedPiAgent(
}
await runOwnsCompactionAfterHook("overflow recovery", compactResult);
if (compactResult.compacted) {
adoptCompactionTranscript(compactResult);
if (preflightRecovery?.route === "compact_then_truncate") {
const truncResult = await truncateOversizedToolResultsInSession({
sessionFile: params.sessionFile,
sessionFile: activeSessionFile,
contextWindowTokens: ctxInfo.tokens,
maxCharsOverride: resolveLiveToolResultMaxChars({
contextWindowTokens: ctxInfo.tokens,
cfg: params.config,
agentId: sessionAgentId,
}),
sessionId: params.sessionId,
sessionId: activeSessionId,
sessionKey: params.sessionKey,
});
if (truncResult.truncated) {
@@ -1328,10 +1358,10 @@ export async function runEmbeddedPiAgent(
`(contextWindow=${contextWindowTokens} tokens)`,
);
const truncResult = await truncateOversizedToolResultsInSession({
sessionFile: params.sessionFile,
sessionFile: activeSessionFile,
contextWindowTokens,
maxCharsOverride: toolResultMaxChars,
sessionId: params.sessionId,
sessionId: activeSessionId,
sessionKey: params.sessionKey,
});
if (truncResult.truncated) {
@@ -1782,6 +1812,7 @@ export async function runEmbeddedPiAgent(
});
const agentMeta: EmbeddedPiAgentMeta = {
sessionId: sessionIdUsed,
sessionFile: sessionFileUsed,
provider: sessionLastAssistant?.provider ?? provider,
model: sessionLastAssistant?.model ?? model.id,
contextTokens: ctxInfo.tokens,

View File

@@ -194,7 +194,6 @@ import {
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { truncateSessionAfterCompaction } from "../session-truncation.js";
import { resolveEmbeddedRunSkillEntries } from "../skills-runtime.js";
import {
describeEmbeddedAgentStreamStrategy,
@@ -233,6 +232,10 @@ import {
shouldStripBootstrapFromEmbeddedContext,
} from "./attempt-bootstrap-routing.js";
export { shouldStripBootstrapFromEmbeddedContext } from "./attempt-bootstrap-routing.js";
import {
rotateTranscriptAfterCompaction,
shouldRotateCompactionTranscript,
} from "../compaction-successor-transcript.js";
import { configureEmbeddedAttemptHttpRuntime } from "./attempt-http-runtime.js";
import {
assembleAttemptContextEngine,
@@ -354,33 +357,6 @@ export {
};
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
const MAX_PRE_OPEN_TRUNCATION_CHECKED_SESSION_FILES = 4096;
const preOpenTruncationCheckedSessionFiles = new Map<string, number>();
function hasPreOpenTruncationCheckedSessionFile(sessionFile: string): boolean {
const normalized = path.resolve(sessionFile);
if (!preOpenTruncationCheckedSessionFiles.has(normalized)) {
return false;
}
preOpenTruncationCheckedSessionFiles.delete(normalized);
preOpenTruncationCheckedSessionFiles.set(normalized, Date.now());
return true;
}
function markPreOpenTruncationCheckedSessionFile(sessionFile: string): void {
const normalized = path.resolve(sessionFile);
preOpenTruncationCheckedSessionFiles.delete(normalized);
preOpenTruncationCheckedSessionFiles.set(normalized, Date.now());
while (
preOpenTruncationCheckedSessionFiles.size > MAX_PRE_OPEN_TRUNCATION_CHECKED_SESSION_FILES
) {
const oldest = preOpenTruncationCheckedSessionFiles.keys().next().value;
if (!oldest) {
break;
}
preOpenTruncationCheckedSessionFiles.delete(oldest);
}
}
export function resolveUnknownToolGuardThreshold(loopDetection?: {
enabled?: boolean;
@@ -1243,31 +1219,6 @@ export async function runEmbeddedAttempt(
.stat(params.sessionFile)
.then(() => true)
.catch(() => false);
if (
hadSessionFile &&
params.config &&
params.config?.agents?.defaults?.compaction?.truncateAfterCompaction !== false &&
!hasPreOpenTruncationCheckedSessionFile(params.sessionFile)
) {
try {
const truncResult = await truncateSessionAfterCompaction({
sessionFile: params.sessionFile,
});
if (truncResult.truncated) {
log.info(
`[session-truncation] pre-open cleanup removed ${truncResult.entriesRemoved} entries ` +
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
);
}
} catch (err) {
log.warn("[session-truncation] pre-open cleanup failed", {
errorMessage: formatErrorMessage(err),
errorStack: err instanceof Error ? err.stack : undefined,
});
} finally {
markPreOpenTruncationCheckedSessionFile(params.sessionFile);
}
}
const transcriptPolicy = resolveAttemptTranscriptPolicy({
runtimePlan: params.runtimePlan,
@@ -2223,6 +2174,7 @@ export async function runEmbeddedAttempt(
let messagesSnapshot: AgentMessage[] = [];
let sessionIdUsed = activeSession.sessionId;
let sessionFileUsed: string | undefined = params.sessionFile;
const onAbort = () => {
externalAbort = true;
const reason = params.abortSignal ? getAbortReason(params.abortSignal) : undefined;
@@ -2957,6 +2909,35 @@ export async function runEmbeddedAttempt(
}
}
if (
compactionOccurredThisAttempt &&
!promptError &&
!aborted &&
!timedOut &&
!idleTimedOut &&
!timedOutDuringCompaction &&
shouldRotateCompactionTranscript(params.config)
) {
try {
const rotation = await rotateTranscriptAfterCompaction({
sessionManager,
sessionFile: params.sessionFile,
});
if (rotation.rotated) {
sessionIdUsed = rotation.sessionId ?? sessionIdUsed;
sessionFileUsed = rotation.sessionFile ?? sessionFileUsed;
log.info(
`[compaction] rotated active transcript after automatic compaction ` +
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
);
}
} catch (err) {
log.warn("[compaction] automatic transcript rotation failed", {
errorMessage: formatErrorMessage(err),
});
}
}
cacheTrace?.recordStage("session:after", {
messages: messagesSnapshot,
note: timedOutDuringCompaction
@@ -3180,6 +3161,7 @@ export async function runEmbeddedAttempt(
promptErrorSource,
preflightRecovery,
sessionIdUsed,
sessionFileUsed,
diagnosticTrace,
bootstrapPromptWarningSignaturesSeen: bootstrapPromptWarning.warningSignaturesSeen,
bootstrapPromptWarningSignature: bootstrapPromptWarning.signature,

View File

@@ -76,6 +76,7 @@ export type EmbeddedRunAttemptResult = {
handled?: false;
};
sessionIdUsed: string;
sessionFileUsed?: string;
diagnosticTrace?: DiagnosticTraceContext;
agentHarnessId?: string;
agentHarnessResultClassification?: "empty" | "reasoning-only" | "planning-only";

View File

@@ -1,476 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
import { truncateSessionAfterCompaction } from "./session-truncation.js";
let tmpDir: string;
async function createTmpDir(): Promise<string> {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-truncation-test-"));
return tmpDir;
}
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
});
function makeAssistant(text: string, timestamp: number) {
return makeAgentAssistantMessage({
content: [{ type: "text", text }],
timestamp,
});
}
function createSessionWithCompaction(sessionDir: string): string {
const sm = SessionManager.create(sessionDir, sessionDir);
// Add messages before compaction
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
sm.appendMessage(makeAssistant("hi there", 2));
sm.appendMessage({ role: "user", content: "do something", timestamp: 3 });
sm.appendMessage(makeAssistant("done", 4));
// Add compaction (summarizing the above)
const branch = sm.getBranch();
const firstKeptId = branch[branch.length - 1].id;
sm.appendCompaction("Summary of conversation so far.", firstKeptId, 5000);
// Add messages after compaction
sm.appendMessage({ role: "user", content: "next task", timestamp: 5 });
sm.appendMessage(makeAssistant("working on it", 6));
return sm.getSessionFile()!;
}
describe("truncateSessionAfterCompaction", () => {
it("removes entries before compaction and keeps entries after (#39953)", async () => {
const dir = await createTmpDir();
const sessionFile = createSessionWithCompaction(dir);
// Verify pre-truncation state
const smBefore = SessionManager.open(sessionFile);
const entriesBefore = smBefore.getEntries().length;
expect(entriesBefore).toBeGreaterThan(5); // 4 messages + compaction + 2 messages
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(true);
expect(result.entriesRemoved).toBeGreaterThan(0);
expect(result.bytesAfter).toBeLessThan(result.bytesBefore!);
// Verify post-truncation: file is still a valid session
const smAfter = SessionManager.open(sessionFile);
const entriesAfter = smAfter.getEntries().length;
expect(entriesAfter).toBeLessThan(entriesBefore);
// The branch should contain the firstKeptEntryId message (unsummarized
// tail), compaction, and post-compaction messages
const branchAfter = smAfter.getBranch();
// The firstKeptEntryId message is preserved as the new root
expect(branchAfter[0].type).toBe("message");
expect(branchAfter[0].parentId).toBeNull();
expect(branchAfter[1].type).toBe("compaction");
// Session context should still work
const ctx = smAfter.buildSessionContext();
expect(ctx.messages.length).toBeGreaterThan(0);
});
it("skips truncation when no compaction entry exists", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
// appendMessage implicitly creates the session file
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
sm.appendMessage(makeAssistant("hi", 2));
sm.appendMessage({ role: "user", content: "bye", timestamp: 3 });
const sessionFile = sm.getSessionFile()!;
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(false);
expect(result.reason).toBe("no compaction entry found");
});
it("is idempotent — second truncation is a no-op", async () => {
const dir = await createTmpDir();
const sessionFile = createSessionWithCompaction(dir);
const first = await truncateSessionAfterCompaction({ sessionFile });
expect(first.truncated).toBe(true);
// Run again — no message entries left to remove
const second = await truncateSessionAfterCompaction({ sessionFile });
expect(second.truncated).toBe(false);
});
it("fails closed on malformed transcript lines", async () => {
const dir = await createTmpDir();
const sessionFile = createSessionWithCompaction(dir);
const before = await fs.readFile(sessionFile, "utf-8");
await fs.appendFile(sessionFile, "not-json\n", "utf-8");
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(false);
expect(result.reason).toContain("Malformed JSONL");
expect(await fs.readFile(sessionFile, "utf-8")).toBe(`${before}not-json\n`);
});
it("breaks cyclic removed-parent chains while re-parenting kept entries", async () => {
const dir = await createTmpDir();
const sessionFile = path.join(dir, "cyclic.jsonl");
const now = new Date().toISOString();
await fs.writeFile(
sessionFile,
[
{ type: "session", version: 3, id: "session", timestamp: now, cwd: dir },
{
type: "message",
id: "a",
parentId: "b",
timestamp: now,
message: { role: "user", content: "a", timestamp: 1 },
},
{
type: "message",
id: "b",
parentId: "a",
timestamp: now,
message: { role: "assistant", content: "b", timestamp: 2 },
},
{
type: "message",
id: "c",
parentId: "b",
timestamp: now,
message: { role: "user", content: "c", timestamp: 3 },
},
{
type: "compaction",
id: "compact",
parentId: "c",
timestamp: now,
summary: "summarized cyclic prefix",
firstKeptEntryId: "c",
tokensBefore: 100,
},
{
type: "message",
id: "after",
parentId: "compact",
timestamp: now,
message: { role: "user", content: "after", timestamp: 4 },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf-8",
);
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(true);
const smAfter = SessionManager.open(sessionFile);
const kept = smAfter.getEntry("c");
expect(kept?.parentId).toBeNull();
expect(smAfter.buildSessionContext().messages.length).toBeGreaterThan(0);
});
it("archives original file when archivePath is provided (#39953)", async () => {
const dir = await createTmpDir();
const sessionFile = createSessionWithCompaction(dir);
const archivePath = path.join(dir, "archive", "backup.jsonl");
const result = await truncateSessionAfterCompaction({ sessionFile, archivePath });
expect(result.truncated).toBe(true);
const archiveExists = await fs
.stat(archivePath)
.then(() => true)
.catch(() => false);
expect(archiveExists).toBe(true);
// Archive should be larger than truncated file (it has the full history)
const archiveSize = (await fs.stat(archivePath)).size;
const truncatedSize = (await fs.stat(sessionFile)).size;
expect(archiveSize).toBeGreaterThan(truncatedSize);
});
it("truncates without opening the full transcript through SessionManager", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
const largeText = "x".repeat(100_000);
sm.appendMessage({ role: "user", content: largeText, timestamp: 1 });
sm.appendMessage(makeAssistant(largeText, 2));
sm.appendMessage({ role: "user", content: largeText, timestamp: 3 });
sm.appendMessage(makeAssistant(largeText, 4));
const branch = sm.getBranch();
const firstKeptId = branch[branch.length - 1].id;
sm.appendCompaction("Summary of large history.", firstKeptId, 50_000);
sm.appendMessage({ role: "user", content: "next task", timestamp: 5 });
const sessionFile = sm.getSessionFile()!;
const bytesBefore = (await fs.stat(sessionFile)).size;
const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => {
throw new Error("unexpected eager SessionManager.open");
});
let result: Awaited<ReturnType<typeof truncateSessionAfterCompaction>>;
try {
result = await truncateSessionAfterCompaction({ sessionFile });
} finally {
openSpy.mockRestore();
}
expect(result.truncated).toBe(true);
expect(result.bytesAfter).toBeLessThan(bytesBefore);
expect(SessionManager.open(sessionFile).buildSessionContext().messages.length).toBeGreaterThan(
0,
);
});
it("handles multiple compaction cycles (#39953)", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
// First cycle: messages + compaction
sm.appendMessage({ role: "user", content: "cycle 1 message 1", timestamp: 1 });
sm.appendMessage(makeAssistant("response 1", 2));
const branch1 = sm.getBranch();
sm.appendCompaction("Summary of cycle 1.", branch1[branch1.length - 1].id, 3000);
// Second cycle: more messages + another compaction
sm.appendMessage({ role: "user", content: "cycle 2 message 1", timestamp: 3 });
sm.appendMessage(makeAssistant("response 2", 4));
const branch2 = sm.getBranch();
sm.appendCompaction("Summary of cycles 1 and 2.", branch2[branch2.length - 1].id, 6000);
// Post-compaction messages
sm.appendMessage({ role: "user", content: "final question", timestamp: 5 });
const sessionFile = sm.getSessionFile()!;
const entriesBefore = sm.getEntries().length;
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(true);
// Should preserve both compactions (older compactions are non-message state)
// but remove the summarized message entries
const smAfter = SessionManager.open(sessionFile);
const branchAfter = smAfter.getBranch();
expect(branchAfter[0].type).toBe("compaction");
// Both compaction entries are preserved (non-message state is kept)
const compactionEntries = branchAfter.filter((e) => e.type === "compaction");
expect(compactionEntries).toHaveLength(2);
// But message entries before the latest compaction were removed
const entriesAfter = smAfter.getEntries().length;
expect(entriesAfter).toBeLessThan(entriesBefore);
// Only the firstKeptEntryId message should remain before the latest compaction
const latestCompIdx = branchAfter.findIndex(
(e) => e.type === "compaction" && e === compactionEntries[compactionEntries.length - 1],
);
const messagesBeforeLatest = branchAfter
.slice(0, latestCompIdx)
.filter((e) => e.type === "message");
expect(messagesBeforeLatest).toHaveLength(1);
});
it("preserves non-message session state during truncation", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
// Messages before compaction
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
sm.appendMessage(makeAssistant("hi", 2));
// Non-message state entries interleaved with messages
sm.appendModelChange("anthropic", "claude-sonnet-4-5-20250514");
sm.appendThinkingLevelChange("high");
sm.appendCustomEntry("my-extension", { key: "value" });
sm.appendSessionInfo("my session");
sm.appendMessage({ role: "user", content: "do task", timestamp: 3 });
sm.appendMessage(makeAssistant("done", 4));
// Compaction summarizing the conversation
const branch = sm.getBranch();
const firstKeptId = branch[branch.length - 1].id;
sm.appendCompaction("Summary.", firstKeptId, 5000);
// Post-compaction messages
sm.appendMessage({ role: "user", content: "next", timestamp: 5 });
const sessionFile = sm.getSessionFile()!;
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(true);
// Verify non-message entries are preserved
const smAfter = SessionManager.open(sessionFile);
const allAfter = smAfter.getEntries();
const types = allAfter.map((e) => e.type);
expect(types).toContain("model_change");
expect(types).toContain("thinking_level_change");
expect(types).toContain("custom");
expect(types).toContain("session_info");
expect(types).toContain("compaction");
// Only the firstKeptEntryId message should remain before the compaction
// (all other messages before it were summarized and removed)
const branchAfter = smAfter.getBranch();
const compIdx = branchAfter.findIndex((e) => e.type === "compaction");
const msgsBefore = branchAfter.slice(0, compIdx).filter((e) => e.type === "message");
expect(msgsBefore).toHaveLength(1);
// Session context should still work
const ctx = smAfter.buildSessionContext();
expect(ctx.messages.length).toBeGreaterThan(0);
// Non-message state entries are preserved in the truncated file
expect(ctx.model).toBeDefined();
expect(ctx.thinkingLevel).toBe("high");
});
it("drops label entries whose target message was truncated", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
// Messages before compaction
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
sm.appendMessage(makeAssistant("hi", 2));
sm.appendMessage({ role: "user", content: "do task", timestamp: 3 });
sm.appendMessage(makeAssistant("done", 4));
// Capture a pre-compaction message that will be summarized away.
const branch = sm.getBranch();
const preCompactionMsgId = branch[1].id; // "hi" message
// Compaction summarizing the conversation
const firstKeptId = branch[branch.length - 1].id;
sm.appendCompaction("Summary.", firstKeptId, 5000);
// Post-compaction messages
sm.appendMessage({ role: "user", content: "next", timestamp: 5 });
sm.appendLabelChange(preCompactionMsgId, "my-label");
const sessionFile = sm.getSessionFile()!;
const labelEntry = sm.getEntries().find((entry) => entry.type === "label");
expect(labelEntry?.parentId).not.toBe(preCompactionMsgId);
const smBefore = SessionManager.open(sessionFile);
expect(smBefore.getLabel(preCompactionMsgId)).toBe("my-label");
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(true);
// Verify label metadata was dropped with the removed target message.
const smAfter = SessionManager.open(sessionFile);
const allAfter = smAfter.getEntries();
const labels = allAfter.filter((e) => e.type === "label");
expect(labels).toHaveLength(0);
expect(smAfter.getLabel(preCompactionMsgId)).toBeUndefined();
});
it("preserves the firstKeptEntryId unsummarized tail", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
// Build a conversation where firstKeptEntryId is NOT the last message
sm.appendMessage({ role: "user", content: "msg1", timestamp: 1 });
sm.appendMessage(makeAssistant("resp1", 2));
sm.appendMessage({ role: "user", content: "msg2", timestamp: 3 });
sm.appendMessage(makeAssistant("resp2", 4));
const branch = sm.getBranch();
// Set firstKeptEntryId to the second message — so msg1 is summarized
// but msg2, resp2, and everything after are the unsummarized tail.
const firstKeptId = branch[1].id; // "resp1"
sm.appendCompaction("Summary of msg1.", firstKeptId, 2000);
sm.appendMessage({ role: "user", content: "next", timestamp: 5 });
const sessionFile = sm.getSessionFile()!;
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(true);
// Only msg1 was summarized (1 entry removed)
expect(result.entriesRemoved).toBe(1);
// Verify the unsummarized tail is preserved
const smAfter = SessionManager.open(sessionFile);
const branchAfter = smAfter.getBranch();
const types = branchAfter.map((e) => e.type);
// resp1 (firstKeptEntryId), msg2, resp2, compaction, next
expect(types).toEqual(["message", "message", "message", "compaction", "message"]);
// buildSessionContext should include the unsummarized tail
const ctx = smAfter.buildSessionContext();
expect(ctx.messages.length).toBeGreaterThan(2);
});
it("preserves unsummarized sibling branches during truncation", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
// Build main conversation
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
sm.appendMessage(makeAssistant("hi there", 2));
// Save a branch point
const branchPoint = sm.getBranch();
const branchFromId = branchPoint[branchPoint.length - 1].id;
// Continue main branch
sm.appendMessage({ role: "user", content: "do task A", timestamp: 3 });
sm.appendMessage(makeAssistant("done A", 4));
// Create a sibling branch from the earlier point
sm.branch(branchFromId);
sm.appendMessage({ role: "user", content: "do task B instead", timestamp: 5 });
const siblingMsg = sm.appendMessage(makeAssistant("done B", 6));
// Go back to main branch tip and add compaction there
sm.branch(branchFromId);
sm.appendMessage({ role: "user", content: "do task A", timestamp: 3 });
sm.appendMessage(makeAssistant("done A take 2", 7));
const mainBranch = sm.getBranch();
const firstKeptId = mainBranch[mainBranch.length - 1].id;
sm.appendCompaction("Summary of main branch.", firstKeptId, 5000);
sm.appendMessage({ role: "user", content: "next", timestamp: 8 });
const sessionFile = sm.getSessionFile()!;
const entriesBefore = sm.getEntries();
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(true);
// Verify sibling branch is preserved in the full entry list
const smAfter = SessionManager.open(sessionFile);
const allAfter = smAfter.getEntries();
// The sibling branch message should still exist
const siblingAfter = allAfter.find((e) => e.id === siblingMsg);
expect(siblingAfter).toBeDefined();
// The tree should have entries from both branches
const tree = smAfter.getTree();
expect(tree.length).toBeGreaterThan(0);
// Total entries should be less (main branch messages removed) but not zero
expect(allAfter.length).toBeGreaterThan(0);
expect(allAfter.length).toBeLessThan(entriesBefore.length);
});
});

View File

@@ -1,458 +0,0 @@
import { randomUUID } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { finished } from "node:stream/promises";
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
import { formatErrorMessage } from "../../infra/errors.js";
import { acquireSessionWriteLock } from "../session-write-lock.js";
import { log } from "./logger.js";
const MAX_SESSION_TRUNCATION_LINE_BYTES = 64 * 1024 * 1024;
const MAX_SESSION_TRUNCATION_ENTRIES = 250_000;
/**
* Truncate a session JSONL file after compaction by removing only the
* message entries that the compaction actually summarized.
*
* After compaction, the session file still contains all historical entries
* even though `buildSessionContext()` logically skips entries before
* `firstKeptEntryId`. Over many compaction cycles this causes unbounded
* file growth (issue #39953).
*
* This function rewrites the file keeping:
* 1. The session header
* 2. All non-message session state (custom, model_change, thinking_level_change,
* session_info, custom_message, compaction entries)
* Note: label and branch_summary entries referencing removed messages are
* also dropped to avoid dangling metadata.
* 3. All entries from sibling branches not covered by the compaction
* 4. The unsummarized tail: entries from `firstKeptEntryId` through (and
* including) the compaction entry, plus all entries after it
*
* Only `message` entries in the current branch that precede the compaction's
* `firstKeptEntryId` are removed — they are the entries the compaction
* actually summarized. Entries from `firstKeptEntryId` onward are preserved
* because `buildSessionContext()` expects them when reconstructing the
* session. Entries whose parent was removed are re-parented to the nearest
* kept ancestor (or become roots).
*/
export async function truncateSessionAfterCompaction(params: {
sessionFile: string;
/** Optional path to archive the pre-truncation file. */
archivePath?: string;
}): Promise<TruncationResult> {
const sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
allowReentrant: true,
});
try {
return await truncateSessionAfterCompactionLocked(params);
} finally {
await sessionLock.release();
}
}
async function truncateSessionAfterCompactionLocked(params: {
sessionFile: string;
/** Optional path to archive the pre-truncation file. */
archivePath?: string;
}): Promise<TruncationResult> {
const { sessionFile } = params;
const scan = await scanSessionFile(sessionFile);
if (!scan.ok) {
log.warn(`[session-truncation] Failed to scan session file: ${scan.reason}`);
return { truncated: false, entriesRemoved: 0, reason: scan.reason };
}
const { headerLine, entries, entryById } = scan;
if (!headerLine) {
return { truncated: false, entriesRemoved: 0, reason: "missing session header" };
}
const branch = buildCurrentBranch(entries, entryById);
if (branch.length === 0) {
return { truncated: false, entriesRemoved: 0, reason: "empty session" };
}
// Find the latest compaction entry in the current branch
let latestCompactionIdx = -1;
for (let i = branch.length - 1; i >= 0; i--) {
if (branch[i].type === "compaction") {
latestCompactionIdx = i;
break;
}
}
if (latestCompactionIdx < 0) {
return { truncated: false, entriesRemoved: 0, reason: "no compaction entry found" };
}
// Nothing to truncate if compaction is already at root
if (latestCompactionIdx === 0) {
return { truncated: false, entriesRemoved: 0, reason: "compaction already at root" };
}
// The compaction's firstKeptEntryId marks the start of the "unsummarized
// tail" — entries from firstKeptEntryId through the compaction that
// buildSessionContext() expects to find when reconstructing the session.
// Only entries *before* firstKeptEntryId were actually summarized.
const compactionEntry = branch[latestCompactionIdx];
const { firstKeptEntryId } = compactionEntry;
// Collect IDs of entries in the current branch that were actually summarized
// (everything before firstKeptEntryId). Entries from firstKeptEntryId through
// the compaction are the unsummarized tail and must be preserved.
const summarizedBranchIds = new Set<string>();
for (let i = 0; i < latestCompactionIdx; i++) {
if (firstKeptEntryId && branch[i].id === firstKeptEntryId) {
break; // Everything from here to the compaction is the unsummarized tail
}
summarizedBranchIds.add(branch[i].id);
}
// Only remove message-type entries that the compaction actually summarized.
// Non-message session state (custom, model_change, thinking_level_change,
// session_info, custom_message) is preserved even if it sits in the
// summarized portion of the branch.
//
// label and branch_summary entries that reference removed message IDs are
// also dropped to avoid dangling metadata (consistent with the approach in
// tool-result-truncation.ts).
const removedIds = new Set<string>();
for (const entry of entries) {
if (summarizedBranchIds.has(entry.id) && entry.type === "message") {
removedIds.add(entry.id);
}
}
// Labels bookmark targetId while parentId just records the leaf when the
// label was changed, so targetId determines whether the label is still valid.
// Branch summaries still hang off the summarized branch via parentId.
for (const entry of entries) {
if (
entry.type === "label" &&
typeof entry.targetId === "string" &&
removedIds.has(entry.targetId)
) {
removedIds.add(entry.id);
continue;
}
if (
entry.type === "branch_summary" &&
entry.parentId !== null &&
removedIds.has(entry.parentId)
) {
removedIds.add(entry.id);
}
}
if (removedIds.size === 0) {
return { truncated: false, entriesRemoved: 0, reason: "no entries to remove" };
}
const entriesRemoved = removedIds.size;
const totalEntriesBefore = entries.length;
// Get file size before truncation
let bytesBefore = 0;
try {
const stat = await fs.stat(sessionFile);
bytesBefore = stat.size;
} catch {
// If stat fails, continue anyway
}
// Archive original file if requested
if (params.archivePath) {
try {
const archiveDir = path.dirname(params.archivePath);
await fs.mkdir(archiveDir, { recursive: true });
await fs.copyFile(sessionFile, params.archivePath);
log.info(`[session-truncation] Archived pre-truncation file to ${params.archivePath}`);
} catch (err) {
const reason = formatErrorMessage(err);
log.warn(`[session-truncation] Failed to archive: ${reason}`);
}
}
const tmpFile = createTruncationTmpFile(sessionFile);
try {
const rewrite = await rewriteSessionFile({
sessionFile,
tmpFile,
headerLine,
removedIds,
entryById,
});
await fs.rename(tmpFile, sessionFile);
const bytesAfter = rewrite.bytesAfter;
log.info(
`[session-truncation] Truncated session file: ` +
`entriesBefore=${totalEntriesBefore} entriesAfter=${rewrite.entriesAfter} ` +
`removed=${entriesRemoved} bytesBefore=${bytesBefore} bytesAfter=${bytesAfter} ` +
`reduction=${bytesBefore > 0 ? ((1 - bytesAfter / bytesBefore) * 100).toFixed(1) : "?"}%`,
);
return { truncated: true, entriesRemoved, bytesBefore, bytesAfter };
} catch (err) {
// Clean up temp file on failure
try {
await fs.unlink(tmpFile);
} catch {
// Ignore cleanup errors
}
const reason = formatErrorMessage(err);
log.warn(`[session-truncation] Failed to write truncated file: ${reason}`);
return { truncated: false, entriesRemoved: 0, reason };
}
}
type SessionEntryMeta = {
id: string;
parentId: string | null;
type: string;
firstKeptEntryId?: string;
targetId?: string;
};
type SessionFileScanResult =
| {
ok: true;
headerLine: string | null;
entries: SessionEntryMeta[];
entryById: Map<string, SessionEntryMeta>;
}
| { ok: false; reason: string };
function normalizeEntryMeta(value: unknown): SessionEntryMeta | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (record.type === "session") {
return null;
}
if (typeof record.id !== "string" || !record.id) {
return null;
}
const parentId = typeof record.parentId === "string" ? record.parentId : null;
return {
id: record.id,
parentId,
type: typeof record.type === "string" ? record.type : "",
...(typeof record.firstKeptEntryId === "string"
? { firstKeptEntryId: record.firstKeptEntryId }
: {}),
...(typeof record.targetId === "string" ? { targetId: record.targetId } : {}),
};
}
async function forEachJsonlLine(
filePath: string,
callback: (line: string, lineNumber: number) => Promise<void> | void,
): Promise<void> {
const stream = createReadStream(filePath, { encoding: "utf-8" });
const lines = readline.createInterface({
input: stream,
crlfDelay: Number.POSITIVE_INFINITY,
});
let lineNumber = 0;
try {
for await (const line of lines) {
lineNumber++;
if (!line.trim()) {
continue;
}
const lineBytes = Buffer.byteLength(line, "utf-8");
if (lineBytes > MAX_SESSION_TRUNCATION_LINE_BYTES) {
throw new Error(
`Session JSONL line ${lineNumber} exceeds ${MAX_SESSION_TRUNCATION_LINE_BYTES} bytes`,
);
}
await callback(line, lineNumber);
}
} finally {
lines.close();
stream.destroy();
}
}
async function scanSessionFile(sessionFile: string): Promise<SessionFileScanResult> {
const entries: SessionEntryMeta[] = [];
const entryById = new Map<string, SessionEntryMeta>();
let headerLine: string | null = null;
try {
await forEachJsonlLine(sessionFile, (line, lineNumber) => {
const parsed = parseJsonlLine(line, lineNumber);
if (
parsed &&
typeof parsed === "object" &&
(parsed as { type?: unknown }).type === "session"
) {
headerLine ??= line;
return;
}
const meta = normalizeEntryMeta(parsed);
if (!meta) {
return;
}
if (entries.length >= MAX_SESSION_TRUNCATION_ENTRIES) {
throw new Error(
`Session transcript exceeds ${MAX_SESSION_TRUNCATION_ENTRIES} entries during truncation scan`,
);
}
entries.push(meta);
entryById.set(meta.id, meta);
});
} catch (err) {
return { ok: false, reason: formatErrorMessage(err) };
}
return { ok: true, headerLine, entries, entryById };
}
function buildCurrentBranch(
entries: SessionEntryMeta[],
entryById: Map<string, SessionEntryMeta>,
): SessionEntryMeta[] {
const branch: SessionEntryMeta[] = [];
const seen = new Set<string>();
let cursor = entries.at(-1);
while (cursor && !seen.has(cursor.id)) {
branch.push(cursor);
seen.add(cursor.id);
cursor = cursor.parentId ? entryById.get(cursor.parentId) : undefined;
}
return branch.toReversed();
}
function resolveKeptParentId(params: {
parentId: string | null;
removedIds: Set<string>;
entryById: Map<string, SessionEntryMeta>;
}): string | null {
let parentId = params.parentId;
const seen = new Set<string>();
while (parentId !== null && params.removedIds.has(parentId)) {
if (seen.has(parentId)) {
return null;
}
seen.add(parentId);
const parent = params.entryById.get(parentId);
parentId = parent?.parentId ?? null;
}
return parentId;
}
async function writeLine(stream: NodeJS.WritableStream, line: string): Promise<void> {
if (!stream.write(`${line}\n`, "utf-8")) {
await waitForDrain(stream);
}
}
function waitForDrain(stream: NodeJS.WritableStream): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
stream.removeListener("drain", onDrain);
stream.removeListener("error", onError);
};
const onDrain = () => {
cleanup();
resolve();
};
const onError = (err: unknown) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
};
stream.once("drain", onDrain);
stream.once("error", onError);
});
}
function createTruncationTmpFile(sessionFile: string): string {
return path.join(
path.dirname(sessionFile),
`.${path.basename(sessionFile)}.${randomUUID()}.truncate-tmp`,
);
}
function parseJsonlLine(line: string, lineNumber: number): unknown {
try {
return JSON.parse(line);
} catch (err) {
throw new Error(
`Malformed JSONL in session transcript at line ${lineNumber}: ${formatErrorMessage(err)}`,
{ cause: err },
);
}
}
async function rewriteSessionFile(params: {
sessionFile: string;
tmpFile: string;
headerLine: string;
removedIds: Set<string>;
entryById: Map<string, SessionEntryMeta>;
}): Promise<{ entriesAfter: number; bytesAfter: number }> {
const output = createWriteStream(params.tmpFile, {
encoding: "utf-8",
flags: "wx",
mode: 0o600,
});
const outputFinished = finished(output);
let entriesAfter = 0;
let bytesAfter = 0;
try {
await writeLine(output, params.headerLine);
bytesAfter += Buffer.byteLength(`${params.headerLine}\n`, "utf-8");
await forEachJsonlLine(params.sessionFile, async (line, lineNumber) => {
const parsed = parseJsonlLine(line, lineNumber);
if (
parsed &&
typeof parsed === "object" &&
(parsed as { type?: unknown }).type === "session"
) {
return;
}
const meta = normalizeEntryMeta(parsed);
if (!meta || params.removedIds.has(meta.id)) {
return;
}
const newParentId = resolveKeptParentId({
parentId: meta.parentId,
removedIds: params.removedIds,
entryById: params.entryById,
});
const outputLine =
newParentId === meta.parentId
? line
: JSON.stringify({ ...(parsed as SessionEntry), parentId: newParentId });
await writeLine(output, outputLine);
entriesAfter++;
bytesAfter += Buffer.byteLength(`${outputLine}\n`, "utf-8");
});
} finally {
output.end();
await outputFinished;
}
return { entriesAfter, bytesAfter };
}
export type TruncationResult = {
truncated: boolean;
entriesRemoved: number;
bytesBefore?: number;
bytesAfter?: number;
reason?: string;
};

View File

@@ -4,6 +4,7 @@ import type { MessagingToolSend } from "../pi-embedded-messaging.types.js";
export type EmbeddedPiAgentMeta = {
sessionId: string;
sessionFile?: string;
provider: string;
model: string;
contextTokens?: number;
@@ -174,6 +175,8 @@ export type EmbeddedPiCompactResult = {
tokensBefore: number;
tokensAfter?: number;
details?: unknown;
sessionId?: string;
sessionFile?: string;
};
};

View File

@@ -25,6 +25,10 @@ type HeldLock = {
releasePromise?: Promise<void>;
};
type SyncClosableFileHandle = fs.FileHandle & {
[key: symbol]: unknown;
};
export type SessionLockInspection = {
lockPath: string;
pid: number | null;
@@ -180,7 +184,7 @@ async function releaseHeldLock(
*/
function releaseAllLocksSync(): void {
for (const [sessionFile, held] of HELD_LOCKS) {
void held.handle.close().catch(() => undefined);
closeFileHandleSyncBestEffort(held.handle);
try {
fsSync.rmSync(held.lockPath, { force: true });
} catch {
@@ -193,6 +197,24 @@ function releaseAllLocksSync(): void {
}
}
function closeFileHandleSyncBestEffort(handle: fs.FileHandle): void {
const syncCloseSymbol = Object.getOwnPropertySymbols(Object.getPrototypeOf(handle)).find(
(symbol) => symbol.description === "kCloseSync",
);
if (syncCloseSymbol) {
const closeSync = (handle as SyncClosableFileHandle)[syncCloseSymbol];
if (typeof closeSync === "function") {
try {
closeSync.call(handle);
return;
} catch {
// Fall back to async close below.
}
}
}
void handle.close().catch(() => undefined);
}
async function runLockWatchdogCheck(nowMs = Date.now()): Promise<number> {
let released = 0;
for (const [sessionFile, held] of HELD_LOCKS.entries()) {

View File

@@ -67,8 +67,15 @@ describe("runMemoryFlushIfNeeded", () => {
};
if (typeof params.newSessionId === "string" && params.newSessionId) {
nextEntry.sessionId = params.newSessionId;
const storePath = typeof params.storePath === "string" ? params.storePath : rootDir;
nextEntry.sessionFile = path.join(path.dirname(storePath), `${params.newSessionId}.jsonl`);
if (typeof params.newSessionFile === "string" && params.newSessionFile) {
nextEntry.sessionFile = params.newSessionFile;
} else {
const storePath = typeof params.storePath === "string" ? params.storePath : rootDir;
nextEntry.sessionFile = path.join(
path.dirname(storePath),
`${params.newSessionId}.jsonl`,
);
}
}
params.sessionStore[sessionKey] = nextEntry;
if (typeof params.storePath === "string") {
@@ -287,6 +294,76 @@ describe("runMemoryFlushIfNeeded", () => {
);
});
it("updates the active preflight run after transcript rotation", async () => {
const sessionFile = path.join(rootDir, "session.jsonl");
const successorFile = path.join(rootDir, "session-rotated.jsonl");
await fs.writeFile(
sessionFile,
`${JSON.stringify({ message: { role: "user", content: "x".repeat(5_000) } })}\n`,
"utf8",
);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 1_000_000_000,
reserveTokensFloor: 0,
prompt: "Pre-compaction memory flush.\nNO_REPLY",
systemPrompt: "Write memory to memory/YYYY-MM-DD.md.",
relativePath: "memory/2023-11-14.md",
}));
compactEmbeddedPiSessionMock.mockResolvedValueOnce({
ok: true,
compacted: true,
result: {
tokensAfter: 42,
sessionId: "session-rotated",
sessionFile: successorFile,
},
});
const sessionEntry: SessionEntry = {
sessionId: "session",
sessionFile,
updatedAt: Date.now(),
totalTokensFresh: false,
};
const sessionStore = { "agent:main:main": sessionEntry };
const followupRun = createTestFollowupRun({
sessionId: "session",
sessionFile,
sessionKey: "agent:main:main",
});
const updateSessionId = vi.fn();
const replyOperation = {
abortSignal: new AbortController().signal,
setPhase: vi.fn(),
updateSessionId,
} as never;
const entry = await runPreflightCompactionIfNeeded({
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
followupRun,
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 100,
sessionEntry,
sessionStore,
sessionKey: "agent:main:main",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation,
});
expect(entry?.sessionId).toBe("session-rotated");
expect(entry?.sessionFile).toBe(successorFile);
expect(followupRun.run.sessionId).toBe("session-rotated");
expect(followupRun.run.sessionFile).toBe(successorFile);
expect(updateSessionId).toHaveBeenCalledWith("session-rotated");
expect(refreshQueuedFollowupSessionMock).toHaveBeenCalledWith({
key: "agent:main:main",
previousSessionId: "session",
nextSessionId: "session-rotated",
nextSessionFile: successorFile,
});
});
it("uses configured prompts and stored bootstrap warning signatures", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",

View File

@@ -506,12 +506,31 @@ export async function runPreflightCompactionIfNeeded(params: {
sessionKey: params.sessionKey,
storePath: params.storePath,
tokensAfter: result.result?.tokensAfter,
newSessionId: result.result?.sessionId,
newSessionFile: result.result?.sessionFile,
});
await appendPostCompactionRefreshPrompt({
cfg: params.cfg,
followupRun: params.followupRun,
});
entry = params.sessionStore?.[params.sessionKey] ?? entry;
if (entry) {
const previousSessionId = params.followupRun.run.sessionId;
params.followupRun.run.sessionId = entry.sessionId;
params.replyOperation.updateSessionId(entry.sessionId);
if (entry.sessionFile) {
params.followupRun.run.sessionFile = entry.sessionFile;
}
const queueKey = params.followupRun.run.sessionKey ?? params.sessionKey;
if (queueKey) {
memoryDeps.refreshQueuedFollowupSession({
key: queueKey,
previousSessionId,
nextSessionId: entry.sessionId,
nextSessionFile: entry.sessionFile,
});
}
}
return entry ?? params.sessionEntry;
}
@@ -749,6 +768,7 @@ export async function runMemoryFlushIfNeeded(params: {
.filter(Boolean)
.join("\n\n");
let postCompactionSessionId: string | undefined;
let postCompactionSessionFile: string | undefined;
try {
await memoryDeps.runWithModelFallback({
...resolveModelFallbackOptions(params.followupRun.run),
@@ -791,6 +811,9 @@ export async function runMemoryFlushIfNeeded(params: {
if (result.meta?.agentMeta?.sessionId) {
postCompactionSessionId = result.meta.agentMeta.sessionId;
}
if (result.meta?.agentMeta?.sessionFile) {
postCompactionSessionFile = result.meta.agentMeta.sessionFile;
}
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,
);
@@ -810,6 +833,7 @@ export async function runMemoryFlushIfNeeded(params: {
sessionKey: params.sessionKey,
storePath: params.storePath,
newSessionId: postCompactionSessionId,
newSessionFile: postCompactionSessionFile,
});
const updatedEntry = params.sessionKey ? activeSessionStore?.[params.sessionKey] : undefined;
if (updatedEntry) {

View File

@@ -1537,6 +1537,7 @@ export async function runReplyAgent(params: {
lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage,
contextTokensUsed,
newSessionId: runResult.meta?.agentMeta?.sessionId,
newSessionFile: runResult.meta?.agentMeta?.sessionFile,
});
const refreshedSessionEntry =
sessionKey && activeSessionStore ? activeSessionStore[sessionKey] : undefined;

View File

@@ -176,6 +176,8 @@ export const handleCompactCommand: CommandHandler = async (params) => {
storePath: params.storePath,
// Update token counts after compaction
tokensAfter: result.result?.tokensAfter,
newSessionId: result.result?.sessionId,
newSessionFile: result.result?.sessionFile,
});
}
// Use the post-compaction token count for context summary if available

View File

@@ -455,6 +455,7 @@ export function createFollowupRunner(params: {
lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage,
contextTokensUsed,
newSessionId: runResult.meta?.agentMeta?.sessionId,
newSessionFile: runResult.meta?.agentMeta?.sessionFile,
});
const refreshedSessionEntry =
sessionKey && sessionStore ? sessionStore[sessionKey] : undefined;

View File

@@ -14,6 +14,7 @@ type IncrementRunCompactionCountParams = Omit<
lastCallUsage?: NormalizedUsage;
contextTokensUsed?: number;
newSessionId?: string;
newSessionFile?: string;
};
export async function persistRunSessionUsage(params: PersistRunSessionUsageParams): Promise<void> {
@@ -38,5 +39,6 @@ export async function incrementRunCompactionCount(
amount: params.amount,
tokensAfter: tokensAfterCompaction,
newSessionId: params.newSessionId,
newSessionFile: params.newSessionFile,
});
}

View File

@@ -219,6 +219,8 @@ export async function incrementCompactionCount(params: {
tokensAfter?: number;
/** Session id after compaction, when the runtime rotated transcripts. */
newSessionId?: string;
/** Session file after compaction, when the runtime rotated transcripts. */
newSessionFile?: string;
}): Promise<number | undefined> {
const {
sessionEntry,
@@ -230,6 +232,7 @@ export async function incrementCompactionCount(params: {
amount = 1,
tokensAfter,
newSessionId,
newSessionFile,
} = params;
if (!sessionStore || !sessionKey) {
return undefined;
@@ -247,12 +250,14 @@ export async function incrementCompactionCount(params: {
};
if (newSessionId && newSessionId !== entry.sessionId) {
updates.sessionId = newSessionId;
updates.sessionFile = resolveCompactionSessionFile({
entry,
sessionKey,
storePath,
newSessionId,
});
updates.sessionFile =
newSessionFile ??
resolveCompactionSessionFile({
entry,
sessionKey,
storePath,
newSessionId,
});
}
// If tokensAfter is provided, update the cached token counts to reflect post-compaction state
if (tokensAfter != null && tokensAfter > 0) {

View File

@@ -22,10 +22,10 @@ const readPackageName = vi.fn();
const readPackageVersion = vi.fn();
const resolveGlobalManager = vi.fn();
const serviceLoaded = vi.fn();
const readGatewayServiceState = vi.fn();
const prepareRestartScript = vi.fn();
const runRestartScript = vi.fn();
const mockedRunDaemonInstall = vi.fn();
const serviceReadCommand = vi.fn();
const serviceReadRuntime = vi.fn();
const inspectPortUsage = vi.fn();
const classifyPortListener = vi.fn();
@@ -165,9 +165,27 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
});
vi.mock("../daemon/service.js", () => ({
readGatewayServiceState: (...args: unknown[]) => readGatewayServiceState(...args),
readGatewayServiceState: async () => {
const command = await serviceReadCommand();
const env = {
...process.env,
...(command && typeof command === "object" && "environment" in command
? (command.environment as NodeJS.ProcessEnv | undefined)
: undefined),
};
const [loaded, runtime] = await Promise.all([serviceLoaded({ env }), serviceReadRuntime(env)]);
return {
installed: command !== null,
loaded,
running: runtime?.status === "running",
env,
command,
runtime,
};
},
resolveGatewayService: vi.fn(() => ({
isLoaded: (...args: unknown[]) => serviceLoaded(...args),
readCommand: (...args: unknown[]) => serviceReadCommand(...args),
readRuntime: (...args: unknown[]) => serviceReadRuntime(...args),
})),
}));
@@ -453,23 +471,14 @@ describe("update-cli", () => {
readPackageVersion.mockResolvedValue("1.0.0");
resolveGlobalManager.mockResolvedValue("npm");
serviceLoaded.mockResolvedValue(false);
serviceReadCommand.mockImplementation(async () =>
(await serviceLoaded()) ? { programArguments: ["openclaw", "gateway", "run"] } : null,
);
serviceReadRuntime.mockResolvedValue({
status: "running",
pid: 4242,
state: "running",
});
readGatewayServiceState.mockImplementation(async () => {
const loaded = Boolean(await serviceLoaded());
const runtime = await serviceReadRuntime();
return {
installed: loaded,
loaded,
running: runtime?.status === "running",
env: process.env,
command: loaded ? { programArguments: ["openclaw", "gateway"] } : null,
runtime,
};
});
prepareRestartScript.mockResolvedValue("/tmp/openclaw-restart-test.sh");
runRestartScript.mockResolvedValue(undefined);
inspectPortUsage.mockResolvedValue({
@@ -556,12 +565,13 @@ describe("update-cli", () => {
expect(runDaemonRestart).not.toHaveBeenCalled();
});
it("respawns package downgrade post-update work into the updated package root", async () => {
const { entrypoints } = setupUpdatedRootRefresh({
gatewayUpdateImpl: async (root) =>
it("keeps downgrade post-update work in the current process", async () => {
const downgradedRoot = createCaseDir("openclaw-downgraded-root");
setupUpdatedRootRefresh({
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
mode: "npm",
root,
root: downgradedRoot,
before: { version: "2026.4.14" },
after: { version: "2026.4.10" },
}),
@@ -588,14 +598,13 @@ describe("update-cli", () => {
url: "ws://127.0.0.1:18789",
});
await updateCommand({ yes: true, tag: "2026.4.10" });
await updateCommand({ yes: true, tag: "2026.4.10", restart: false });
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/node/),
[entrypoints[0], "update", "--yes"],
expect.objectContaining({ stdio: "inherit" }),
);
expect(spawn).not.toHaveBeenCalled();
expect(syncPluginsForUpdateChannel).toHaveBeenCalled();
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
expect(runDaemonInstall).not.toHaveBeenCalled();
expect(probeGateway).not.toHaveBeenCalled();
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
@@ -1899,16 +1908,20 @@ describe("update-cli", () => {
});
it("fails a JSON package update when fallback restart leaves the old gateway running", async () => {
const { entrypoints } = setupUpdatedRootRefresh({
gatewayUpdateImpl: async (root) =>
const updatedRoot = createCaseDir("openclaw-updated-root");
const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
setupUpdatedRootRefresh({
entrypoints: [updatedEntrypoint],
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
mode: "npm",
root,
root: updatedRoot,
before: { version: "2026.4.23" },
after: { version: "2026.4.24" },
}),
});
prepareRestartScript.mockResolvedValue(null);
serviceLoaded.mockResolvedValue(true);
probeGateway.mockResolvedValue({
ok: true,
close: null,
@@ -1931,8 +1944,8 @@ describe("update-cli", () => {
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[expect.stringMatching(/node/), entrypoints[0], "gateway", "restart", "--json"],
expect.any(Object),
[expect.stringMatching(/node/), updatedEntrypoint, "gateway", "restart", "--json"],
expect.objectContaining({ cwd: updatedRoot, timeoutMs: 60_000 }),
);
expect(probeGateway).toHaveBeenCalledWith(expect.objectContaining({ includeDetails: true }));
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
@@ -1949,11 +1962,14 @@ describe("update-cli", () => {
});
it("fails a package update when the restarted gateway reports activated plugin load errors", async () => {
const updatedRoot = createCaseDir("openclaw-updated-root");
const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
setupUpdatedRootRefresh({
gatewayUpdateImpl: async (root) =>
entrypoints: [updatedEntrypoint],
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
mode: "npm",
root,
root: updatedRoot,
before: { version: "2026.4.23" },
after: { version: "2026.4.24" },
}),

View File

@@ -66,12 +66,14 @@ function mockNodeGatewayPlanFixture(
} = {},
) {
const {
workingDirectory = "/Users/me",
version = "22.0.0",
supported = true,
warning,
serviceEnvironment = { OPENCLAW_PORT: "3000" },
} = params;
const workingDirectory = Object.hasOwn(params, "workingDirectory")
? params.workingDirectory
: "/Users/me";
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
@@ -166,6 +168,43 @@ describe("buildGatewayInstallPlan", () => {
expect(mocks.resolvePreferredNodePath).toHaveBeenCalled();
});
it("uses the state dir as the default macOS launchd working directory", async () => {
mockNodeGatewayPlanFixture({
workingDirectory: undefined,
serviceEnvironment: {},
});
const plan = await buildGatewayInstallPlan({
env: isolatedPlanEnv(),
port: 3000,
runtime: "node",
platform: "darwin",
});
expect(plan.workingDirectory).toBe(path.join(isolatedHome, ".openclaw"));
expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith(
expect.objectContaining({
platform: "darwin",
}),
);
});
it("does not invent a working directory for non-macOS service installs", async () => {
mockNodeGatewayPlanFixture({
workingDirectory: undefined,
serviceEnvironment: {},
});
const plan = await buildGatewayInstallPlan({
env: isolatedPlanEnv(),
port: 3000,
runtime: "node",
platform: "linux",
});
expect(plan.workingDirectory).toBeUndefined();
});
it("merges safe config env while dropping unsafe values and keeping service precedence", async () => {
mockNodeGatewayPlanFixture({
serviceEnvironment: {

View File

@@ -5,6 +5,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import { collectDurableServiceEnvVars } from "../config/state-dir-dotenv.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayStateDir } from "../daemon/paths.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import {
@@ -212,6 +213,20 @@ function collectPreservedExistingServiceEnvVars(
return preserved;
}
function resolveGatewayInstallWorkingDirectory(params: {
env: Record<string, string | undefined>;
platform: NodeJS.Platform;
workingDirectory: string | undefined;
}): string | undefined {
if (params.workingDirectory) {
return params.workingDirectory;
}
if (params.platform !== "darwin") {
return undefined;
}
return resolveGatewayStateDir(params.env);
}
async function buildGatewayInstallEnvironment(params: {
env: Record<string, string | undefined>;
config?: OpenClawConfig;
@@ -261,11 +276,13 @@ export async function buildGatewayInstallPlan(params: {
existingEnvironment?: Record<string, string | undefined>;
devMode?: boolean;
nodePath?: string;
platform?: NodeJS.Platform;
warn?: DaemonInstallWarnFn;
/** Full config to extract env vars from (env vars + inline env keys). */
config?: OpenClawConfig;
authStore?: AuthProfileStore;
}): Promise<GatewayInstallPlan> {
const platform = params.platform ?? process.platform;
const { devMode, nodePath } = await resolveDaemonInstallRuntimeInputs({
env: params.env,
runtime: params.runtime,
@@ -289,16 +306,21 @@ export async function buildGatewayInstallPlan(params: {
env: params.env,
port: params.port,
launchdLabel:
process.platform === "darwin"
platform === "darwin"
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
: undefined,
platform,
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
});
// Lowest to highest: preserved custom vars, durable config, auth env refs, generated service env.
return {
programArguments,
workingDirectory,
workingDirectory: resolveGatewayInstallWorkingDirectory({
env: params.env,
platform,
workingDirectory,
}),
environment: await buildGatewayInstallEnvironment({
env: params.env,
config: params.config,

View File

@@ -1,11 +1,14 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
import {
createColdPluginConfig,
createColdPluginFixture,
createColdPluginHermeticEnv,
isColdPluginRuntimeLoaded,
} from "../plugins/test-helpers/cold-plugin-fixtures.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js";
import { buildAuthChoiceOptions, formatAuthChoiceChoicesForCli } from "./auth-choice-options.js";
import { listManifestInstalledChannelIds } from "./channel-setup/discovery.js";
import { resolveProviderCatalogPluginIdsForFilter } from "./models/list.provider-catalog.js";
@@ -13,111 +16,21 @@ import { resolveProviderCatalogPluginIdsForFilter } from "./models/list.provider
const tempDirs: string[] = [];
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-command-cold-imports-"));
tempDirs.push(dir);
return dir;
}
function hermeticEnv(
homeDir: string,
options: { disablePersistedRegistry?: boolean } = {},
): NodeJS.ProcessEnv {
return {
...process.env,
OPENCLAW_HOME: path.join(homeDir, "home"),
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY:
options.disablePersistedRegistry === false ? undefined : "1",
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
OPENCLAW_VERSION: "2026.4.25",
VITEST: "true",
};
}
function createColdControlPlanePlugin() {
const rootDir = makeTempDir();
const runtimeMarker = path.join(rootDir, "runtime-loaded.txt");
fs.writeFileSync(
path.join(rootDir, "package.json"),
JSON.stringify(
{
name: "@example/openclaw-cold-control-plane",
version: "1.0.0",
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "cold-control-plane",
name: "Cold Control Plane",
configSchema: { type: "object" },
providers: ["cold-model-provider"],
channels: ["cold-channel"],
channelConfigs: {
"cold-channel": {
schema: { type: "object" },
},
},
providerAuthChoices: [
{
provider: "cold-model-provider",
method: "api-key",
choiceId: "cold-provider-api-key",
choiceLabel: "Cold Provider API key",
groupId: "cold-model-provider",
groupLabel: "Cold Provider",
optionKey: "coldProviderApiKey",
cliFlag: "--cold-provider-api-key",
cliOption: "--cold-provider-api-key <key>",
onboardingScopes: ["text-inference"],
},
],
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf8");\nthrow new Error("runtime entry should not load for command control-plane discovery");\n`,
"utf8",
);
return { rootDir, runtimeMarker };
}
function createColdConfig(pluginDir: string): OpenClawConfig {
return {
plugins: {
load: { paths: [pluginDir] },
entries: {
"cold-control-plane": { enabled: true },
},
},
};
return makeTrackedTempDir("openclaw-command-cold-imports", tempDirs);
}
afterEach(() => {
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
cleanupTrackedTempDirs(tempDirs);
});
describe("command control-plane plugin discovery", () => {
it("resolves channel setup metadata without importing plugin runtime", () => {
const plugin = createColdControlPlanePlugin();
const plugin = createColdPluginFixture({ rootDir: makeTempDir() });
const workspaceDir = makeTempDir();
const cfg = createColdConfig(plugin.rootDir);
const env = hermeticEnv(workspaceDir);
const cfg = createColdPluginConfig(plugin.rootDir, plugin.pluginId);
const env = createColdPluginHermeticEnv(workspaceDir);
expect(
listManifestInstalledChannelIds({
@@ -125,15 +38,15 @@ describe("command control-plane plugin discovery", () => {
workspaceDir,
env,
}),
).toContain("cold-channel");
expect(fs.existsSync(plugin.runtimeMarker)).toBe(false);
).toContain(plugin.channelId);
expect(isColdPluginRuntimeLoaded(plugin)).toBe(false);
});
it("builds onboarding auth choices from manifest metadata without importing plugin runtime", () => {
const plugin = createColdControlPlanePlugin();
const plugin = createColdPluginFixture({ rootDir: makeTempDir() });
const workspaceDir = makeTempDir();
const cfg = createColdConfig(plugin.rootDir);
const env = hermeticEnv(workspaceDir);
const cfg = createColdPluginConfig(plugin.rootDir, plugin.pluginId);
const env = createColdPluginHermeticEnv(workspaceDir);
expect(
buildAuthChoiceOptions({
@@ -145,9 +58,9 @@ describe("command control-plane plugin discovery", () => {
}),
).toContainEqual(
expect.objectContaining({
value: "cold-provider-api-key",
value: plugin.authChoiceId,
label: "Cold Provider API key",
groupId: "cold-model-provider",
groupId: plugin.providerId,
}),
);
expect(
@@ -156,15 +69,15 @@ describe("command control-plane plugin discovery", () => {
workspaceDir,
env,
}).split("|"),
).toContain("cold-provider-api-key");
expect(fs.existsSync(plugin.runtimeMarker)).toBe(false);
).toContain(plugin.authChoiceId);
expect(isColdPluginRuntimeLoaded(plugin)).toBe(false);
});
it("resolves models-list provider ownership without importing plugin runtime", async () => {
const plugin = createColdControlPlanePlugin();
const plugin = createColdPluginFixture({ rootDir: makeTempDir() });
const workspaceDir = makeTempDir();
const cfg = createColdConfig(plugin.rootDir);
const env = hermeticEnv(workspaceDir, { disablePersistedRegistry: false });
const cfg = createColdPluginConfig(plugin.rootDir, plugin.pluginId);
const env = createColdPluginHermeticEnv(workspaceDir, { disablePersistedRegistry: false });
await refreshPluginRegistry({
config: cfg,
@@ -172,15 +85,15 @@ describe("command control-plane plugin discovery", () => {
env,
reason: "manual",
});
expect(fs.existsSync(plugin.runtimeMarker)).toBe(false);
expect(isColdPluginRuntimeLoaded(plugin)).toBe(false);
await expect(
resolveProviderCatalogPluginIdsForFilter({
cfg,
env,
providerFilter: "cold-model-provider",
providerFilter: plugin.providerId,
}),
).resolves.toEqual(["cold-control-plane"]);
expect(fs.existsSync(plugin.runtimeMarker)).toBe(false);
).resolves.toEqual([plugin.pluginId]);
expect(isColdPluginRuntimeLoaded(plugin)).toBe(false);
});
});

View File

@@ -67,20 +67,6 @@ describe("config compaction settings", () => {
expect(compaction?.reserveTokensFloor).toBe(9000);
});
it("defaults post-compaction transcript truncation on", () => {
const compaction = materializeCompactionConfig({});
expect(compaction?.truncateAfterCompaction).toBe(true);
});
it("preserves an explicit post-compaction transcript truncation opt-out", () => {
const compaction = materializeCompactionConfig({
truncateAfterCompaction: false,
});
expect(compaction?.truncateAfterCompaction).toBe(false);
});
it("preserves recent turn safeguard values during materialization", () => {
const compaction = materializeCompactionConfig({
mode: "safeguard",

View File

@@ -387,12 +387,7 @@ export function applyCompactionDefaults(cfg: OpenClawConfig): OpenClawConfig {
return cfg;
}
const compaction = defaults?.compaction;
const mode = compaction?.mode ?? "safeguard";
const truncateAfterCompaction = compaction?.truncateAfterCompaction ?? true;
if (
compaction?.mode === mode &&
compaction?.truncateAfterCompaction === truncateAfterCompaction
) {
if (compaction?.mode) {
return cfg;
}
@@ -404,8 +399,7 @@ export function applyCompactionDefaults(cfg: OpenClawConfig): OpenClawConfig {
...defaults,
compaction: {
...compaction,
mode,
truncateAfterCompaction,
mode: "safeguard",
},
},
},

View File

@@ -4990,9 +4990,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
truncateAfterCompaction: {
type: "boolean",
title: "Truncate After Compaction",
title: "Rotate Transcript After Compaction",
description:
"When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: true.",
"When enabled, rotates the active session JSONL file 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.",
},
notifyUser: {
type: "boolean",
@@ -26856,8 +26856,8 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["models"],
},
"agents.defaults.compaction.truncateAfterCompaction": {
label: "Truncate After Compaction",
help: "When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: true.",
label: "Rotate Transcript After Compaction",
help: "When enabled, rotates the active session JSONL file 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.",
tags: ["advanced"],
},
"agents.defaults.compaction.notifyUser": {

View File

@@ -1266,7 +1266,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.compaction.model":
"Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.",
"agents.defaults.compaction.truncateAfterCompaction":
"When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: true.",
"When enabled, rotates the active session JSONL file 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.",
"agents.defaults.compaction.notifyUser":
"When enabled, sends brief compaction notices to the user when compaction starts and when it completes (for example, '🧹 Compacting context...' and '🧹 Compaction complete'). Disabled by default to keep compaction silent and non-intrusive.",
"agents.defaults.compaction.memoryFlush":

View File

@@ -594,7 +594,7 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.compaction.postCompactionSections": "Post-Compaction Context Sections",
"agents.defaults.compaction.timeoutSeconds": "Compaction Timeout (Seconds)",
"agents.defaults.compaction.model": "Compaction Model Override",
"agents.defaults.compaction.truncateAfterCompaction": "Truncate After Compaction",
"agents.defaults.compaction.truncateAfterCompaction": "Rotate Transcript After Compaction",
"agents.defaults.compaction.notifyUser": "Compaction Notify User",
"agents.defaults.compaction.memoryFlush": "Compaction Memory Flush",
"agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled",

View File

@@ -471,9 +471,10 @@ export type AgentCompactionConfig = {
*/
provider?: string;
/**
* Truncate the session JSONL file after compaction to remove entries that
* were summarized. Prevents unbounded file growth in long-running sessions.
* Default: true.
* Rotate the active session JSONL file after compaction so the next turn
* starts from the compaction summary and unsummarized tail while the old
* transcript stays archived.
* Default: false (existing behavior preserved).
*/
truncateAfterCompaction?: boolean;
/**

View File

@@ -74,6 +74,8 @@ export async function delegateCompactionToRuntime(
tokensBefore: result.result.tokensBefore,
tokensAfter: result.result.tokensAfter,
details: result.result.details,
sessionId: result.result.sessionId,
sessionFile: result.result.sessionFile,
}
: undefined,
};

View File

@@ -22,6 +22,10 @@ export type CompactResult = {
tokensBefore: number;
tokensAfter?: number;
details?: unknown;
/** Session id after compaction, when the runtime rotated transcripts. */
sessionId?: string;
/** Session file after compaction, when the runtime rotated transcripts. */
sessionFile?: string;
};
};

View File

@@ -451,7 +451,7 @@ describe("launchd install", () => {
it("writes TMPDIR to LaunchAgent environment when provided", async () => {
const env = createDefaultLaunchdEnv();
const tmpDir = "/var/folders/xy/abc123/T/";
const tmpDir = "/Users/test/.openclaw/tmp";
await installLaunchAgent({
env,
stdout: new PassThrough(),
@@ -466,6 +466,20 @@ describe("launchd install", () => {
expect(plist).toContain(`<string>${tmpDir}</string>`);
});
it("creates the LaunchAgent TMPDIR before bootstrap", async () => {
const env = createDefaultLaunchdEnv();
const tmpDir = "/Users/test/.openclaw/tmp";
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
environment: { TMPDIR: tmpDir },
});
expect(state.dirs.has(tmpDir)).toBe(true);
expect(state.dirModes.get(tmpDir)).toBe(0o700);
});
it("writes KeepAlive=true policy with restrictive umask", async () => {
const env = createDefaultLaunchdEnv();
await installLaunchAgent({

View File

@@ -36,6 +36,7 @@ import type {
const LAUNCH_AGENT_DIR_MODE = 0o755;
const LAUNCH_AGENT_PLIST_MODE = 0o644;
const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700;
function assertValidLaunchAgentLabel(label: string): string {
const trimmed = label.trim();
@@ -209,12 +210,16 @@ async function bootstrapLaunchAgentOrThrow(params: {
throw new Error(`launchctl bootstrap failed: ${detail}`);
}
async function ensureSecureDirectory(targetPath: string): Promise<void> {
await fs.mkdir(targetPath, { recursive: true, mode: LAUNCH_AGENT_DIR_MODE });
async function ensureSecureDirectory(
targetPath: string,
dirMode = LAUNCH_AGENT_DIR_MODE,
): Promise<void> {
await fs.mkdir(targetPath, { recursive: true, mode: dirMode });
try {
const stat = await fs.stat(targetPath);
const mode = stat.mode & 0o777;
const tightenedMode = mode & ~0o022;
const forbiddenMode = dirMode === LAUNCH_AGENT_PRIVATE_DIR_MODE ? 0o077 : 0o022;
const tightenedMode = mode & ~forbiddenMode;
if (tightenedMode !== mode) {
await fs.chmod(targetPath, tightenedMode);
}
@@ -223,6 +228,15 @@ async function ensureSecureDirectory(targetPath: string): Promise<void> {
}
}
async function ensureLaunchAgentEnvironmentDirectories(
environment: Record<string, string | undefined> | undefined,
): Promise<void> {
const tmpDir = environment?.TMPDIR?.trim();
if (tmpDir) {
await ensureSecureDirectory(tmpDir, LAUNCH_AGENT_PRIVATE_DIR_MODE);
}
}
export type LaunchctlPrintInfo = {
state?: string;
pid?: number;
@@ -535,6 +549,7 @@ async function writeLaunchAgentPlist({
await ensureSecureDirectory(home);
await ensureSecureDirectory(libraryDir);
await ensureSecureDirectory(path.dirname(plistPath));
await ensureLaunchAgentEnvironmentDirectories(environment);
const serviceDescription = resolveGatewayServiceDescription({ env, environment, description });
const plist = buildLaunchAgentPlist({

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatRuntimeStatus } from "./runtime-format.js";
describe("formatRuntimeStatus", () => {
it("labels abort-shaped launchd exit statuses", () => {
expect(formatRuntimeStatus({ status: "stopped", lastExitStatus: 134 })).toContain(
"last exit 134 (SIGABRT/abort)",
);
});
});

View File

@@ -12,6 +12,20 @@ export type ServiceRuntimeLike = {
detail?: string;
};
const SIGNAL_NAMES_BY_STATUS = new Map<number, string>([
[129, "SIGHUP"],
[130, "SIGINT"],
[131, "SIGQUIT"],
[134, "SIGABRT/abort"],
[137, "SIGKILL"],
[143, "SIGTERM"],
]);
function formatLastExitStatus(status: number): string {
const signalName = SIGNAL_NAMES_BY_STATUS.get(status);
return signalName ? `last exit ${status} (${signalName})` : `last exit ${status}`;
}
export function formatRuntimeStatus(runtime: ServiceRuntimeLike | undefined): string | null {
if (!runtime) {
return null;
@@ -21,7 +35,7 @@ export function formatRuntimeStatus(runtime: ServiceRuntimeLike | undefined): st
details.push(`sub ${runtime.subState}`);
}
if (runtime.lastExitStatus !== undefined) {
details.push(`last exit ${runtime.lastExitStatus}`);
details.push(formatLastExitStatus(runtime.lastExitStatus));
}
if (runtime.lastExitReason) {
details.push(`reason ${runtime.lastExitReason}`);

View File

@@ -398,18 +398,29 @@ describe("buildServiceEnvironment", () => {
}
});
it("forwards TMPDIR from the host environment", () => {
it("forwards TMPDIR from the host environment on Linux", () => {
const env = buildServiceEnvironment({
env: { HOME: "/home/user", TMPDIR: "/var/folders/xw/abc123/T/" },
port: 18789,
platform: "linux",
});
expect(env.TMPDIR).toBe("/var/folders/xw/abc123/T/");
});
it("falls back to os.tmpdir when TMPDIR is not set", () => {
it("uses a durable state temp directory for macOS LaunchAgents", () => {
const env = buildServiceEnvironment({
env: { HOME: "/Users/user", TMPDIR: "/var/folders/xw/abc123/T/" },
port: 18789,
platform: "darwin",
});
expect(env.TMPDIR).toBe(path.join("/Users/user", ".openclaw", "tmp"));
});
it("falls back to os.tmpdir when TMPDIR is not set on Linux", () => {
const env = buildServiceEnvironment({
env: { HOME: "/home/user" },
port: 18789,
platform: "linux",
});
expect(env.TMPDIR).toBe(os.tmpdir());
});
@@ -519,16 +530,26 @@ describe("buildNodeServiceEnvironment", () => {
expect(env.no_proxy).toBe("localhost,127.0.0.1");
});
it("forwards TMPDIR for node services", () => {
it("forwards TMPDIR for node services on Linux", () => {
const env = buildNodeServiceEnvironment({
env: { HOME: "/home/user", TMPDIR: "/tmp/custom" },
platform: "linux",
});
expect(env.TMPDIR).toBe("/tmp/custom");
});
it("falls back to os.tmpdir for node services when TMPDIR is not set", () => {
it("uses a durable state temp directory for macOS node services", () => {
const env = buildNodeServiceEnvironment({
env: { HOME: "/Users/user", TMPDIR: "/var/folders/xw/abc123/T/" },
platform: "darwin",
});
expect(env.TMPDIR).toBe(path.join("/Users/user", ".openclaw", "tmp"));
});
it("falls back to os.tmpdir for node services when TMPDIR is not set on Linux", () => {
const env = buildNodeServiceEnvironment({
env: { HOME: "/home/user" },
platform: "linux",
});
expect(env.TMPDIR).toBe(os.tmpdir());
});

View File

@@ -20,6 +20,7 @@ import {
resolveNodeSystemdServiceName,
resolveNodeWindowsTaskName,
} from "./constants.js";
import { resolveGatewayStateDir } from "./paths.js";
export { isNodeVersionManagerRuntime, resolveLinuxSystemCaBundle };
@@ -360,6 +361,20 @@ function buildCommonServiceEnvironment(
return serviceEnv;
}
function resolveServiceTmpDir(
env: Record<string, string | undefined>,
platform: NodeJS.Platform,
): string {
if (platform === "darwin") {
try {
return path.join(resolveGatewayStateDir(env), "tmp");
} catch {
return env.TMPDIR?.trim() || os.tmpdir();
}
}
return env.TMPDIR?.trim() || os.tmpdir();
}
function resolveSharedServiceEnvironmentFields(
env: Record<string, string | undefined>,
platform: NodeJS.Platform,
@@ -368,8 +383,7 @@ function resolveSharedServiceEnvironmentFields(
): SharedServiceEnvironmentFields {
const stateDir = env.OPENCLAW_STATE_DIR;
const configPath = env.OPENCLAW_CONFIG_PATH;
// Keep a usable temp directory for supervised services even when the host env omits TMPDIR.
const tmpDir = env.TMPDIR?.trim() || os.tmpdir();
const tmpDir = resolveServiceTmpDir(env, platform);
const proxyEnv = readServiceProxyEnvironment(env);
// On macOS, launchd services don't inherit the shell environment, so Node's undici/fetch
// cannot locate the system CA bundle. Default to /etc/ssl/cert.pem so TLS verification

View File

@@ -1597,6 +1597,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}
entryToUpdate.updatedAt = Date.now();
entryToUpdate.compactionCount = Math.max(0, entryToUpdate.compactionCount ?? 0) + 1;
if (result.result?.sessionId && result.result.sessionId !== entryToUpdate.sessionId) {
entryToUpdate.sessionId = result.result.sessionId;
}
if (result.result?.sessionFile) {
entryToUpdate.sessionFile = result.result.sessionFile;
}
delete entryToUpdate.inputTokens;
delete entryToUpdate.outputTokens;
if (

View File

@@ -5,13 +5,8 @@ const { getMemorySearchManagerMock } = vi.hoisted(() => ({
getMemorySearchManagerMock: vi.fn(),
}));
const { resolveActiveMemoryBackendConfigMock } = vi.hoisted(() => ({
resolveActiveMemoryBackendConfigMock: vi.fn(),
}));
vi.mock("../plugins/memory-runtime.js", () => ({
getActiveMemorySearchManager: getMemorySearchManagerMock,
resolveActiveMemoryBackendConfig: resolveActiveMemoryBackendConfigMock,
}));
import { startGatewayMemoryBackend } from "./server-startup-memory.js";
@@ -30,11 +25,6 @@ function createGatewayLogMock() {
describe("startGatewayMemoryBackend", () => {
beforeEach(() => {
getMemorySearchManagerMock.mockClear();
resolveActiveMemoryBackendConfigMock.mockReset();
resolveActiveMemoryBackendConfigMock.mockImplementation(({ cfg }: { cfg: OpenClawConfig }) => ({
backend: cfg.memory?.backend === "qmd" ? "qmd" : "builtin",
qmd: cfg.memory?.backend === "qmd" ? {} : undefined,
}));
});
it("skips initialization when memory backend is not qmd", async () => {
@@ -51,8 +41,14 @@ describe("startGatewayMemoryBackend", () => {
expect(log.warn).not.toHaveBeenCalled();
});
it("initializes qmd backend for each configured agent", async () => {
const cfg = createQmdConfig({ list: [{ id: "ops", default: true }, { id: "main" }] });
it("initializes qmd backend for the default and explicitly configured agents", async () => {
const cfg = createQmdConfig({
list: [
{ id: "ops", default: true },
{ id: "main", memorySearch: { enabled: true } },
{ id: "lazy" },
],
});
const log = createGatewayLogMock();
getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
@@ -61,15 +57,41 @@ describe("startGatewayMemoryBackend", () => {
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(2);
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(1, { cfg, agentId: "ops" });
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(2, { cfg, agentId: "main" });
expect(log.info).toHaveBeenCalledTimes(1);
expect(log.info).toHaveBeenCalledWith(
'qmd memory startup initialization armed for 2 agents: "ops", "main"',
);
expect(log.info).toHaveBeenCalledWith(
'qmd memory startup initialization deferred for 1 agent: "lazy"',
);
expect(log.warn).not.toHaveBeenCalled();
});
it("initializes all qmd agents when memory search is explicitly enabled in defaults", async () => {
const cfg = createQmdConfig({
defaults: { memorySearch: { enabled: true } },
list: [{ id: "ops", default: true }, { id: "main" }],
});
const log = createGatewayLogMock();
getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
await startGatewayMemoryBackend({ cfg, log });
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(2);
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(1, { cfg, agentId: "ops" });
expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(2, { cfg, agentId: "main" });
expect(log.info).toHaveBeenCalledWith(
'qmd memory startup initialization armed for 2 agents: "ops", "main"',
);
expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("deferred"));
});
it("logs a warning when qmd manager init fails and continues with other agents", async () => {
const cfg = createQmdConfig({ list: [{ id: "main", default: true }, { id: "ops" }] });
const cfg = createQmdConfig({
list: [
{ id: "main", default: true },
{ id: "ops", memorySearch: { enabled: true } },
],
});
const log = createGatewayLogMock();
getMemorySearchManagerMock
.mockResolvedValueOnce({ manager: null, error: "qmd missing" })
@@ -105,4 +127,23 @@ describe("startGatewayMemoryBackend", () => {
);
expect(log.warn).not.toHaveBeenCalled();
});
it("does not initialize qmd managers when background work is disabled", async () => {
const cfg = {
agents: { list: [{ id: "main", default: true }] },
memory: {
backend: "qmd",
qmd: {
update: { onBoot: false, interval: "0s", embedInterval: "0s" },
},
},
} as OpenClawConfig;
const log = createGatewayLogMock();
await startGatewayMemoryBackend({ cfg, log });
expect(getMemorySearchManagerMock).not.toHaveBeenCalled();
expect(log.info).not.toHaveBeenCalled();
expect(log.warn).not.toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,39 @@
import { listAgentIds } from "../agents/agent-scope.js";
import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
getActiveMemorySearchManager,
resolveActiveMemoryBackendConfig,
} from "../plugins/memory-runtime.js";
resolveMemoryBackendConfig,
type ResolvedQmdConfig,
} from "../memory-host-sdk/host/backend-config.js";
import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js";
import { normalizeAgentId } from "../routing/session-key.js";
function shouldStartQmdBackgroundWork(qmd: ResolvedQmdConfig): boolean {
return qmd.update.onBoot || qmd.update.intervalMs > 0 || qmd.update.embedIntervalMs > 0;
}
function hasExplicitAgentMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean {
return listAgentEntries(cfg).some(
(entry) => normalizeAgentId(entry.id) === agentId && entry.memorySearch != null,
);
}
function shouldEagerlyStartAgentMemory(params: {
cfg: OpenClawConfig;
agentId: string;
agentCount: number;
}): boolean {
if (params.agentCount <= 1) {
return true;
}
if (params.agentId === resolveDefaultAgentId(params.cfg)) {
return true;
}
if (params.cfg.agents?.defaults?.memorySearch?.enabled === true) {
return true;
}
return hasExplicitAgentMemorySearchConfig(params.cfg, params.agentId);
}
export async function startGatewayMemoryBackend(params: {
cfg: OpenClawConfig;
@@ -12,17 +41,31 @@ export async function startGatewayMemoryBackend(params: {
}): Promise<void> {
const agentIds = listAgentIds(params.cfg);
const armedAgentIds: string[] = [];
const deferredAgentIds: string[] = [];
for (const agentId of agentIds) {
if (!resolveMemorySearchConfig(params.cfg, agentId)) {
continue;
}
const resolved = resolveActiveMemoryBackendConfig({ cfg: params.cfg, agentId });
const resolved = resolveMemoryBackendConfig({ cfg: params.cfg, agentId });
if (!resolved) {
continue;
}
if (resolved.backend !== "qmd" || !resolved.qmd) {
continue;
}
if (!shouldStartQmdBackgroundWork(resolved.qmd)) {
continue;
}
if (
!shouldEagerlyStartAgentMemory({
cfg: params.cfg,
agentId,
agentCount: agentIds.length,
})
) {
deferredAgentIds.push(agentId);
continue;
}
const { manager, error } = await getActiveMemorySearchManager({ cfg: params.cfg, agentId });
if (!manager) {
@@ -40,6 +83,13 @@ export async function startGatewayMemoryBackend(params: {
.join(", ")}`,
);
}
if (deferredAgentIds.length > 0) {
params.log.info?.(
`qmd memory startup initialization deferred for ${formatAgentCount(deferredAgentIds.length)}: ${deferredAgentIds
.map((agentId) => `"${agentId}"`)
.join(", ")}`,
);
}
}
function formatAgentCount(count: number): string {

View File

@@ -0,0 +1,27 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
export function resolveProviderConfigApiOwnerHint(params: {
provider: string;
config?: OpenClawConfig;
}): string | undefined {
const providers = params.config?.models?.providers;
if (!providers) {
return undefined;
}
const normalizedProvider = normalizeProviderId(params.provider);
if (!normalizedProvider) {
return undefined;
}
const providerConfig =
providers[params.provider] ??
Object.entries(providers).find(
([candidateId]) => normalizeProviderId(candidateId) === normalizedProvider,
)?.[1];
const api =
typeof providerConfig?.api === "string" ? normalizeProviderId(providerConfig.api) : "";
if (!api || api === normalizedProvider) {
return undefined;
}
return api;
}

View File

@@ -1,6 +1,7 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
@@ -164,16 +165,24 @@ export function resolveProviderRuntimePlugin(params: {
bundledProviderVitestCompat?: boolean;
installBundledRuntimeDeps?: boolean;
}): ProviderPlugin | undefined {
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider: params.provider,
config: params.config,
});
return resolveProviderPluginsForHooks({
config: params.config,
workspaceDir: params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(),
env: params.env,
providerRefs: [params.provider],
providerRefs: apiOwnerHint ? [params.provider, apiOwnerHint] : [params.provider],
applyAutoEnable: params.applyAutoEnable,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
}).find((plugin) => matchesProviderId(plugin, params.provider));
}).find(
(plugin) =>
matchesProviderId(plugin, params.provider) ||
(apiOwnerHint ? matchesProviderId(plugin, apiOwnerHint) : false),
);
}
export function resolveProviderHookPlugin(params: {

View File

@@ -1630,6 +1630,38 @@ describe("provider-runtime", () => {
);
});
it("matches provider hooks through a custom provider's native api owner", () => {
const ollamaPlugin: ProviderPlugin = {
id: "ollama",
label: "Ollama",
auth: [],
createStreamFn: vi.fn(() => vi.fn()),
};
resolvePluginProvidersMock.mockReturnValue([ollamaPlugin]);
const plugin = resolveProviderRuntimePlugin({
provider: "ollama-spark",
config: {
models: {
providers: {
"ollama-spark": {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
} as never,
});
expect(plugin).toBe(ollamaPlugin);
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect.objectContaining({
providerRefs: ["ollama-spark", "ollama"],
}),
);
});
it("merges compat contributions from owner and foreign provider plugins", () => {
resolvePluginProvidersMock.mockImplementation((params) => {
const onlyPluginIds = params.onlyPluginIds ?? [];

View File

@@ -8,6 +8,7 @@ import {
type PluginLoadOptions,
} from "./loader.js";
import { hasExplicitPluginIdScope } from "./plugin-scope.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import {
resolveActivatableProviderOwnerPluginIds,
resolveDiscoverableProviderOwnerPluginIds,
@@ -49,6 +50,33 @@ function resolveExplicitProviderOwnerPluginIds(params: {
if (plannedPluginIds.length > 0) {
return plannedPluginIds;
}
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider,
config: params.config,
});
if (apiOwnerHint) {
const apiOwnerPluginIds = resolveManifestActivationPluginIds({
trigger: {
kind: "provider",
provider: apiOwnerHint,
},
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (apiOwnerPluginIds.length > 0) {
return apiOwnerPluginIds;
}
const legacyApiOwnerPluginIds = resolveOwningPluginIdsForProvider({
provider: apiOwnerHint,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (legacyApiOwnerPluginIds?.length) {
return legacyApiOwnerPluginIds;
}
}
// Keep legacy provider/CLI-backend ownership working until every owner is
// expressible through activation descriptors.
return (

View File

@@ -804,6 +804,47 @@ describe("resolvePluginProviders", () => {
);
});
it("activates the owner plugin for custom provider refs that use a native provider api", () => {
setManifestPlugins([
createManifestProviderPlugin({
id: "ollama",
providerIds: ["ollama"],
enabledByDefault: true,
}),
]);
resolvePluginProviders({
config: {
models: {
providers: {
"ollama-spark": {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
} as OpenClawConfig,
providerRefs: ["ollama-spark"],
activate: true,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["ollama"],
activate: true,
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["ollama"],
entries: {
ollama: { enabled: true },
},
}),
}),
}),
);
});
it("uses activation.onProviders to keep explicit provider owners on the runtime path", () => {
setManifestPlugins([
createManifestProviderPlugin({

View File

@@ -1,43 +1,36 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearPluginDiscoveryCache } from "./discovery.js";
import { clearPluginManifestRegistryCache } from "./manifest-registry.js";
import { buildPluginRegistrySnapshotReport } from "./status.js";
import { buildPluginRegistrySnapshotReport, buildPluginSnapshotReport } from "./status.js";
import {
createColdPluginConfig,
createColdPluginFixture,
createColdPluginHermeticEnv,
isColdPluginRuntimeLoaded,
} from "./test-helpers/cold-plugin-fixtures.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-status-"));
tempDirs.push(dir);
return dir;
return makeTrackedTempDir("openclaw-plugin-status", tempDirs);
}
afterEach(() => {
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
cleanupTrackedTempDirs(tempDirs);
});
describe("buildPluginRegistrySnapshotReport", () => {
it("reconstructs list metadata from indexed manifests without importing plugin runtime", () => {
const pluginDir = makeTempDir();
const runtimeMarker = path.join(pluginDir, "runtime-loaded.txt");
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "@example/openclaw-indexed-demo",
version: "9.8.7",
openclaw: { extensions: ["./index.cjs"] },
}),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
const fixture = createColdPluginFixture({
rootDir: makeTempDir(),
pluginId: "indexed-demo",
packageName: "@example/openclaw-indexed-demo",
packageVersion: "9.8.7",
manifest: {
id: "indexed-demo",
name: "Indexed Demo",
description: "Manifest-backed list metadata",
@@ -49,19 +42,13 @@ describe("buildPluginRegistrySnapshotReport", () => {
additionalProperties: false,
properties: {},
},
}),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8");\nmodule.exports = { id: "indexed-demo", register() {} };\n`,
"utf-8",
);
},
});
const report = buildPluginRegistrySnapshotReport({
config: {
plugins: {
load: { paths: [pluginDir] },
load: { paths: [fixture.rootDir] },
},
},
});
@@ -75,9 +62,45 @@ describe("buildPluginRegistrySnapshotReport", () => {
format: "openclaw",
providerIds: ["indexed-provider"],
commands: ["indexed-demo"],
source: fs.realpathSync(path.join(pluginDir, "index.cjs")),
source: fs.realpathSync(fixture.runtimeSource),
status: "loaded",
});
expect(fs.existsSync(runtimeMarker)).toBe(false);
expect(isColdPluginRuntimeLoaded(fixture)).toBe(false);
});
it("builds read-only plugin status snapshots without importing plugin runtime", () => {
const fixture = createColdPluginFixture({
rootDir: makeTempDir(),
pluginId: "snapshot-demo",
manifest: {
id: "snapshot-demo",
name: "Snapshot Demo",
description: "Status metadata",
providers: ["snapshot-provider"],
},
providerId: "snapshot-provider",
runtimeMessage: "runtime entry should not load for plugin status snapshot report",
});
const workspaceDir = makeTempDir();
const report = buildPluginSnapshotReport({
config: createColdPluginConfig(fixture.rootDir, fixture.pluginId),
workspaceDir,
env: createColdPluginHermeticEnv(workspaceDir, {
bundledPluginsDir: makeTempDir(),
}),
});
expect(report.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "snapshot-demo",
name: "Snapshot Demo",
source: fs.realpathSync(fixture.runtimeSource),
status: "loaded",
imported: false,
}),
]),
);
expect(isColdPluginRuntimeLoaded(fixture)).toBe(false);
});
});

View File

@@ -0,0 +1,128 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
export type ColdPluginFixture = {
authChoiceId: string;
channelId: string;
pluginId: string;
providerId: string;
rootDir: string;
runtimeMarker: string;
runtimeSource: string;
};
type ColdPluginFixtureOptions = {
rootDir: string;
pluginId?: string;
packageName?: string;
packageVersion?: string;
providerId?: string;
channelId?: string;
authChoiceId?: string;
runtimeMessage?: string;
manifest?: Record<string, unknown>;
};
export function createColdPluginFixture(options: ColdPluginFixtureOptions): ColdPluginFixture {
const pluginId = options.pluginId ?? "cold-control-plane";
const providerId = options.providerId ?? "cold-model-provider";
const channelId = options.channelId ?? "cold-channel";
const authChoiceId = options.authChoiceId ?? "cold-provider-api-key";
const runtimeSource = path.join(options.rootDir, "index.cjs");
const runtimeMarker = path.join(options.rootDir, "runtime-loaded.txt");
fs.writeFileSync(
path.join(options.rootDir, "package.json"),
JSON.stringify(
{
name: options.packageName ?? "@example/openclaw-cold-control-plane",
version: options.packageVersion ?? "1.0.0",
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(options.rootDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
name: "Cold Control Plane",
configSchema: { type: "object" },
providers: [providerId],
channels: [channelId],
channelConfigs: {
[channelId]: {
schema: { type: "object" },
},
},
providerAuthChoices: [
{
provider: providerId,
method: "api-key",
choiceId: authChoiceId,
choiceLabel: "Cold Provider API key",
groupId: providerId,
groupLabel: "Cold Provider",
optionKey: "coldProviderApiKey",
cliFlag: "--cold-provider-api-key",
cliOption: "--cold-provider-api-key <key>",
onboardingScopes: ["text-inference"],
},
],
...options.manifest,
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
runtimeSource,
`require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf8");\nthrow new Error(${JSON.stringify(options.runtimeMessage ?? "runtime entry should not load for cold plugin metadata discovery")});\n`,
"utf8",
);
return {
authChoiceId,
channelId,
pluginId,
providerId,
rootDir: options.rootDir,
runtimeMarker,
runtimeSource,
};
}
export function createColdPluginConfig(pluginDir: string, pluginId: string): OpenClawConfig {
return {
plugins: {
load: { paths: [pluginDir] },
entries: {
[pluginId]: { enabled: true },
},
},
};
}
export function createColdPluginHermeticEnv(
homeDir: string,
options: { bundledPluginsDir?: string; disablePersistedRegistry?: boolean } = {},
): NodeJS.ProcessEnv {
return {
...process.env,
OPENCLAW_HOME: path.join(homeDir, "home"),
OPENCLAW_BUNDLED_PLUGINS_DIR: options.bundledPluginsDir,
OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY:
options.disablePersistedRegistry === false ? undefined : "1",
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
OPENCLAW_VERSION: "2026.4.25",
VITEST: "true",
};
}
export function isColdPluginRuntimeLoaded(fixture: Pick<ColdPluginFixture, "runtimeMarker">) {
return fs.existsSync(fixture.runtimeMarker);
}

View File

@@ -74,6 +74,11 @@ const {
args: string[],
cwd?: string,
listChangedPaths?: (baseRef: string, cwd: string) => string[],
options?: {
cwd?: string;
env?: NodeJS.ProcessEnv;
broad?: boolean;
},
) => string[] | null;
resolveChangedTestTargetPlan: (
changedPaths: string[],
@@ -904,7 +909,7 @@ describe("test-projects args", () => {
]);
});
it("keeps extension-facing core contract changes focused by default and supports broad opt-in", () => {
it("routes extension-facing core contract changes and supports broad extension opt-in", () => {
const changedPaths = ["src/plugin-sdk/core.ts"];
const plans = buildVitestRunPlans(["--changed=origin/main"], process.cwd(), () => changedPaths);
const targetArgs = resolveChangedTargetArgs(

View File

@@ -116,6 +116,7 @@ describe("gateway multi-instance e2e", () => {
events: chatEvents,
runId: String(runId),
sessionKey,
timeoutMs: 45_000,
});
const finalText = extractFirstTextBlock(finalEvent.message);
expect(typeof finalText).toBe("string");

View File

@@ -69,6 +69,12 @@ import { createUtilsVitestConfig } from "./vitest/vitest.utils.config.ts";
import { createWizardVitestConfig } from "./vitest/vitest.wizard.config.ts";
const EXTENSIONS_CHANNEL_GLOB = ["extensions", "channel", "**"].join("/");
const PRIVATE_PLUGIN_SDK_SUBPATHS = [
"qa-channel",
"qa-channel-protocol",
"qa-lab",
"qa-runtime",
] as const;
function bundledExcludePatternCouldMatchFile(pattern: string, file: string): boolean {
if (pattern === file) {
@@ -82,6 +88,28 @@ function bundledExcludePatternCouldMatchFile(pattern: string, file: string): boo
}
describe("resolveVitestIsolation", () => {
it("aliases private QA plugin SDK subpaths for source tests only", () => {
expect(sharedVitestConfig.resolve.alias).toEqual(
expect.arrayContaining(
PRIVATE_PLUGIN_SDK_SUBPATHS.map((subpath) =>
expect.objectContaining({
find: `openclaw/plugin-sdk/${subpath}`,
replacement: path.join(process.cwd(), "src", "plugin-sdk", `${subpath}.ts`),
}),
),
),
);
expect(sharedVitestConfig.resolve.alias).not.toEqual(
expect.arrayContaining(
PRIVATE_PLUGIN_SDK_SUBPATHS.map((subpath) =>
expect.objectContaining({
find: `@openclaw/plugin-sdk/${subpath}`,
}),
),
),
);
});
it("defaults shared scoped configs to the non-isolated runner", () => {
expect(resolveVitestIsolation({})).toBe(false);
});

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