Files
Ben Younes c805410878 fix(pi): bridge MCP tools into pi.registerTool() so the LLM can call them (#426) (#472)
* fix(pi): bridge MCP tools into pi.registerTool() so the LLM can call them (#426)

Pi 0.73.x has no native MCP support — its README is explicit:

> No MCP. Build CLI tools with READMEs (see Skills), or build an
> extension that adds MCP support.

Without a bridge inside the context-mode Pi extension, the routing
block tells the LLM to call `ctx_execute` / `ctx_search` / etc. but
those tools never enter Pi's tool list and the LLM cannot reach them.
The reporter measured 18 sessions over 2 days: ~2,500 tokens of
system-prompt overhead per window, 0 actual ctx_* calls, 447 events
recorded but never retrieved. Net ROI on Pi was negative.

This adds a stdio JSON-RPC client (`MCPStdioClient`) plus a thin
bootstrap (`bootstrapMCPTools`) that:

  - spawns `server.bundle.mjs` as a long-lived MCP child,
  - performs the standard MCP handshake (initialize →
    notifications/initialized),
  - lists tools once via `tools/list`, and
  - registers each tool through `pi.registerTool({ name, label,
    description, parameters, execute })` so the LLM sees the canonical
    bare names (matching what hooks/core/tool-naming.mjs emits for
    Pi).

Each Pi `execute()` callback forwards into the MCP child via
`tools/call`. Errors are translated to `throw` (Pi's contract for
"tool failed") so the LLM sees the MCP server's diagnostic text.

Lifecycle:

  - Bridge bootstrap is fire-and-forget at extension load — the rest
    of the extension (session capture, hooks, slash commands) is not
    blocked by spawn / handshake latency.
  - `session_shutdown` terminates the child via SIGTERM.
  - A missing `server.bundle.mjs` or any spawn / handshake error is
    surfaced once on stderr, then the extension keeps running with
    only the existing hooks + commands. Defense-in-depth so the bridge
    can never break Pi sessions for users with broken installs.

## Why a JSON Schema parameters object instead of TypeBox

MCP `tools/list` returns JSON Schema. Pi's parameter validator accepts
JSON Schema directly (TypeBox just produces JSON Schema with extra
Symbol metadata for type inference). Passing the schema through
unchanged avoids a runtime translation pass and keeps the bridge a
true thin layer over the MCP protocol — what works in Claude Code,
Gemini CLI, and the other adapters now also works in Pi.

## No new runtime dependencies

Pure `node:child_process` + `node:path`. The `@earendil-works/pi-*`
packages are NOT pulled in as build deps — `pi` is typed structurally
as `any` (matching the existing src/pi-extension.ts style) and the
bridge only touches the documented `pi.registerTool()` shape.

## Tests

Added two new `describe` blocks in `tests/pi-extension.test.ts`:

  1. `MCPStdioClient` (5 tests) — wire-protocol contract pinned with
     fake stdio servers: id-matched responses, concurrent in-flight
     requests with out-of-order delivery, child-exit cancellation,
     timeout, non-JSON noise tolerance.
  2. `bootstrapMCPTools` (2 integration tests) — spawn the real
     `start.mjs` MCP server, assert that the canonical ctx_* set
     (`ctx_execute`, `ctx_execute_file`, `ctx_search`, `ctx_index`,
     `ctx_batch_execute`, `ctx_fetch_and_index`, `ctx_doctor`,
     `ctx_stats`, `ctx_purge`) is registered, and round-trip
     `ctx_index` through `tools/call` to confirm execute() forwards
     args and returns text.

## Test plan

  - [x] `npm run build`
  - [x] `npm run typecheck` clean
  - [x] `npm test` — 73 files, 2406 pass / 25 skipped / 0 fail
  - [x] `npx vitest run tests/pi-extension.test.ts` — 44 pass
        (37 pre-existing + 7 new for the bridge)
  - [x] see-real-bug repro: `pi.registerTool` count = 0 in installed
        binary (`/home/$USER/.nvm/.../context-mode/build/pi-extension.js`)
        on `next` @ 1f70bee, plus Pi README's "No MCP" stance, plus
        the issue reporter's 18-session measurements — all three
        agree.
  - [-] Live LLM tool-call probe in Pi: blocked — free-tier Gemini
        quota was exhausted on every available key during the fix
        session. The integration test exercises the same code path
        (real MCP server + the Pi-facing registerTool surface), so
        the regression contract is enforced from CI.

## Out of scope

  - Removing the MCP server stanza from the Pi install README. Once
    this lands, the `~/.pi/agent/mcp.json` step is still harmless but
    no longer load-bearing. Cleanup left to a docs-only follow-up.
  - In-process refactor of server.ts handlers. The subprocess bridge
    is the same model used by every other adapter; a refactor that
    inlines the handlers is its own scope.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* fix(pi): address self-review findings on the MCP bridge (#426)

Three follow-up changes from the empirical self-review on PR #472:

1. **C1 HIGH — wiring not test-covered.**
   Phase A of the empirical review only failed because removing
   `src/pi-mcp-bridge.ts` produced an import error, not because the
   bug reproduced behaviorally. If a future refactor dropped the
   `bootstrapMCPTools(pi, …)` call from `src/pi-extension.ts` while
   keeping the bridge module intact, every existing bridge test
   stayed green and the bug silently re-entered.

   Fix: export `_mcpBridgeReady: Promise<void>` from
   `src/pi-extension.ts`. Bootstrap is still fire-and-forget (so
   spawn / handshake latency does not block session_start), but the
   promise gives tests a deterministic await point. Reset to a fresh
   promise on every `piExtension(pi)` call so multiple registrations
   in one process do not see a stale resolution.

   New test in `tests/pi-extension.test.ts` ("pi-extension.ts wiring
   (#426 regression guard)"): calls `registerPiExtension(api)`,
   awaits `_mcpBridgeReady`, asserts `api.registerTool.mock.calls`
   includes at least the canonical `ctx_execute` / `ctx_search` /
   `ctx_index` / `ctx_batch_execute` / `ctx_fetch_and_index` set.
   Verified red-on-revert: with the bridge module intact but the
   `bootstrapMCPTools(...)` call reverted to next, this test fails
   with `registeredNames: []`. Pre-fix it would have stayed green.

2. **C2 LOW — duplicated path resolution in the integration tests.**
   `tests/pi-extension.test.ts` had `path.dirname(...) +
   path.resolve(here, "..", "start.mjs")` recomputed in each `it()`.
   Lifted to a single `mcpEntry` const at the top of the
   `bootstrapMCPTools — registers every ctx_* tool with Pi` describe
   block, plus a shared `mcpEnv` for the `CONTEXT_MODE_DISABLE_VERSION_CHECK`
   override. One place to update if `start.mjs` ever moves.

3. **C3 LOW — dead `running` getter on MCPStdioClient.**
   Exported in the original commit but had zero callers anywhere in
   `src/` or `tests/`. Dropped — five lines, no behavioral impact.

## Test plan

- [x] `npm run build`
- [x] `npm run typecheck` — clean
- [x] `npm test` — 73/73 files, 2407 pass / 25 skipped / 0 fail
- [x] `npx vitest run tests/pi-extension.test.ts -t "MCP bridge|wiring"` — 8 pass
- [x] Phase A re-validation: revert ONLY the wiring in
      `src/pi-extension.ts` (keep `src/pi-mcp-bridge.ts` intact),
      run the wiring test → fails with `registeredNames: []`. Restore
      and the test goes green again.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor(openclaw): consolidate src/openclaw/* into src/adapters/openclaw/

Pre-fix layout split OpenClaw across two locations:
  - src/adapters/openclaw/ — config, hooks, index, session-db (standard
    adapter pattern matching every other platform)
  - src/openclaw/         — mcp-tools, workspace-router (rogue location)

The split predates the adapter pattern: workspace-router.ts was added
first by Pedro Almeida (#aa8d93c), then mcp-tools.ts by the maintainer
(#ff0a9a2 v1.0.107), while the adapter dir was bootstrapped later by
the copilot-swe-agent (#5fd6a9e). Source-of-truth for every platform
should live under src/adapters/<platform>/, so we move the two
stragglers in.

## Changes

  - git mv src/openclaw/mcp-tools.ts        → src/adapters/openclaw/mcp-tools.ts
  - git mv src/openclaw/workspace-router.ts → src/adapters/openclaw/workspace-router.ts
  - rmdir src/openclaw

  - src/openclaw-plugin.ts: 3 import-path updates
  - tests/plugins/openclaw.test.ts: 1 import-path update
  - tests/core/cli.test.ts: 1 readFileSync source-grep path update
    (the existing PR #183 path-traversal regression test reads the
    workspace-router source file directly to grep for safe-regex
    patterns; pin updated to the new location)

## Test plan

  - [x] npm run typecheck — clean
  - [x] npx vitest run tests/plugins/openclaw.test.ts tests/core/cli.test.ts
        → 225/225 pass
  - [x] npm test — 73 files, 2405+ pass / 25 skipped / 0 fail

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor: flatten src/concurrency/runPool.ts → src/runPool.ts

The src/concurrency/ directory held a single file. A whole directory
for one module is structural noise — flatten it to src/runPool.ts.

## Changes

  - git mv src/concurrency/runPool.ts → src/runPool.ts
  - rmdir src/concurrency
  - src/server.ts: import path updated
  - tests/core/server.test.ts: import path updated

## Test plan

  - [x] npm run typecheck — clean
  - [x] npx vitest run tests/core/server.test.ts -t "runPool" — pass

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor: relocate plugin entry files into src/adapters/<platform>/

Pre-fix layout had three platform plugin entry files at the src/ root:

  src/pi-extension.ts        — Pi Coding Agent extension
  src/pi-mcp-bridge.ts       — Pi MCP bridge (added in #426)
  src/openclaw-plugin.ts     — OpenClaw gateway plugin
  src/opencode-plugin.ts     — OpenCode plugin

Every other platform follows the src/adapters/<name>/ pattern (config,
hooks, index, …). The four root-level files were the last hold-outs:
inconsistent layout, plus they made adapter discovery harder for new
contributors.

## Changes (file moves)

  - git mv src/pi-extension.ts        → src/adapters/pi/extension.ts
  - git mv src/pi-mcp-bridge.ts       → src/adapters/pi/mcp-bridge.ts
  - git mv src/openclaw-plugin.ts     → src/adapters/openclaw/plugin.ts
  - git mv src/opencode-plugin.ts     → src/adapters/opencode/plugin.ts

## Internal import-path updates inside the moved files

  - ./session/db.js    → ../../session/db.js   (depth +2)
  - ./types.js         → ../../types.js
  - ./adapters/X/Y.js  → ./Y.js                (now sibling)
  - ./adapters/types.js → ../types.js          (now parent)
  - ./pi-mcp-bridge.js → ./mcp-bridge.js       (renamed + sibling)

## Runtime path-resolution updates

The plugins read sibling resources (hooks/, package.json, etc.) via
`resolve(buildDir, "..")`. After the move buildDir lives 2 dirs
deeper, so every `..` is now `../../..`:

  - resolve(buildDir, "..")                                 → resolve(buildDir, "..", "..", "..")
  - resolve(buildDir, "..", "hooks", "core", "routing.mjs")    → resolve(buildDir, "..", "..", "..", "hooks", "core", "routing.mjs")
  - (and similar for routing-block / tool-naming / auto-injection)

For opencode/plugin.ts the version-from-package.json walker prepends
`../../../package.json` to its search list (keeps the legacy
`../package.json` and `./package.json` entries as fall-backs so
unbundled or old-layout dev environments still resolve).

## Build-output paths in package.json

tsc preserves src/ structure under build/, so:

  ./build/pi-extension.js     → ./build/adapters/pi/extension.js
  ./build/openclaw-plugin.js  → ./build/adapters/openclaw/plugin.js
  ./build/opencode-plugin.js  → ./build/adapters/opencode/plugin.js

Updated:

  - package.json: pi.extensions[0], openclaw.extensions[0], main,
    exports["."], exports["./plugin"], exports["./openclaw"]
  - .pi/extensions/context-mode/index.ts: re-export delegate path
  - .openclaw-plugin/index.ts: re-export delegate path + JSDoc

## Test-side updates

  - tests/pi-extension.test.ts: dynamic-import paths updated
  - tests/opencode-plugin.test.ts: dynamic-import paths updated
  - tests/plugins/openclaw.test.ts: dynamic-import paths updated
  - tests/core/cli.test.ts: 4 dynamic-import paths + 1
    `readFileSync(src/openclaw-plugin.ts)` source-grep updated to the
    new location
  - src/adapters/detect.ts: comment-line ref updated
  - tests/adapters/detect.test.ts: comment-line ref updated

## Test plan

  - [x] npm run build                                        clean
  - [x] npm run typecheck                                    clean
  - [x] npm test                                             73 files,
        2407 pass / 25 skipped / 0 fail
  - [x] npx vitest run tests/opencode-plugin.test.ts         33/33 pass
        (regression: marker test that needed package.json walker fix)
  - [x] npx vitest run tests/plugins/openclaw.test.ts        225/225 pass
  - [x] npx vitest run tests/pi-extension.test.ts            45/45 pass
        (incl. the wiring guard added in the previous commit)
  - [x] npx vitest run tests/core/cli.test.ts -t "openclaw-plugin.ts doctor/upgrade"
        passes against the new src/adapters/openclaw/plugin.ts location
  - [x] Manual sanity: every old root-level path (build/pi-extension.js,
        src/opencode-plugin.ts, etc.) is gone from the repo — grep
        confirms zero stale refs in src/ + tests/ + package.json + the
        .pi/.openclaw-plugin/ thin wrappers.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* fix(ci): update E2E + install scripts for relocated openclaw plugin path

The structural refactor in 4911c07 (src/openclaw-plugin.ts → src/adapters/
openclaw/plugin.ts) moved the build output from build/openclaw-plugin.js
to build/adapters/openclaw/plugin.js. Three scripts still pointed at the
legacy path and broke on next-CI.

## OpenClaw E2E (failing on ubuntu-latest + macos-latest)

  scripts/test-openclaw-e2e.sh:34
    join(process.cwd(), "build", "openclaw-plugin.js")

The Phase 1 plugin-load check failed at "❌ build/openclaw-plugin.js
exists" → exit 1. Updated to look for the new path first, fall back to
the legacy one for transition safety:

  build/adapters/openclaw/plugin.js → fall back → build/openclaw-plugin.js

Loaded-tag tracks which path actually resolved.

## OpenClaw global install (would have broken at user-install time)

  scripts/install-openclaw-plugin.sh:49 (auto-generated index.ts stub)

Updated the absolute re-export path written into the generated stub
plus the jiti cache-clear glob (now matches both
`build-adapters-openclaw-plugin.*.cjs` and the legacy
`build-openclaw-plugin.*.cjs` filenames).

## Bonus: security.js path was wrong post-refactor

The opencode + openclaw plugins called `routing.initSecurity(buildDir)`
where buildDir = build/adapters/<platform>/. That made initSecurity look
for build/adapters/<platform>/security.js — which never exists. The
security module lives at build/security.js (top-level). The fix-open
fallback meant tests still passed but every plugin load emitted a
spurious WARNING about deny-policy enforcement being off.

  - opencode/plugin.ts: pass `resolve(buildDir, "..", "..")` (= build/)
  - openclaw/plugin.ts: same

Verified locally: `bash scripts/test-openclaw-e2e.sh` → 39/39 pass, no
security warning.

## Test plan

  - [x] npm run build              clean
  - [x] npm run typecheck          clean
  - [x] npm test                   73 files, 2405+ pass / 25 skipped /
        0 fail (2 pre-existing flake worker-pool timeouts on
        kiro-hooks + insight-cors; both pass when run in isolation)
  - [x] bash scripts/test-openclaw-e2e.sh
        → "Results: 39 passed  0 warned  0 failed"  +  "✅ E2E test PASSED"

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* docs(openclaw): update Key Files paths after src/adapters/<platform>/ refactor

Independent PR review on #472 caught 3 stale path strings in
`docs/adapters/openclaw.md` that the structural refactor (4911c07)
missed:

  - src/openclaw-plugin.ts          → src/adapters/openclaw/plugin.ts
  - src/openclaw/workspace-router.ts → src/adapters/openclaw/workspace-router.ts (×2)

Doc-only — no code paths reference these strings.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

---------

Co-authored-by: Ora Studio <noreply@oratelecom.net>
2026-05-08 02:30:46 +03:00

350 lines
13 KiB
Bash

#!/usr/bin/env bash
# End-to-end synthetic smoke test for the OpenClaw plugin.
# Loads the real built plugin, fires all hooks in sequence with a recording
# API proxy, and queries SQLite to verify DB writes. No real gateway needed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_FILE="${RUNNER_TEMP:-/tmp}/e2e-output.log"
cd "$REPO_ROOT"
echo "=== context-mode OpenClaw E2E Synthetic Test ===" | tee "$LOG_FILE"
echo "Repo: $REPO_ROOT" | tee -a "$LOG_FILE"
echo "Node: $(node --version)" | tee -a "$LOG_FILE"
echo "Version: $(node -e "console.log(require('./package.json').version)" 2>/dev/null)" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
node --experimental-vm-modules --no-warnings --input-type=module 2>&1 <<'HARNESS_EOF' | tee -a "$LOG_FILE"
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { existsSync, readdirSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
let passed = 0;
let failed = 0;
let warned = 0;
function pass(label) { console.log(` ✅ ${label}`); passed++; }
function fail(label, detail) { console.error(` ❌ ${label}${detail ? ': ' + detail : ''}`); failed++; }
function warn(label) { console.log(` ⚠️ ${label}`); warned++; }
function section(label) { console.log(`\n--- ${label} ---`); }
// ── 0. Load plugin ────────────────────────────────────────
section("Phase 1: Plugin load");
// Plugin entry was moved from build/openclaw-plugin.js to
// build/adapters/openclaw/plugin.js when the OpenClaw entry file was
// relocated under src/adapters/openclaw/. The legacy path is kept as a
// fall-back so older checked-out CI configs do not break across the
// transition.
const pluginPath = join(process.cwd(), "build", "adapters", "openclaw", "plugin.js");
const legacyPluginPath = join(process.cwd(), "build", "openclaw-plugin.js");
const resolvedPluginPath = existsSync(pluginPath)
? pluginPath
: existsSync(legacyPluginPath)
? legacyPluginPath
: null;
if (!resolvedPluginPath) { fail("build/adapters/openclaw/plugin.js exists"); process.exit(1); }
let plugin;
try {
const mod = await import(resolvedPluginPath);
plugin = mod.default;
pass(`${resolvedPluginPath.includes("adapters") ? "build/adapters/openclaw/plugin.js" : "build/openclaw-plugin.js"} loaded`);
} catch (err) {
fail("plugin load", err.message);
process.exit(1);
}
// ── 1. Plugin metadata ────────────────────────────────────
section("Phase 2: Plugin metadata");
[
["id === 'context-mode'", plugin.id === "context-mode"],
["name defined", !!plugin.name],
["configSchema is object", typeof plugin.configSchema === "object"],
["register is function", typeof plugin.register === "function"],
].forEach(([l, ok]) => ok ? pass(l) : fail(l));
// ── 2. Build recording API proxy ──────────────────────────
const hooks = new Map();
const lifecycle = new Map();
const commands = new Map();
let contextEngineId = null;
const api = {
registerHook(event, handler, meta) {
if (!hooks.has(event)) hooks.set(event, []);
hooks.get(event).push({ handler, meta });
},
on(event, handler, opts) {
if (!lifecycle.has(event)) lifecycle.set(event, []);
lifecycle.get(event).push({ handler, priority: opts?.priority ?? 0 });
},
registerContextEngine(id) { contextEngineId = id; },
registerCommand(cmd) { commands.set(cmd.name, cmd); },
logger: {
info: (...a) => {},
error: (...a) => console.error("[plugin:error]", ...a),
debug: (...a) => {},
warn: (...a) => {},
},
};
// ── 3. register() ─────────────────────────────────────────
section("Phase 3: register()");
try {
plugin.register(api);
pass("register() completed synchronously without throwing");
} catch (err) {
fail("register()", err.message);
process.exit(1);
}
// ── 4. Hook registration ──────────────────────────────────
section("Phase 4: Hook registration");
const expectedLifecycle = [
"before_tool_call", "after_tool_call",
"session_start", "before_compaction", "after_compaction",
"before_model_resolve", "before_prompt_build",
];
expectedLifecycle.forEach(name => {
lifecycle.has(name) && lifecycle.get(name).length > 0
? pass(`api.on("${name}") registered`)
: fail(`api.on("${name}") registered`);
});
["command:new", "command:stop"].forEach(name => {
hooks.has(name) && hooks.get(name).length > 0
? pass(`api.registerHook("${name}") registered`)
: fail(`api.registerHook("${name}") registered`);
});
const promptHandlers = lifecycle.get("before_prompt_build") || [];
promptHandlers.length >= 2
? pass(`before_prompt_build has ${promptHandlers.length} handlers (resume p=10 + routing p=5)`)
: warn(`before_prompt_build has only ${promptHandlers.length} handler(s) — expected 2`);
contextEngineId === "context-mode"
? pass("registerContextEngine('context-mode') called")
: fail("registerContextEngine('context-mode') called", `got: ${contextEngineId}`);
["/ctx-stats", "/ctx-doctor", "/ctx-upgrade"].forEach(name => {
commands.has(name.slice(1))
? pass(`${name} command registered`)
: fail(`${name} command registered`);
});
// ── 5. Hook execution sequence ────────────────────────────
section("Phase 5: Hook execution (full lifecycle)");
async function fireLifecycle(event, payload) {
const hs = lifecycle.get(event) || [];
const results = [];
for (const { handler } of hs) results.push(await handler(payload));
return results;
}
async function fireHook(event) {
const hs = hooks.get(event) || [];
for (const { handler } of hs) await handler();
}
// 5a. command:new — session init
await fireHook("command:new");
pass("command:new fired");
// 5b. session_start — re-key session
const testSessionId = randomUUID();
const testSessionKey = `e2e-agent:test:${Date.now()}`;
await fireLifecycle("session_start", {
sessionId: testSessionId,
sessionKey: testSessionKey,
startedAt: new Date().toISOString(),
});
pass("session_start fired (sessionKey provided)");
// Allow initPromise to resolve
await new Promise(r => setTimeout(r, 600));
// 5c. before_tool_call — read tool (should passthrough)
const btcResult = await (async () => {
const hs = lifecycle.get("before_tool_call") || [];
for (const { handler } of hs) return await handler({
toolName: "read",
params: { file_path: "/tmp/e2e-test.txt" },
runId: randomUUID(),
toolCallId: randomUUID(),
});
})();
pass(`before_tool_call fired (read) → ${btcResult == null ? "passthrough" : JSON.stringify(btcResult)}`);
// 5d. after_tool_call — read
await fireLifecycle("after_tool_call", {
toolName: "read",
params: { file_path: "/tmp/e2e-test.txt" },
result: "line1\nline2\nline3",
runId: randomUUID(), toolCallId: randomUUID(), durationMs: 12,
});
pass("after_tool_call fired (read)");
// 5e. after_tool_call — exec/bash
await fireLifecycle("after_tool_call", {
toolName: "exec",
params: { command: "ls -la" },
result: "total 8\ndrwxr-xr-x 2 pedro pedro",
runId: randomUUID(), toolCallId: randomUUID(), durationMs: 45,
});
pass("after_tool_call fired (exec)");
// 5f. after_tool_call — write
await fireLifecycle("after_tool_call", {
toolName: "write",
params: { file_path: "/tmp/e2e-out.txt", content: "test" },
result: "ok",
runId: randomUUID(), toolCallId: randomUUID(), durationMs: 8,
});
pass("after_tool_call fired (write)");
// 5g. after_tool_call — error case
await fireLifecycle("after_tool_call", {
toolName: "exec",
params: { command: "false" },
error: "exit code 1",
isError: true,
runId: randomUUID(), toolCallId: randomUUID(), durationMs: 5,
});
pass("after_tool_call fired (error case)");
// 5h. before_model_resolve
await fireLifecycle("before_model_resolve", {
userMessage: "Please read the config and summarize the recent changes",
});
pass("before_model_resolve fired");
// 5i. before_compaction
await fireLifecycle("before_compaction", {});
pass("before_compaction fired");
// 5j. after_compaction
await fireLifecycle("after_compaction", {});
pass("after_compaction fired");
// 5k. before_prompt_build — both handlers
const promptResults = await fireLifecycle("before_prompt_build", {});
pass(`before_prompt_build fired (${promptResults.length} handlers)`);
// ── 6. SQLite DB verification ─────────────────────────────
section("Phase 6: SQLite DB verification");
await new Promise(r => setTimeout(r, 300));
const sessionDir = join(homedir(), ".openclaw", "context-mode", "sessions");
if (!existsSync(sessionDir)) {
fail("session directory exists", sessionDir);
process.exit(1);
}
pass(`session directory exists: ${sessionDir}`);
const dbFiles = readdirSync(sessionDir).filter(f => f.endsWith(".db"));
if (dbFiles.length === 0) { fail("session DB file(s) created"); process.exit(1); }
pass(`${dbFiles.length} session DB file(s) found`);
const Database = (await import("better-sqlite3")).default;
// Use the most recently modified DB
// Find DB with our session or most recent
const { statSync } = await import("node:fs");
let chosenDb;
for (const f of dbFiles.map(f => join(sessionDir, f)).reverse()) {
try {
const d = new Database(f, { readonly: true });
const row = d.prepare("SELECT 1 FROM session_meta WHERE session_id = ? OR session_id LIKE ?")
.get(testSessionId, testSessionId.slice(0,8) + "%");
d.close();
if (row) { chosenDb = f; break; }
} catch {}
}
// Fallback: most recently modified
if (!chosenDb) {
chosenDb = dbFiles.map(f => join(sessionDir, f))
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
warn(`exact session not found — using most recent DB: ${chosenDb.split('/').pop()}`);
}
const db = new Database(chosenDb, { readonly: true });
// session_meta
const allMeta = db.prepare("SELECT session_id, event_count FROM session_meta").all();
allMeta.length > 0
? pass(`session_meta has ${allMeta.length} session(s)`)
: fail("session_meta populated");
// session_events
const eventCount = db.prepare("SELECT COUNT(*) as cnt FROM session_events").get();
eventCount.cnt >= 3
? pass(`session_events has ${eventCount.cnt} event(s) (≥3 expected)`)
: fail(`session_events populated`, `only ${eventCount.cnt} events`);
// event type breakdown
const types = db.prepare("SELECT type, COUNT(*) as cnt FROM session_events GROUP BY type ORDER BY cnt DESC").all();
console.log(` event types: ${types.map(t => `${t.type}(${t.cnt})`).join(", ")}`);
// session_resume (compaction)
const resume = db.prepare("SELECT COUNT(*) as cnt FROM session_resume").get();
resume.cnt > 0
? pass(`session_resume has ${resume.cnt} snapshot(s) — before_compaction fired correctly`)
: warn("session_resume is empty — compaction hook may not have produced a snapshot yet");
// openclaw_session_map
const mapRow = db.prepare("SELECT * FROM openclaw_session_map WHERE session_key = ?").get(testSessionKey);
mapRow
? pass(`openclaw_session_map entry for key '${testSessionKey.slice(0,20)}...' → ${mapRow.session_id.slice(0,8)}`)
: warn(`openclaw_session_map entry not found for test key — session re-keying may differ`);
db.close();
// ── 7. Command handlers ───────────────────────────────────
section("Phase 7: Command handlers");
const statsCmd = commands.get("ctx-stats");
if (statsCmd) {
try {
const result = statsCmd.handler({});
const text = typeof result === "object" ? (result.text ?? JSON.stringify(result)) : String(result ?? "");
text.length > 10
? pass(`/ctx-stats returns output (${text.length} chars)`)
: fail("/ctx-stats returns non-empty output");
} catch (e) {
fail("/ctx-stats handler throws", e.message);
}
} else {
fail("/ctx-stats handler callable");
}
// ── 8. command:stop ────────────────────────────────────────
section("Phase 8: Cleanup");
try {
await fireHook("command:stop");
pass("command:stop fired without throwing");
} catch (e) {
fail("command:stop", e.message);
}
// ── Summary ────────────────────────────────────────────────
console.log("\n══════════════════════════════════════════");
console.log(`Results: ${passed} passed ${warned} warned ${failed} failed`);
console.log("══════════════════════════════════════════");
if (failed > 0) {
console.error(`\n❌ E2E test FAILED (${failed} failure(s))`);
process.exit(1);
} else {
console.log(`\n✅ E2E test PASSED`);
}
HARNESS_EOF
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "E2E test failed (exit $EXIT_CODE)" >> "$LOG_FILE"
exit $EXIT_CODE
fi