brane 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +116 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +332 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1094 -0
- package/dist/commands/token.js +153 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6349 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +633 -0
- package/dist/db/tokens.js +583 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +82 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +197 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +49 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +262 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +85 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +137 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +115 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +581 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +53 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +48 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createTestDb } from "./helpers.js";
|
|
4
|
+
import { adoptBindTokens } from "../db/tokens.js";
|
|
5
|
+
import { appendEvent } from "../db/events.js";
|
|
6
|
+
import { recordToolUse, readToolUseWindow, readToolUsesSince, maxToolUseRev, settleDaemonCall, readToolUse, LIST_EXCERPT_BYTES, prepareCaptureText, contentDedupKey, excerptForPush, shapeToolUsesForPush, toolUseCursor, toolUseFromClaudeHook, normalizeHookPayload, } from "../db/tool-uses.js";
|
|
7
|
+
import { verifyNarrativeBind, numericConfig, fitToolUses, TOOL_USES_MAX_BYTES } from "../daemon/server.js";
|
|
8
|
+
function seedNarrative(db, id = "claude-test0001") {
|
|
9
|
+
db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at) VALUES (?, 'brane-test', 'claude', 'worker', 'active', '/tmp', ?)").run(id, Date.now());
|
|
10
|
+
return id;
|
|
11
|
+
}
|
|
12
|
+
describe("recordToolUse", () => {
|
|
13
|
+
it("anchors after_seq to the narrative's current head seq", () => {
|
|
14
|
+
const db = createTestDb();
|
|
15
|
+
const nid = seedNarrative(db);
|
|
16
|
+
appendEvent(db, nid, "narration", { content: "one" });
|
|
17
|
+
appendEvent(db, nid, "narration", { content: "two" });
|
|
18
|
+
const row = recordToolUse(db, nid, { name: "Bash", status: "completed", source: "hook" });
|
|
19
|
+
assert.equal(row.after_seq, 2);
|
|
20
|
+
assert.equal(row.rev, 1);
|
|
21
|
+
assert.equal(row.status, "completed");
|
|
22
|
+
});
|
|
23
|
+
it("allocates monotonic rev per narrative and bumps it on every write", () => {
|
|
24
|
+
const db = createTestDb();
|
|
25
|
+
const nid = seedNarrative(db);
|
|
26
|
+
const a = recordToolUse(db, nid, { name: "Read", status: "completed", source: "hook", dedupKey: "a" });
|
|
27
|
+
const b = recordToolUse(db, nid, { name: "Edit", status: "completed", source: "hook", dedupKey: "b" });
|
|
28
|
+
assert.equal(a.rev, 1);
|
|
29
|
+
assert.equal(b.rev, 2);
|
|
30
|
+
// Upsert of an existing row must bump rev — rev is the sync cursor, and a
|
|
31
|
+
// mutation that kept its old rev would be invisible to a resuming client.
|
|
32
|
+
const a2 = recordToolUse(db, nid, { name: "Read", status: "failed", source: "hook", dedupKey: "a" });
|
|
33
|
+
assert.equal(a2.rev, 3);
|
|
34
|
+
assert.equal(a2.status, "failed");
|
|
35
|
+
assert.equal(maxToolUseRev(db, nid), 3);
|
|
36
|
+
});
|
|
37
|
+
it("preserves after_seq and started_at on upsert — a row's position never moves", () => {
|
|
38
|
+
const db = createTestDb();
|
|
39
|
+
const nid = seedNarrative(db);
|
|
40
|
+
appendEvent(db, nid, "narration", { content: "one" });
|
|
41
|
+
const first = recordToolUse(db, nid, {
|
|
42
|
+
name: "Bash", status: "completed", source: "hook", dedupKey: "x", startedAt: 1000,
|
|
43
|
+
});
|
|
44
|
+
appendEvent(db, nid, "narration", { content: "two" });
|
|
45
|
+
const second = recordToolUse(db, nid, {
|
|
46
|
+
name: "Bash", status: "failed", source: "session_log", dedupKey: "x", startedAt: 9999,
|
|
47
|
+
});
|
|
48
|
+
assert.equal(first.after_seq, 1);
|
|
49
|
+
assert.equal(second.after_seq, 1, "after_seq must not move on upsert");
|
|
50
|
+
assert.equal(second.started_at, 1000, "started_at must not move on upsert");
|
|
51
|
+
assert.equal(second.status, "failed");
|
|
52
|
+
assert.equal(second.source, "session_log");
|
|
53
|
+
});
|
|
54
|
+
it("collapses live-hook and backfill delivery of the same call to one row", () => {
|
|
55
|
+
const db = createTestDb();
|
|
56
|
+
const nid = seedNarrative(db);
|
|
57
|
+
recordToolUse(db, nid, { name: "Bash", status: "completed", source: "hook", dedupKey: "toolu_01" });
|
|
58
|
+
recordToolUse(db, nid, { name: "Bash", status: "completed", source: "session_log", dedupKey: "toolu_01" });
|
|
59
|
+
const rows = readToolUseWindow(db, nid);
|
|
60
|
+
assert.equal(rows.length, 1);
|
|
61
|
+
});
|
|
62
|
+
it("falls back to a content dedup key when the vendor supplies no id", () => {
|
|
63
|
+
const db = createTestDb();
|
|
64
|
+
const nid = seedNarrative(db);
|
|
65
|
+
const row = recordToolUse(db, nid, {
|
|
66
|
+
name: "Bash", input: "ls", status: "completed", source: "hook", startedAt: 1234,
|
|
67
|
+
});
|
|
68
|
+
assert.equal(row.dedup_key, contentDedupKey(nid, 1234, "Bash", prepareCaptureText("ls")));
|
|
69
|
+
});
|
|
70
|
+
it("keeps narratives' rev spaces independent", () => {
|
|
71
|
+
const db = createTestDb();
|
|
72
|
+
const a = seedNarrative(db, "claude-narra");
|
|
73
|
+
const b = seedNarrative(db, "claude-narrb");
|
|
74
|
+
recordToolUse(db, a, { name: "Read", status: "completed", source: "hook" });
|
|
75
|
+
const rowB = recordToolUse(db, b, { name: "Read", status: "completed", source: "hook" });
|
|
76
|
+
assert.equal(rowB.rev, 1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe("readToolUseWindow / readToolUsesSince", () => {
|
|
80
|
+
it("filters by since_rev and orders by display key", () => {
|
|
81
|
+
const db = createTestDb();
|
|
82
|
+
const nid = seedNarrative(db);
|
|
83
|
+
appendEvent(db, nid, "narration", { content: "one" });
|
|
84
|
+
const r1 = recordToolUse(db, nid, { name: "Read", status: "completed", source: "hook", dedupKey: "1", startedAt: 10 });
|
|
85
|
+
const r2 = recordToolUse(db, nid, { name: "Edit", status: "completed", source: "hook", dedupKey: "2", startedAt: 20 });
|
|
86
|
+
appendEvent(db, nid, "narration", { content: "two" });
|
|
87
|
+
recordToolUse(db, nid, { name: "Bash", status: "completed", source: "hook", dedupKey: "3", startedAt: 30 });
|
|
88
|
+
const all = readToolUseWindow(db, nid);
|
|
89
|
+
assert.deepEqual(all.map((r) => r.dedup_key), ["1", "2", "3"]);
|
|
90
|
+
assert.deepEqual(all.map((r) => r.after_seq), [1, 1, 2]);
|
|
91
|
+
const since = readToolUsesSince(db, nid, { sinceRev: r2.rev });
|
|
92
|
+
assert.deepEqual(since.map((r) => r.dedup_key), ["3"]);
|
|
93
|
+
assert.ok(since.every((r) => r.rev > r2.rev));
|
|
94
|
+
assert.equal(r1.rev, 1);
|
|
95
|
+
});
|
|
96
|
+
it("returns a mutated row again after its rev bump (catch-up sees updates)", () => {
|
|
97
|
+
const db = createTestDb();
|
|
98
|
+
const nid = seedNarrative(db);
|
|
99
|
+
const first = recordToolUse(db, nid, { name: "Bash", status: "completed", source: "hook", dedupKey: "x" });
|
|
100
|
+
recordToolUse(db, nid, { name: "Bash", status: "failed", source: "hook", dedupKey: "x" });
|
|
101
|
+
const caughtUp = readToolUsesSince(db, nid, { sinceRev: first.rev });
|
|
102
|
+
assert.equal(caughtUp.length, 1);
|
|
103
|
+
assert.equal(caughtUp[0].status, "failed");
|
|
104
|
+
});
|
|
105
|
+
// Paging back must fetch only the newly exposed band. Without the upper
|
|
106
|
+
// bound each page-back re-downloaded the suffix it already held, so transfer
|
|
107
|
+
// grew quadratically in the number of pages (claude-8fkbabsx:9, finding 1).
|
|
108
|
+
it("windows half-open [gte, lt) so paging back never re-fetches the suffix", () => {
|
|
109
|
+
const db = createTestDb();
|
|
110
|
+
const nid = seedNarrative(db);
|
|
111
|
+
for (let i = 1; i <= 4; i++) {
|
|
112
|
+
appendEvent(db, nid, "narration", { content: `e${i}` });
|
|
113
|
+
recordToolUse(db, nid, { name: "Bash", status: "completed", source: "hook", dedupKey: `t${i}` });
|
|
114
|
+
}
|
|
115
|
+
const page = readToolUseWindow(db, nid, { afterSeqGte: 3 });
|
|
116
|
+
assert.deepEqual(page.map((r) => r.dedup_key), ["t3", "t4"]);
|
|
117
|
+
const band = readToolUseWindow(db, nid, { afterSeqGte: 1, afterSeqLt: 3 });
|
|
118
|
+
assert.deepEqual(band.map((r) => r.dedup_key), ["t1", "t2"], "upper bound is exclusive");
|
|
119
|
+
// The two together are the whole history, each row exactly once.
|
|
120
|
+
assert.deepEqual([...band, ...page].map((r) => r.dedup_key), ["t1", "t2", "t3", "t4"]);
|
|
121
|
+
});
|
|
122
|
+
it("pages past a limit with no gap and no repeat, even when rows share an anchor", () => {
|
|
123
|
+
const db = createTestDb();
|
|
124
|
+
const nid = seedNarrative(db);
|
|
125
|
+
appendEvent(db, nid, "narration", { content: "one" });
|
|
126
|
+
// A burst: five rows, one anchor, identical started_at. after_seq alone
|
|
127
|
+
// could not order or resume these — the cursor carries the full sort key.
|
|
128
|
+
for (let i = 0; i < 5; i++) {
|
|
129
|
+
recordToolUse(db, nid, {
|
|
130
|
+
name: "Bash", status: "completed", source: "hook", dedupKey: `k${i}`, startedAt: 500,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const seen = [];
|
|
134
|
+
let cursor = undefined;
|
|
135
|
+
for (let page = 0; page < 10; page++) {
|
|
136
|
+
const rows = readToolUseWindow(db, nid, {
|
|
137
|
+
limit: 2,
|
|
138
|
+
after: cursor ?? undefined,
|
|
139
|
+
});
|
|
140
|
+
seen.push(...rows.map((r) => r.dedup_key));
|
|
141
|
+
if (rows.length < 2)
|
|
142
|
+
break;
|
|
143
|
+
cursor = toolUseCursor(rows);
|
|
144
|
+
}
|
|
145
|
+
assert.deepEqual(seen, ["k0", "k1", "k2", "k3", "k4"]);
|
|
146
|
+
});
|
|
147
|
+
it("caps the page at limit and hands back a cursor that resumes exactly there", () => {
|
|
148
|
+
const db = createTestDb();
|
|
149
|
+
const nid = seedNarrative(db);
|
|
150
|
+
appendEvent(db, nid, "narration", { content: "one" });
|
|
151
|
+
for (let i = 0; i < 3; i++) {
|
|
152
|
+
recordToolUse(db, nid, {
|
|
153
|
+
name: "Read", status: "completed", source: "hook", dedupKey: `k${i}`, startedAt: 100 + i,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const first = readToolUseWindow(db, nid, { limit: 2 });
|
|
157
|
+
assert.equal(first.length, 2);
|
|
158
|
+
const rest = readToolUseWindow(db, nid, { after: toolUseCursor(first) });
|
|
159
|
+
assert.deepEqual(rest.map((r) => r.dedup_key), ["k2"]);
|
|
160
|
+
assert.equal(toolUseCursor([]), null);
|
|
161
|
+
});
|
|
162
|
+
// The reason the two reads are separate functions (claude-8fkbabsx:14). A
|
|
163
|
+
// client's watermark is the highest rev it has applied, so a truncated
|
|
164
|
+
// catch-up page must never contain a high rev while lower revs are left
|
|
165
|
+
// behind — that hole would be skipped forever. Ordering by rev is what makes
|
|
166
|
+
// "highest received" and "highest contiguous" the same number.
|
|
167
|
+
it("truncates the catch-up read in rev order, so a partial page can't skip a rev", () => {
|
|
168
|
+
const db = createTestDb();
|
|
169
|
+
const nid = seedNarrative(db);
|
|
170
|
+
// Later revs deliberately anchored *earlier* in display order, which is
|
|
171
|
+
// exactly the arrangement display-ordered truncation gets wrong: it would
|
|
172
|
+
// return the newest rev and drop the ones before it.
|
|
173
|
+
appendEvent(db, nid, "narration", { content: "one" });
|
|
174
|
+
recordToolUse(db, nid, { name: "A", status: "completed", source: "hook", dedupKey: "late", startedAt: 1 });
|
|
175
|
+
appendEvent(db, nid, "narration", { content: "two" });
|
|
176
|
+
recordToolUse(db, nid, { name: "B", status: "completed", source: "hook", dedupKey: "mid", startedAt: 2 });
|
|
177
|
+
recordToolUse(db, nid, { name: "C", status: "completed", source: "hook", dedupKey: "early", startedAt: 3 });
|
|
178
|
+
// Mutate the oldest row so its rev jumps to the top of the rev space
|
|
179
|
+
// while its display position stays first.
|
|
180
|
+
recordToolUse(db, nid, { name: "A", status: "failed", source: "hook", dedupKey: "late" });
|
|
181
|
+
const page = readToolUsesSince(db, nid, { sinceRev: 0, limit: 2 });
|
|
182
|
+
assert.equal(page.length, 2);
|
|
183
|
+
const revs = page.map((r) => r.rev);
|
|
184
|
+
assert.deepEqual(revs, [...revs].sort((a, b) => a - b), "catch-up pages come back in rev order");
|
|
185
|
+
// The watermark a client would keep, and the rows it would then be owed.
|
|
186
|
+
const watermark = page[page.length - 1].rev;
|
|
187
|
+
const next = readToolUsesSince(db, nid, { sinceRev: watermark });
|
|
188
|
+
const everySeen = [...page, ...next].map((r) => r.dedup_key).sort();
|
|
189
|
+
assert.deepEqual(everySeen, ["early", "late", "mid"], "no row falls in the gap");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe("toolUseFromClaudeHook", () => {
|
|
193
|
+
it("maps the vendor payload onto a record call", () => {
|
|
194
|
+
const opts = toolUseFromClaudeHook({
|
|
195
|
+
tool_name: "Bash",
|
|
196
|
+
tool_input: { command: "ls" },
|
|
197
|
+
tool_response: "a\nb\n",
|
|
198
|
+
tool_use_id: "toolu_01",
|
|
199
|
+
duration_ms: 42,
|
|
200
|
+
parent_tool_use_id: "toolu_parent",
|
|
201
|
+
}, { source: "hook", agentType: "claude" });
|
|
202
|
+
assert.deepEqual(opts, {
|
|
203
|
+
dedupKey: "toolu_01",
|
|
204
|
+
name: "Bash",
|
|
205
|
+
input: { command: "ls" },
|
|
206
|
+
status: "completed",
|
|
207
|
+
output: "a\nb\n",
|
|
208
|
+
durationMs: 42,
|
|
209
|
+
error: undefined,
|
|
210
|
+
source: "hook",
|
|
211
|
+
agentType: "claude",
|
|
212
|
+
parentId: "toolu_parent",
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
// The same call, seen earlier. A PreToolUse payload describes something that
|
|
216
|
+
// hasn't happened: no response, no duration, no outcome. Read through the
|
|
217
|
+
// completion path — where "no error present" means success — it would report
|
|
218
|
+
// every call as finished the instant it began.
|
|
219
|
+
it("maps a PreToolUse payload to a pending row that opens at the call's start", () => {
|
|
220
|
+
const opts = toolUseFromClaudeHook({
|
|
221
|
+
hook_event_name: "PreToolUse",
|
|
222
|
+
tool_name: "Bash",
|
|
223
|
+
tool_input: { command: "ls" },
|
|
224
|
+
tool_use_id: "toolu_01",
|
|
225
|
+
}, { source: "hook", agentType: "claude", now: 1_000 });
|
|
226
|
+
assert.equal(opts.status, "pending");
|
|
227
|
+
assert.equal(opts.startedAt, 1_000);
|
|
228
|
+
assert.equal(opts.durationMs, undefined);
|
|
229
|
+
assert.equal(opts.output, undefined);
|
|
230
|
+
});
|
|
231
|
+
// The two hooks are independent HTTP calls and can land out of order. A late
|
|
232
|
+
// `pending` must not strand a finished call as permanently running.
|
|
233
|
+
it("keeps started_at from the pending row and never regresses a completion", () => {
|
|
234
|
+
const db = createTestDb();
|
|
235
|
+
const nid = seedNarrative(db);
|
|
236
|
+
recordToolUse(db, nid, {
|
|
237
|
+
name: "Bash", status: "pending", source: "hook", dedupKey: "k", startedAt: 1_000,
|
|
238
|
+
});
|
|
239
|
+
const done = recordToolUse(db, nid, {
|
|
240
|
+
name: "Bash", status: "completed", source: "hook", dedupKey: "k",
|
|
241
|
+
startedAt: 9_999, durationMs: 40, output: "ok",
|
|
242
|
+
});
|
|
243
|
+
assert.equal(done.started_at, 1_000, "the call began when it was announced");
|
|
244
|
+
assert.equal(done.duration_ms, 40);
|
|
245
|
+
const late = recordToolUse(db, nid, {
|
|
246
|
+
name: "Bash", status: "pending", source: "hook", dedupKey: "k", startedAt: 1_000,
|
|
247
|
+
});
|
|
248
|
+
assert.equal(late.status, "completed", "a late pending must not undo a completion");
|
|
249
|
+
assert.equal(late.output, "ok");
|
|
250
|
+
});
|
|
251
|
+
it("reads failure from either an error field or is_error on the response", () => {
|
|
252
|
+
const byError = toolUseFromClaudeHook({ tool_name: "Bash", error: "exit 1" }, { source: "hook" });
|
|
253
|
+
assert.equal(byError?.status, "failed");
|
|
254
|
+
assert.equal(byError?.error, "exit 1");
|
|
255
|
+
const byResponse = toolUseFromClaudeHook({ tool_name: "Bash", tool_response: { is_error: true, content: "no matches found" } }, { source: "hook" });
|
|
256
|
+
assert.equal(byResponse?.status, "failed");
|
|
257
|
+
const ok = toolUseFromClaudeHook({ tool_name: "Bash", tool_response: { is_error: false, content: "ok" } }, { source: "hook" });
|
|
258
|
+
assert.equal(ok?.status, "completed");
|
|
259
|
+
});
|
|
260
|
+
it("survives a payload with nothing but tool_name, and rejects one without it", () => {
|
|
261
|
+
const bare = toolUseFromClaudeHook({ tool_name: "Read" }, { source: "hook" });
|
|
262
|
+
assert.equal(bare?.name, "Read");
|
|
263
|
+
assert.equal(bare?.dedupKey, undefined, "no vendor id — recordToolUse content-hashes instead");
|
|
264
|
+
assert.equal(bare?.status, "completed");
|
|
265
|
+
assert.equal(toolUseFromClaudeHook({}, { source: "hook" }), null);
|
|
266
|
+
assert.equal(toolUseFromClaudeHook({ tool_name: "" }, { source: "hook" }), null);
|
|
267
|
+
});
|
|
268
|
+
// goose's PreToolUse and PostToolUse payloads are byte-identical apart from
|
|
269
|
+
// the event name — {event, matcher_context, session_id, tool_input,
|
|
270
|
+
// tool_name, working_dir}, captured live. No call id anywhere in either, so
|
|
271
|
+
// there is nothing but the content to pair the two halves with.
|
|
272
|
+
describe("pairing a vendor that sends no call id", () => {
|
|
273
|
+
const goose = (event, command) => normalizeHookPayload({
|
|
274
|
+
event,
|
|
275
|
+
session_id: "20260816_4",
|
|
276
|
+
matcher_context: "developer__shell",
|
|
277
|
+
tool_name: "developer__shell",
|
|
278
|
+
tool_input: { command },
|
|
279
|
+
working_dir: "/Users/ben/dev/goose",
|
|
280
|
+
});
|
|
281
|
+
function report(db, nid, event, command, now) {
|
|
282
|
+
const opts = toolUseFromClaudeHook(goose(event, command), { source: "hook", now });
|
|
283
|
+
// A completion carries no time of its own, so the daemon stamps its
|
|
284
|
+
// arrival — exactly as the HTTP handler does.
|
|
285
|
+
return recordToolUse(db, nid, opts.status === "pending" ? opts : { ...opts, startedAt: now });
|
|
286
|
+
}
|
|
287
|
+
it("closes the pending row instead of opening a second one", () => {
|
|
288
|
+
const db = createTestDb();
|
|
289
|
+
const nid = seedNarrative(db);
|
|
290
|
+
report(db, nid, "PreToolUse", "sleep 3", 1_000);
|
|
291
|
+
assert.equal(readToolUseWindow(db, nid).length, 1);
|
|
292
|
+
const done = report(db, nid, "PostToolUse", "sleep 3", 4_000);
|
|
293
|
+
const rows = readToolUseWindow(db, nid);
|
|
294
|
+
assert.equal(rows.length, 1, "one call is one row");
|
|
295
|
+
assert.equal(done.status, "completed");
|
|
296
|
+
assert.equal(done.started_at, 1_000, "the call began when it was announced");
|
|
297
|
+
});
|
|
298
|
+
// The vendor timed nothing, but the daemon did: the two hooks bracket the
|
|
299
|
+
// call, so the gap between their arrivals is the real duration.
|
|
300
|
+
it("derives a duration from the two hooks' arrival times", () => {
|
|
301
|
+
const db = createTestDb();
|
|
302
|
+
const nid = seedNarrative(db);
|
|
303
|
+
report(db, nid, "PreToolUse", "sleep 3", 1_000);
|
|
304
|
+
assert.equal(report(db, nid, "PostToolUse", "sleep 3", 4_050).duration_ms, 3_050);
|
|
305
|
+
});
|
|
306
|
+
// The timestamp in contentDedupKey was what told two identical calls apart,
|
|
307
|
+
// and dropping it must not merge them. It doesn't, because only a
|
|
308
|
+
// completion claims, and claiming requires the row still be pending — so
|
|
309
|
+
// the second completion cannot take the row the first one closed.
|
|
310
|
+
it("keeps two identical concurrent calls as two rows, both closed", () => {
|
|
311
|
+
const db = createTestDb();
|
|
312
|
+
const nid = seedNarrative(db);
|
|
313
|
+
report(db, nid, "PreToolUse", "date", 1_000);
|
|
314
|
+
report(db, nid, "PreToolUse", "date", 1_001);
|
|
315
|
+
assert.equal(readToolUseWindow(db, nid).length, 2, "two calls in");
|
|
316
|
+
report(db, nid, "PostToolUse", "date", 2_000);
|
|
317
|
+
report(db, nid, "PostToolUse", "date", 2_001);
|
|
318
|
+
const rows = readToolUseWindow(db, nid);
|
|
319
|
+
assert.equal(rows.length, 2, "two rows out — neither call swallowed the other");
|
|
320
|
+
assert.ok(rows.every((r) => r.status === "completed"), "and neither is left running forever");
|
|
321
|
+
});
|
|
322
|
+
// An agent killed mid-call leaves a pending row that will never close. The
|
|
323
|
+
// next identical call must claim its *own* row, not be absorbed by the
|
|
324
|
+
// corpse — otherwise the fresh row is orphaned and the stale one lies.
|
|
325
|
+
it("claims the newest pending row, so a stale one can't absorb a fresh call", () => {
|
|
326
|
+
const db = createTestDb();
|
|
327
|
+
const nid = seedNarrative(db);
|
|
328
|
+
const stale = report(db, nid, "PreToolUse", "date", 1_000);
|
|
329
|
+
const fresh = report(db, nid, "PreToolUse", "date", 500_000);
|
|
330
|
+
report(db, nid, "PostToolUse", "date", 500_100);
|
|
331
|
+
const byKey = new Map(readToolUseWindow(db, nid).map((r) => [r.dedup_key, r]));
|
|
332
|
+
assert.equal(byKey.get(fresh.dedup_key).status, "completed");
|
|
333
|
+
assert.equal(byKey.get(stale.dedup_key).status, "pending", "the corpse is left as it is");
|
|
334
|
+
});
|
|
335
|
+
it("does not reach past the pairing window", () => {
|
|
336
|
+
const db = createTestDb();
|
|
337
|
+
const nid = seedNarrative(db);
|
|
338
|
+
report(db, nid, "PreToolUse", "date", 1_000);
|
|
339
|
+
// Later than any call brane asks a client to allow. Degrades to the old
|
|
340
|
+
// two-row behaviour rather than to a wrong pairing.
|
|
341
|
+
report(db, nid, "PostToolUse", "date", 1_000 + 66 * 60 * 1000);
|
|
342
|
+
assert.equal(readToolUseWindow(db, nid).length, 2);
|
|
343
|
+
});
|
|
344
|
+
// `leanLoopCapture` rewrites a narrate's input once the result proves the
|
|
345
|
+
// text reached the log — but only on the half that carries the seq. If the
|
|
346
|
+
// twin lookup matched the *stored* value, a completion would go looking for
|
|
347
|
+
// the shrunk form while its own pending row still held the whole payload,
|
|
348
|
+
// find nothing, and open a second row: this pairing's own defect
|
|
349
|
+
// reappearing on the tool whose rows the operator reads most.
|
|
350
|
+
it("matches on the call as it came, not on the leaner thing stored", () => {
|
|
351
|
+
const db = createTestDb();
|
|
352
|
+
const nid = seedNarrative(db);
|
|
353
|
+
const say = { say: "hello", meta: { summary: "working" } };
|
|
354
|
+
recordToolUse(db, nid, {
|
|
355
|
+
name: "mcp__brane__narrate", input: say, status: "pending", source: "hook", startedAt: 1_000,
|
|
356
|
+
});
|
|
357
|
+
recordToolUse(db, nid, {
|
|
358
|
+
name: "mcp__brane__narrate", input: say, status: "completed", source: "hook", startedAt: 1_200,
|
|
359
|
+
// A result with a seq is what triggers the rewrite.
|
|
360
|
+
output: { nid, seq: 7 },
|
|
361
|
+
});
|
|
362
|
+
const rows = readToolUseWindow(db, nid);
|
|
363
|
+
assert.equal(rows.length, 1, "the narrate is one row, not two");
|
|
364
|
+
assert.equal(rows[0].status, "completed");
|
|
365
|
+
assert.equal(rows[0].duration_ms, 200);
|
|
366
|
+
});
|
|
367
|
+
// Everything above must stay confined to vendors that send no id: an agent
|
|
368
|
+
// whose payload carries tool_use_id is matched on it and nothing else.
|
|
369
|
+
it("never claims when the vendor supplied an id", () => {
|
|
370
|
+
const db = createTestDb();
|
|
371
|
+
const nid = seedNarrative(db);
|
|
372
|
+
recordToolUse(db, nid, {
|
|
373
|
+
name: "Bash", input: { command: "date" }, status: "pending", source: "hook",
|
|
374
|
+
dedupKey: "toolu_01", startedAt: 1_000,
|
|
375
|
+
});
|
|
376
|
+
recordToolUse(db, nid, {
|
|
377
|
+
name: "Bash", input: { command: "date" }, status: "completed", source: "hook",
|
|
378
|
+
dedupKey: "toolu_02", startedAt: 2_000,
|
|
379
|
+
});
|
|
380
|
+
const rows = readToolUseWindow(db, nid);
|
|
381
|
+
assert.equal(rows.length, 2, "two ids are two calls, whatever they look like");
|
|
382
|
+
assert.equal(rows[0].status, "pending");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
it("reads failure from the out-of-band flag the other harnesses send", () => {
|
|
386
|
+
const flagged = toolUseFromClaudeHook({ tool_name: "shell", failed: true, tool_response: "partial output" }, { source: "hook" });
|
|
387
|
+
assert.equal(flagged?.status, "failed");
|
|
388
|
+
assert.equal(flagged?.error, undefined, "no message was sent, so none is invented");
|
|
389
|
+
assert.equal(flagged?.output, "partial output", "the output it did produce is still kept");
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
// Payload samples below are the shapes each harness reported first-hand from
|
|
393
|
+
// its own running session (goose: claude-pasen1co:20, crush: :24, cline: :28).
|
|
394
|
+
describe("normalizeHookPayload", () => {
|
|
395
|
+
it("leaves a payload that already speaks the contract untouched", () => {
|
|
396
|
+
const claude = {
|
|
397
|
+
hook_event_name: "PostToolUse",
|
|
398
|
+
tool_name: "Bash",
|
|
399
|
+
tool_input: { command: "ls" },
|
|
400
|
+
tool_response: "a\nb\n",
|
|
401
|
+
tool_use_id: "toolu_01",
|
|
402
|
+
duration_ms: 42,
|
|
403
|
+
parent_tool_use_id: "toolu_parent",
|
|
404
|
+
};
|
|
405
|
+
assert.deepEqual(normalizeHookPayload(claude), claude);
|
|
406
|
+
});
|
|
407
|
+
// The bug this whole function exists for: goose and crush both announce a
|
|
408
|
+
// call as `event`, which the mapper doesn't recognize — so without this the
|
|
409
|
+
// pre-hook takes the completion path and the call is recorded as finished
|
|
410
|
+
// before it has run.
|
|
411
|
+
it("recognizes `event` as the pre-hook, so a starting call stays pending", () => {
|
|
412
|
+
const goosePre = normalizeHookPayload({
|
|
413
|
+
event: "PreToolUse",
|
|
414
|
+
session_id: "20260816_2",
|
|
415
|
+
tool_name: "developer__shell",
|
|
416
|
+
tool_input: { command: "sleep 25" },
|
|
417
|
+
working_dir: "/Users/ben/dev/goose",
|
|
418
|
+
});
|
|
419
|
+
assert.equal(goosePre.hook_event_name, "PreToolUse");
|
|
420
|
+
assert.equal(goosePre.tool_name, "developer__shell");
|
|
421
|
+
const opts = toolUseFromClaudeHook(goosePre, { source: "hook", now: 1_000 });
|
|
422
|
+
assert.equal(opts?.status, "pending");
|
|
423
|
+
assert.equal(opts?.startedAt, 1_000);
|
|
424
|
+
});
|
|
425
|
+
it("accepts the case and separator variants goose and crush accept", () => {
|
|
426
|
+
for (const event of ["PreToolUse", "pretooluse", "pre_tool_use", "PRE-TOOL-USE"]) {
|
|
427
|
+
assert.equal(normalizeHookPayload({ event, tool_name: "x" }).hook_event_name, "PreToolUse");
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
it("maps goose's post payload, including its separate failure event", () => {
|
|
431
|
+
const post = normalizeHookPayload({
|
|
432
|
+
event: "PostToolUse",
|
|
433
|
+
session_id: "20260816_2",
|
|
434
|
+
tool_name: "developer__text_editor",
|
|
435
|
+
tool_input: { path: "/tmp/x" },
|
|
436
|
+
tool_output: { content: ["ok"] },
|
|
437
|
+
});
|
|
438
|
+
assert.equal(post.hook_event_name, "PostToolUse");
|
|
439
|
+
assert.deepEqual(post.tool_response, { content: ["ok"] }, "tool_output is the response");
|
|
440
|
+
assert.equal(post.failed, undefined);
|
|
441
|
+
assert.equal(toolUseFromClaudeHook(post, { source: "hook" })?.status, "completed");
|
|
442
|
+
const failure = normalizeHookPayload({
|
|
443
|
+
event: "PostToolUseFailure",
|
|
444
|
+
tool_name: "developer__shell",
|
|
445
|
+
tool_input: { command: "false" },
|
|
446
|
+
});
|
|
447
|
+
assert.equal(failure.failed, true);
|
|
448
|
+
assert.equal(failure.error, undefined, "goose sends no message with the event");
|
|
449
|
+
assert.equal(toolUseFromClaudeHook(failure, { source: "hook" })?.status, "failed");
|
|
450
|
+
});
|
|
451
|
+
it("maps cline's nested call and outcome, and its success flag", () => {
|
|
452
|
+
const pre = normalizeHookPayload({
|
|
453
|
+
hookName: "tool_call",
|
|
454
|
+
clineVersion: "3.0.55",
|
|
455
|
+
taskId: "1786899559349_f9dpo",
|
|
456
|
+
iteration: 3,
|
|
457
|
+
tool_call: { id: "brane__narrate_0", name: "brane__narrate", input: { say: "hi" } },
|
|
458
|
+
preToolUse: { toolName: "brane__narrate", parameters: { say: "hi" } },
|
|
459
|
+
});
|
|
460
|
+
// cline names its hooks after the thing rather than the phase, so its
|
|
461
|
+
// pre-hook arrives as `tool_call`. Read literally that is not "PreToolUse"
|
|
462
|
+
// and the call would be recorded as finished before it ran.
|
|
463
|
+
assert.equal(pre.hook_event_name, "PreToolUse");
|
|
464
|
+
assert.equal(toolUseFromClaudeHook(pre, { source: "hook" })?.status, "pending");
|
|
465
|
+
assert.equal(pre.tool_name, "brane__narrate");
|
|
466
|
+
assert.equal(pre.tool_use_id, "brane__narrate_0");
|
|
467
|
+
assert.deepEqual(pre.tool_input, { say: "hi" });
|
|
468
|
+
const post = normalizeHookPayload({
|
|
469
|
+
hookName: "tool_result",
|
|
470
|
+
tool_result: { id: "brane__narrate_0", name: "brane__narrate", input: { say: "hi" }, output: "seq 7" },
|
|
471
|
+
postToolUse: {
|
|
472
|
+
toolName: "brane__narrate",
|
|
473
|
+
parameters: { say: "hi" },
|
|
474
|
+
result: "seq 7",
|
|
475
|
+
success: true,
|
|
476
|
+
executionTimeMs: 210,
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
assert.equal(post.tool_use_id, "brane__narrate_0", "same id — pre and post collapse to one row");
|
|
480
|
+
assert.equal(post.tool_response, "seq 7");
|
|
481
|
+
assert.equal(post.duration_ms, 210);
|
|
482
|
+
assert.equal(post.failed, undefined);
|
|
483
|
+
const failed = normalizeHookPayload({
|
|
484
|
+
hookName: "tool_result",
|
|
485
|
+
tool_result: { id: "run_commands_2", name: "run_commands", output: "" },
|
|
486
|
+
postToolUse: { toolName: "run_commands", result: "", success: false, executionTimeMs: 12 },
|
|
487
|
+
});
|
|
488
|
+
assert.equal(failed.failed, true);
|
|
489
|
+
assert.equal(toolUseFromClaudeHook(failed, { source: "hook" })?.status, "failed");
|
|
490
|
+
});
|
|
491
|
+
it("maps crush's pre payload, whose tool_input is already Claude-shaped", () => {
|
|
492
|
+
const crush = normalizeHookPayload({
|
|
493
|
+
event: "PreToolUse",
|
|
494
|
+
session_id: "e662a552-ce21-4384-abb6-4e2c72bb58bc",
|
|
495
|
+
cwd: "/Users/ben/dev/crush",
|
|
496
|
+
tool_name: "bash",
|
|
497
|
+
tool_input: { command: "ls" },
|
|
498
|
+
});
|
|
499
|
+
assert.equal(crush.hook_event_name, "PreToolUse");
|
|
500
|
+
assert.deepEqual(crush.tool_input, { command: "ls" });
|
|
501
|
+
// Crush's pre payload carries no call id, and crush has no post event at
|
|
502
|
+
// all — which is why its completions come from the session-log tail rather
|
|
503
|
+
// than from a second hook. Nothing here can invent the id.
|
|
504
|
+
assert.equal(crush.tool_use_id, undefined);
|
|
505
|
+
});
|
|
506
|
+
it("ignores junk without inventing fields", () => {
|
|
507
|
+
assert.deepEqual(normalizeHookPayload({}), {});
|
|
508
|
+
assert.deepEqual(normalizeHookPayload(null), {});
|
|
509
|
+
assert.deepEqual(normalizeHookPayload("nonsense"), {});
|
|
510
|
+
// Wrong types are dropped rather than passed through to be stored.
|
|
511
|
+
const wrong = normalizeHookPayload({ tool_name: 7, duration_ms: "fast", tool_use_id: 3 });
|
|
512
|
+
assert.deepEqual(wrong, {});
|
|
513
|
+
});
|
|
514
|
+
it("does not read cline's parent_agent_id as a parent tool call", () => {
|
|
515
|
+
const nested = normalizeHookPayload({
|
|
516
|
+
hookName: "tool_call",
|
|
517
|
+
tool_call: { id: "x_1", name: "x", input: {} },
|
|
518
|
+
agent_id: "agent-2",
|
|
519
|
+
parent_agent_id: "agent-1",
|
|
520
|
+
});
|
|
521
|
+
assert.equal(nested.parent_tool_use_id, undefined, "a sub-agent is not a parent tool call");
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
// The hook endpoint sits *before* the bearer-token check, so this is the only
|
|
525
|
+
// thing standing between an unauthenticated POST and a narrative's rows.
|
|
526
|
+
describe("verifyNarrativeBind", () => {
|
|
527
|
+
function seedWithToken(db, id, token) {
|
|
528
|
+
db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at, bind_token) VALUES (?, 'brane-test', 'claude', 'worker', 'active', '/tmp', ?, ?)").run(id, Date.now(), token);
|
|
529
|
+
adoptBindTokens(db);
|
|
530
|
+
}
|
|
531
|
+
it("accepts the matching secret and reports the narrative's agent type", () => {
|
|
532
|
+
const db = createTestDb();
|
|
533
|
+
seedWithToken(db, "claude-bind0001", "s3cret");
|
|
534
|
+
assert.deepEqual(verifyNarrativeBind(db, "claude-bind0001", "s3cret"), {
|
|
535
|
+
nid: "claude-bind0001",
|
|
536
|
+
agentType: "claude",
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
it("refuses a wrong secret, an unknown narrative, and a null bind_token alike", () => {
|
|
540
|
+
const db = createTestDb();
|
|
541
|
+
seedWithToken(db, "claude-bind0001", "s3cret");
|
|
542
|
+
seedWithToken(db, "claude-legacy01", null);
|
|
543
|
+
assert.equal(verifyNarrativeBind(db, "claude-bind0001", "wrong"), null);
|
|
544
|
+
assert.equal(verifyNarrativeBind(db, "claude-bind0001", ""), null);
|
|
545
|
+
assert.equal(verifyNarrativeBind(db, "claude-nosuch01", "s3cret"), null);
|
|
546
|
+
// No grace path here, unlike resolveMcpBind: a hook has no session to
|
|
547
|
+
// save, so a pre-bind-token narrative is refused rather than trusted.
|
|
548
|
+
assert.equal(verifyNarrativeBind(db, "claude-legacy01", ""), null);
|
|
549
|
+
assert.equal(verifyNarrativeBind(db, "claude-legacy01", "anything"), null);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
describe("numericConfig", () => {
|
|
553
|
+
it("falls back on absent or non-numeric values instead of yielding NaN", () => {
|
|
554
|
+
assert.equal(numericConfig("250", 999), 250);
|
|
555
|
+
assert.equal(numericConfig(undefined, 999), 999);
|
|
556
|
+
assert.equal(numericConfig(null, 999), 999);
|
|
557
|
+
assert.equal(numericConfig("", 999), 999);
|
|
558
|
+
// The case that used to disable coalescing in silence: NaN fails every
|
|
559
|
+
// comparison, so the buffer never flushed on size and the timer fired
|
|
560
|
+
// next tick (claude-8fkbabsx:9, finding 6).
|
|
561
|
+
assert.equal(numericConfig("twenty", 20), 20);
|
|
562
|
+
assert.equal(numericConfig("abc", 250), 250);
|
|
563
|
+
assert.equal(numericConfig("0", 250), 0, "an explicit 0 is a real setting, not a fallback");
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
describe("prepareCaptureText", () => {
|
|
567
|
+
it("passes text through unchanged and JSON-stringifies structures", () => {
|
|
568
|
+
assert.equal(prepareCaptureText("npm test"), "npm test");
|
|
569
|
+
assert.equal(prepareCaptureText({ cmd: "ls" }), '{"cmd":"ls"}');
|
|
570
|
+
assert.equal(prepareCaptureText(undefined), null);
|
|
571
|
+
assert.equal(prepareCaptureText(null), null);
|
|
572
|
+
});
|
|
573
|
+
it("stores verbatim — no redaction, no cap (operator ruling, claude-xlolbv6l:94)", () => {
|
|
574
|
+
const sensitive = "Authorization: Bearer abcdefghijklmnop1234 AKIAIOSFODNN7EXAMPLE";
|
|
575
|
+
assert.equal(prepareCaptureText(sensitive), sensitive);
|
|
576
|
+
const big = "x".repeat(100_000);
|
|
577
|
+
assert.equal(prepareCaptureText(big), big);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
describe("excerptForPush", () => {
|
|
581
|
+
it("returns the row untouched when input/output fit the push cap", () => {
|
|
582
|
+
const db = createTestDb();
|
|
583
|
+
const nid = seedNarrative(db);
|
|
584
|
+
const row = recordToolUse(db, nid, {
|
|
585
|
+
name: "Bash", input: "ls", output: "ok", status: "completed", source: "hook",
|
|
586
|
+
});
|
|
587
|
+
assert.equal(excerptForPush(row), row);
|
|
588
|
+
});
|
|
589
|
+
it("excerpts oversized fields for the wire while the stored row keeps the full value", () => {
|
|
590
|
+
const db = createTestDb();
|
|
591
|
+
const nid = seedNarrative(db);
|
|
592
|
+
const big = "x".repeat(100_000);
|
|
593
|
+
const row = recordToolUse(db, nid, {
|
|
594
|
+
name: "Read", input: "file.txt", output: big, status: "completed", source: "hook",
|
|
595
|
+
});
|
|
596
|
+
assert.equal(row.output, big, "stored row is verbatim");
|
|
597
|
+
const pushed = excerptForPush(row);
|
|
598
|
+
assert.ok(Buffer.byteLength(pushed.output, "utf-8") <= 16_384);
|
|
599
|
+
assert.equal(pushed.output_truncated, true);
|
|
600
|
+
assert.equal(pushed.input, "file.txt");
|
|
601
|
+
assert.equal(pushed.input_truncated, undefined);
|
|
602
|
+
const stored = readToolUseWindow(db, nid)[0];
|
|
603
|
+
assert.equal(stored.output, big, "push excerpting never touches the database");
|
|
604
|
+
});
|
|
605
|
+
// The old excerpt sliced the *string* by character count, which can land
|
|
606
|
+
// between the two halves of a surrogate pair and ship a lone surrogate that
|
|
607
|
+
// renders as U+FFFD (claude-8fkbabsx:9, finding 7). Emoji are 2 UTF-16 code
|
|
608
|
+
// units and 4 UTF-8 bytes, so a cap that isn't a multiple of 4 lands
|
|
609
|
+
// mid-character on every pass.
|
|
610
|
+
it("cuts multi-byte text on a character boundary, never mid-surrogate", () => {
|
|
611
|
+
const db = createTestDb();
|
|
612
|
+
const nid = seedNarrative(db);
|
|
613
|
+
const emoji = "🙂".repeat(20_000);
|
|
614
|
+
const row = recordToolUse(db, nid, {
|
|
615
|
+
name: "Read", output: emoji, status: "completed", source: "hook",
|
|
616
|
+
});
|
|
617
|
+
const pushed = excerptForPush(row);
|
|
618
|
+
const excerpt = pushed.output;
|
|
619
|
+
assert.ok(Buffer.byteLength(excerpt, "utf-8") <= 16_384);
|
|
620
|
+
assert.equal(pushed.output_truncated, true);
|
|
621
|
+
assert.ok(excerpt.length > 0, "the cap is large enough for many characters");
|
|
622
|
+
// A lone surrogate is not a valid code point: round-tripping through UTF-8
|
|
623
|
+
// would replace it with U+FFFD. Equality proves every character survived
|
|
624
|
+
// whole.
|
|
625
|
+
assert.equal(Buffer.from(excerpt, "utf-8").toString("utf-8"), excerpt, "excerpt must contain no unpaired surrogate");
|
|
626
|
+
// A prefix cut can only ever strand a *high* surrogate at the end.
|
|
627
|
+
assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(excerpt), "no unpaired high surrogate");
|
|
628
|
+
assert.ok(emoji.startsWith(excerpt), "excerpt is a true prefix of the original");
|
|
629
|
+
});
|
|
630
|
+
// The cap that broke the wire was `map`'s index. Nothing asks to excerpt a
|
|
631
|
+
// payload to single digits, so a cap under the floor is a caller's slip and
|
|
632
|
+
// is read as "no cap given" (claude-bk6vwpc8:9).
|
|
633
|
+
it("ignores a nonsensical cap rather than blanking the field", () => {
|
|
634
|
+
const db = createTestDb();
|
|
635
|
+
const nid = seedNarrative(db);
|
|
636
|
+
const row = recordToolUse(db, nid, {
|
|
637
|
+
name: "Bash", input: '{"command":"ls"}', output: "ok", status: "completed", source: "hook",
|
|
638
|
+
});
|
|
639
|
+
for (const cap of [0, 1, 2, 63, Number.NaN, -1]) {
|
|
640
|
+
const pushed = excerptForPush(row, cap);
|
|
641
|
+
assert.equal(pushed.input, '{"command":"ls"}', `cap ${cap} must not empty the input`);
|
|
642
|
+
assert.equal(pushed.input_truncated, undefined, `cap ${cap} must not claim truncation`);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
it("still honours a cap that a caller could plausibly mean", () => {
|
|
646
|
+
const db = createTestDb();
|
|
647
|
+
const nid = seedNarrative(db);
|
|
648
|
+
const row = recordToolUse(db, nid, {
|
|
649
|
+
name: "Read", output: "y".repeat(500), status: "completed", source: "hook",
|
|
650
|
+
});
|
|
651
|
+
const pushed = excerptForPush(row, 64);
|
|
652
|
+
assert.equal(Buffer.byteLength(pushed.output, "utf-8"), 64);
|
|
653
|
+
assert.equal(pushed.output_truncated, true);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
/** The defect was in the *call*, not the callee: `.map(excerptForPush)` fed
|
|
657
|
+
* each row its own index as a byte cap. Every unit test above passed while the
|
|
658
|
+
* wire went out empty, so this exercises the shaping the flush path actually
|
|
659
|
+
* performs — the smallest thing that would have failed
|
|
660
|
+
* (claude-bk6vwpc8:9). */
|
|
661
|
+
describe("shapeToolUsesForPush", () => {
|
|
662
|
+
it("keeps every row's input, whatever its position in the flush", () => {
|
|
663
|
+
const db = createTestDb();
|
|
664
|
+
const nid = seedNarrative(db);
|
|
665
|
+
const rows = ["ls", "pwd", "whoami", "date", "uname"].map((cmd, i) => recordToolUse(db, nid, {
|
|
666
|
+
name: "Bash",
|
|
667
|
+
input: { command: cmd },
|
|
668
|
+
output: "ok",
|
|
669
|
+
status: "completed",
|
|
670
|
+
source: "hook",
|
|
671
|
+
dedupKey: `toolu_${i}`,
|
|
672
|
+
startedAt: 1_000 + i,
|
|
673
|
+
}));
|
|
674
|
+
const shaped = shapeToolUsesForPush(rows);
|
|
675
|
+
assert.equal(shaped.length, 5);
|
|
676
|
+
shaped.forEach((row, i) => {
|
|
677
|
+
// The first row is the one the old bug emptied outright: index 0 as a
|
|
678
|
+
// cap excerpts to zero bytes.
|
|
679
|
+
assert.ok(row.input, `row ${i} must reach the wire with its input`);
|
|
680
|
+
assert.equal(JSON.parse(row.input).command, ["ls", "pwd", "whoami", "date", "uname"][i]);
|
|
681
|
+
assert.equal(row.input_truncated, undefined, `row ${i} must not be flagged truncated`);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
it("sorts by display key, so a client can insert-merge in one pass", () => {
|
|
685
|
+
const db = createTestDb();
|
|
686
|
+
const nid = seedNarrative(db);
|
|
687
|
+
const late = recordToolUse(db, nid, {
|
|
688
|
+
name: "Bash", input: "b", status: "completed", source: "hook",
|
|
689
|
+
dedupKey: "toolu_late", startedAt: 2_000,
|
|
690
|
+
});
|
|
691
|
+
const early = recordToolUse(db, nid, {
|
|
692
|
+
name: "Bash", input: "a", status: "completed", source: "hook",
|
|
693
|
+
dedupKey: "toolu_early", startedAt: 1_000,
|
|
694
|
+
});
|
|
695
|
+
const shaped = shapeToolUsesForPush([late, early]);
|
|
696
|
+
assert.deepEqual(shaped.map((r) => r.dedup_key), ["toolu_early", "toolu_late"]);
|
|
697
|
+
});
|
|
698
|
+
it("does not reorder the caller's array in place", () => {
|
|
699
|
+
const db = createTestDb();
|
|
700
|
+
const nid = seedNarrative(db);
|
|
701
|
+
const late = recordToolUse(db, nid, {
|
|
702
|
+
name: "Bash", input: "b", status: "completed", source: "hook",
|
|
703
|
+
dedupKey: "toolu_late", startedAt: 2_000,
|
|
704
|
+
});
|
|
705
|
+
const early = recordToolUse(db, nid, {
|
|
706
|
+
name: "Bash", input: "a", status: "completed", source: "hook",
|
|
707
|
+
dedupKey: "toolu_early", startedAt: 1_000,
|
|
708
|
+
});
|
|
709
|
+
const given = [late, early];
|
|
710
|
+
shapeToolUsesForPush(given);
|
|
711
|
+
assert.deepEqual(given.map((r) => r.dedup_key), ["toolu_late", "toolu_early"]);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
describe("leanLoopCapture", () => {
|
|
715
|
+
const NARRATE = "mcp__brane__narrate";
|
|
716
|
+
const LISTEN = "mcp__brane__listen";
|
|
717
|
+
it("drops the events a listen delivered, and keeps its cursors", () => {
|
|
718
|
+
const db = createTestDb();
|
|
719
|
+
const nid = seedNarrative(db);
|
|
720
|
+
const row = recordToolUse(db, nid, {
|
|
721
|
+
name: LISTEN,
|
|
722
|
+
status: "completed",
|
|
723
|
+
source: "hook",
|
|
724
|
+
input: { after: { self: 29, "claude-peer0001": 15 }, tail: 50 },
|
|
725
|
+
output: { events: { self: [{ seq: 30, content: "a whole narration" }] }, waited_ms: 12 },
|
|
726
|
+
dedupKey: "toolu_listen",
|
|
727
|
+
});
|
|
728
|
+
// The events are the log itself, stored twice. The cursors stay: they
|
|
729
|
+
// implicitly encode one session's chunking, which the operator weighed and
|
|
730
|
+
// accepted, and they are the only record of who was being watched.
|
|
731
|
+
assert.equal(row.output, null);
|
|
732
|
+
assert.deepEqual(JSON.parse(row.input), {
|
|
733
|
+
after: { self: 29, "claude-peer0001": 15 },
|
|
734
|
+
tail: 50,
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
it("keeps a narration's text until the result proves it reached the log", () => {
|
|
738
|
+
const db = createTestDb();
|
|
739
|
+
const nid = seedNarrative(db);
|
|
740
|
+
// PreToolUse: no result yet, so no proof — the words stay.
|
|
741
|
+
const pre = recordToolUse(db, nid, {
|
|
742
|
+
name: NARRATE, status: "pending", source: "hook",
|
|
743
|
+
input: { say: "a long narration", meta: { summary: "working" } },
|
|
744
|
+
dedupKey: "toolu_self",
|
|
745
|
+
});
|
|
746
|
+
assert.ok(pre.input.includes("a long narration"));
|
|
747
|
+
// PostToolUse: a seq means the narration is in `events`, and this copy is
|
|
748
|
+
// redundant rather than unique.
|
|
749
|
+
const post = recordToolUse(db, nid, {
|
|
750
|
+
name: NARRATE, status: "completed", source: "hook",
|
|
751
|
+
input: { say: "a long narration", meta: { summary: "working" } },
|
|
752
|
+
output: { nid, seq: 12, events: { self: [] } },
|
|
753
|
+
dedupKey: "toolu_self",
|
|
754
|
+
});
|
|
755
|
+
assert.deepEqual(JSON.parse(post.input), {});
|
|
756
|
+
assert.deepEqual(JSON.parse(post.output), {});
|
|
757
|
+
});
|
|
758
|
+
it("never drops the words of a narrate that failed", () => {
|
|
759
|
+
const db = createTestDb();
|
|
760
|
+
const nid = seedNarrative(db);
|
|
761
|
+
const row = recordToolUse(db, nid, {
|
|
762
|
+
name: NARRATE, status: "failed", source: "hook",
|
|
763
|
+
input: { nid: "claude-other001", say: "never landed" },
|
|
764
|
+
error: "no such narrative",
|
|
765
|
+
dedupKey: "toolu_failed_foreign",
|
|
766
|
+
});
|
|
767
|
+
// No seq, no proof, no event anywhere — this row is the only copy there is.
|
|
768
|
+
assert.ok(row.input.includes("never landed"));
|
|
769
|
+
});
|
|
770
|
+
it("keeps the pointer a foreign narrate creates, and only that", () => {
|
|
771
|
+
const db = createTestDb();
|
|
772
|
+
const nid = seedNarrative(db);
|
|
773
|
+
const row = recordToolUse(db, nid, {
|
|
774
|
+
name: NARRATE,
|
|
775
|
+
status: "completed",
|
|
776
|
+
source: "hook",
|
|
777
|
+
input: { nid: "claude-other001", say: "a message that lives over there" },
|
|
778
|
+
output: { nid: "claude-other001", seq: 7, events: { self: [] } },
|
|
779
|
+
dedupKey: "toolu_foreign",
|
|
780
|
+
});
|
|
781
|
+
// The text belongs to the narrative that received it; the reference is the
|
|
782
|
+
// only part of an outgoing message this side should carry.
|
|
783
|
+
assert.deepEqual(JSON.parse(row.input), { nid: "claude-other001" });
|
|
784
|
+
assert.deepEqual(JSON.parse(row.output), { nid: "claude-other001", seq: 7 });
|
|
785
|
+
});
|
|
786
|
+
it("reads the seq through the MCP result envelope some vendors wrap", () => {
|
|
787
|
+
const db = createTestDb();
|
|
788
|
+
const nid = seedNarrative(db);
|
|
789
|
+
const row = recordToolUse(db, nid, {
|
|
790
|
+
name: NARRATE, status: "completed", source: "hook",
|
|
791
|
+
input: { nid: "claude-other001", say: "hello" },
|
|
792
|
+
output: { content: [{ type: "text", text: JSON.stringify({ nid: "claude-other001", seq: 41 }) }] },
|
|
793
|
+
dedupKey: "toolu_wrapped",
|
|
794
|
+
});
|
|
795
|
+
assert.deepEqual(JSON.parse(row.output), { nid: "claude-other001", seq: 41 });
|
|
796
|
+
});
|
|
797
|
+
it("lets a Post payload without tool_input keep what Pre recorded", () => {
|
|
798
|
+
const db = createTestDb();
|
|
799
|
+
const nid = seedNarrative(db);
|
|
800
|
+
recordToolUse(db, nid, {
|
|
801
|
+
name: "Bash", status: "pending", source: "hook",
|
|
802
|
+
input: { command: "ls -la" }, dedupKey: "toolu_coalesce",
|
|
803
|
+
});
|
|
804
|
+
const post = recordToolUse(db, nid, {
|
|
805
|
+
name: "Bash", status: "completed", source: "hook",
|
|
806
|
+
output: "total 0\n", dedupKey: "toolu_coalesce",
|
|
807
|
+
});
|
|
808
|
+
assert.deepEqual(JSON.parse(post.input), { command: "ls -la" });
|
|
809
|
+
});
|
|
810
|
+
it("leaves every other tool verbatim", () => {
|
|
811
|
+
const db = createTestDb();
|
|
812
|
+
const nid = seedNarrative(db);
|
|
813
|
+
const row = recordToolUse(db, nid, {
|
|
814
|
+
name: "Bash", status: "completed", source: "hook",
|
|
815
|
+
input: { command: "ls -la" }, output: "total 0\n",
|
|
816
|
+
dedupKey: "toolu_bash",
|
|
817
|
+
});
|
|
818
|
+
assert.deepEqual(JSON.parse(row.input), { command: "ls -la" });
|
|
819
|
+
assert.equal(row.output, "total 0\n");
|
|
820
|
+
});
|
|
821
|
+
it("tells two self-narrations apart even though neither keeps its text", () => {
|
|
822
|
+
const db = createTestDb();
|
|
823
|
+
const nid = seedNarrative(db);
|
|
824
|
+
// Identity comes from the call, not from what survives capture — same
|
|
825
|
+
// start, same name, both compacted to `{}`, and still two rows.
|
|
826
|
+
const startedAt = Date.now();
|
|
827
|
+
const a = recordToolUse(db, nid, {
|
|
828
|
+
name: NARRATE, status: "completed", source: "session_log", startedAt,
|
|
829
|
+
input: { say: "first" }, output: { nid, seq: 1 },
|
|
830
|
+
});
|
|
831
|
+
const b = recordToolUse(db, nid, {
|
|
832
|
+
name: NARRATE, status: "completed", source: "session_log", startedAt,
|
|
833
|
+
input: { say: "second" }, output: { nid, seq: 2 },
|
|
834
|
+
});
|
|
835
|
+
assert.equal(a.input, "{}");
|
|
836
|
+
assert.equal(b.input, "{}");
|
|
837
|
+
assert.notEqual(a.dedup_key, b.dedup_key);
|
|
838
|
+
assert.equal(readToolUseWindow(db, nid, {}).length, 2);
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
describe("wire size", () => {
|
|
842
|
+
it("excerpts a list row to 1KB, not to the old 16KB push cap", () => {
|
|
843
|
+
const db = createTestDb();
|
|
844
|
+
const nid = seedNarrative(db);
|
|
845
|
+
const row = recordToolUse(db, nid, {
|
|
846
|
+
name: "Read", output: "x".repeat(200_000), status: "completed", source: "hook",
|
|
847
|
+
});
|
|
848
|
+
const listed = excerptForPush(row);
|
|
849
|
+
assert.ok(Buffer.byteLength(listed.output, "utf-8") <= LIST_EXCERPT_BYTES);
|
|
850
|
+
assert.equal(listed.output_truncated, true);
|
|
851
|
+
// The stored row is untouched — the excerpt is a wire concern, and the
|
|
852
|
+
// single-row read is what serves the rest.
|
|
853
|
+
assert.equal(readToolUse(db, nid, row.dedup_key).output.length, 200_000);
|
|
854
|
+
});
|
|
855
|
+
it("keeps a foreign narrate's pointer whole, since it fits the cap easily", () => {
|
|
856
|
+
const db = createTestDb();
|
|
857
|
+
const nid = seedNarrative(db);
|
|
858
|
+
const row = recordToolUse(db, nid, {
|
|
859
|
+
name: "mcp__brane__narrate", status: "completed", source: "hook",
|
|
860
|
+
input: { nid: "claude-other001", say: "a".repeat(50_000) },
|
|
861
|
+
output: { nid: "claude-other001", seq: 7 },
|
|
862
|
+
});
|
|
863
|
+
const listed = excerptForPush(row);
|
|
864
|
+
assert.deepEqual(JSON.parse(listed.output), { nid: "claude-other001", seq: 7 });
|
|
865
|
+
assert.equal(listed.output_truncated, undefined);
|
|
866
|
+
});
|
|
867
|
+
it("holds a byte budget the row count cannot express", () => {
|
|
868
|
+
const db = createTestDb();
|
|
869
|
+
const nid = seedNarrative(db);
|
|
870
|
+
// Excerpting to 1KB means the 500-row limit already implies ~500KB, so the
|
|
871
|
+
// budget is a backstop rather than the working mechanism. Reach past the
|
|
872
|
+
// row limit to prove the backstop is real: 700 heavy rows, asked for whole.
|
|
873
|
+
const rows = Array.from({ length: 700 }, (_, i) => recordToolUse(db, nid, {
|
|
874
|
+
name: "Read", status: "completed", source: "hook",
|
|
875
|
+
output: "y".repeat(100_000), dedupKey: `toolu_${i}`,
|
|
876
|
+
}));
|
|
877
|
+
const fitted = fitToolUses(rows, 700);
|
|
878
|
+
assert.ok(fitted.truncated, "a page this heavy must not go out whole");
|
|
879
|
+
assert.ok(fitted.rows.length < rows.length);
|
|
880
|
+
const bytes = fitted.rows.reduce((n, r) => n + (r.input?.length ?? 0) + (r.output?.length ?? 0), 0);
|
|
881
|
+
assert.ok(bytes <= TOOL_USES_MAX_BYTES, `page was ${bytes} bytes`);
|
|
882
|
+
});
|
|
883
|
+
it("fits a real narrative's worth of rows into a page a phone can pull", () => {
|
|
884
|
+
const db = createTestDb();
|
|
885
|
+
const nid = seedNarrative(db);
|
|
886
|
+
// The shape that started this: 265 rows carrying multi-KB payloads was a
|
|
887
|
+
// 3.65MB response, and on a ~60KB/s link that request never finished
|
|
888
|
+
// (claude-n1izig04:74).
|
|
889
|
+
const rows = Array.from({ length: 265 }, (_, i) => recordToolUse(db, nid, {
|
|
890
|
+
name: "Read", status: "completed", source: "hook",
|
|
891
|
+
output: "y".repeat(13_000), dedupKey: `toolu_${i}`,
|
|
892
|
+
}));
|
|
893
|
+
const verbatim = rows.reduce((n, r) => n + (r.output?.length ?? 0), 0);
|
|
894
|
+
const fitted = fitToolUses(rows, 500);
|
|
895
|
+
const shipped = fitted.rows.reduce((n, r) => n + (r.output?.length ?? 0), 0);
|
|
896
|
+
assert.equal(fitted.rows.length, 265, "every row still ships — just lighter");
|
|
897
|
+
assert.ok(verbatim > 3_000_000, `sanity: ${verbatim} bytes stored`);
|
|
898
|
+
assert.ok(shipped < 300_000, `page was ${shipped} bytes`);
|
|
899
|
+
});
|
|
900
|
+
it("always yields one row, even when that row alone exceeds the budget", () => {
|
|
901
|
+
const db = createTestDb();
|
|
902
|
+
const nid = seedNarrative(db);
|
|
903
|
+
// A page that returned zero rows with truncated: true would hand the client
|
|
904
|
+
// a cursor pointing at the row it just refused, and it would page forever.
|
|
905
|
+
const heavy = recordToolUse(db, nid, {
|
|
906
|
+
name: "Read", status: "completed", source: "hook",
|
|
907
|
+
output: "z".repeat(5_000_000), dedupKey: "toolu_heavy",
|
|
908
|
+
});
|
|
909
|
+
const fitted = fitToolUses([heavy], 500);
|
|
910
|
+
assert.equal(fitted.rows.length, 1);
|
|
911
|
+
assert.equal(fitted.truncated, false);
|
|
912
|
+
});
|
|
913
|
+
it("reads one row whole by dedup_key, and misses with null", () => {
|
|
914
|
+
const db = createTestDb();
|
|
915
|
+
const nid = seedNarrative(db);
|
|
916
|
+
recordToolUse(db, nid, {
|
|
917
|
+
name: "Bash", status: "completed", source: "hook",
|
|
918
|
+
input: { command: "ls" }, output: "q".repeat(80_000), dedupKey: "toolu_one",
|
|
919
|
+
});
|
|
920
|
+
assert.equal(readToolUse(db, nid, "toolu_one").output.length, 80_000);
|
|
921
|
+
assert.equal(readToolUse(db, nid, "toolu_absent"), null);
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
describe("settleDaemonCall", () => {
|
|
925
|
+
const LISTEN = "mcp__brane__listen";
|
|
926
|
+
it("replaces the harness's background-cutoff stamp with the real duration", () => {
|
|
927
|
+
const db = createTestDb();
|
|
928
|
+
const nid = seedNarrative(db);
|
|
929
|
+
const startedAt = Date.now() - 600_000;
|
|
930
|
+
// What the hooks leave behind for a listen that outlived two minutes:
|
|
931
|
+
// PreToolUse opened it, PostToolUse closed it at Claude Code's 120s
|
|
932
|
+
// background cutoff while it was still blocking.
|
|
933
|
+
recordToolUse(db, nid, {
|
|
934
|
+
name: LISTEN, status: "completed", source: "hook",
|
|
935
|
+
startedAt, durationMs: 120_004, dedupKey: "toolu_long",
|
|
936
|
+
});
|
|
937
|
+
const settled = settleDaemonCall(db, nid, LISTEN, startedAt, startedAt + 600_000);
|
|
938
|
+
assert.equal(settled?.duration_ms, 600_000);
|
|
939
|
+
assert.equal(settled?.status, "completed");
|
|
940
|
+
assert.equal(settled?.dedup_key, "toolu_long");
|
|
941
|
+
});
|
|
942
|
+
it("bumps rev, so a resuming client sees the correction", () => {
|
|
943
|
+
const db = createTestDb();
|
|
944
|
+
const nid = seedNarrative(db);
|
|
945
|
+
const startedAt = Date.now() - 300_000;
|
|
946
|
+
const before = recordToolUse(db, nid, {
|
|
947
|
+
name: LISTEN, status: "pending", source: "hook", startedAt, dedupKey: "toolu_pending",
|
|
948
|
+
});
|
|
949
|
+
const settled = settleDaemonCall(db, nid, LISTEN, startedAt, startedAt + 300_000);
|
|
950
|
+
assert.ok(settled);
|
|
951
|
+
assert.ok(settled.rev > before.rev, "a settle that kept its rev would be invisible");
|
|
952
|
+
assert.equal(settled.status, "completed", "a call the daemon answered did not stay pending");
|
|
953
|
+
assert.equal(maxToolUseRev(db, nid), settled.rev);
|
|
954
|
+
});
|
|
955
|
+
it("only extends: a shorter answer is not this call's ending", () => {
|
|
956
|
+
const db = createTestDb();
|
|
957
|
+
const nid = seedNarrative(db);
|
|
958
|
+
const startedAt = Date.now() - 60_000;
|
|
959
|
+
recordToolUse(db, nid, {
|
|
960
|
+
name: LISTEN, status: "completed", source: "hook",
|
|
961
|
+
startedAt, durationMs: 60_000, dedupKey: "toolu_done",
|
|
962
|
+
});
|
|
963
|
+
assert.equal(settleDaemonCall(db, nid, LISTEN, startedAt, startedAt + 5_000), null);
|
|
964
|
+
const row = readToolUseWindow(db, nid, {}).find((r) => r.dedup_key === "toolu_done");
|
|
965
|
+
assert.equal(row?.duration_ms, 60_000, "a stray settle must not rewrite a finished call");
|
|
966
|
+
});
|
|
967
|
+
it("keeps a failure's verdict while fixing its clock", () => {
|
|
968
|
+
const db = createTestDb();
|
|
969
|
+
const nid = seedNarrative(db);
|
|
970
|
+
const startedAt = Date.now() - 200_000;
|
|
971
|
+
recordToolUse(db, nid, {
|
|
972
|
+
name: LISTEN, status: "failed", source: "hook",
|
|
973
|
+
startedAt, durationMs: 120_004, error: "bridge closed", dedupKey: "toolu_failed",
|
|
974
|
+
});
|
|
975
|
+
const settled = settleDaemonCall(db, nid, LISTEN, startedAt, startedAt + 200_000);
|
|
976
|
+
assert.equal(settled?.status, "failed", "settling is not evidence against a recorded failure");
|
|
977
|
+
assert.equal(settled?.duration_ms, 200_000);
|
|
978
|
+
});
|
|
979
|
+
it("ignores rows outside the call's own window, and misses quietly", () => {
|
|
980
|
+
const db = createTestDb();
|
|
981
|
+
const nid = seedNarrative(db);
|
|
982
|
+
const now = Date.now();
|
|
983
|
+
// An older listen, long finished — a settle for a call that started a
|
|
984
|
+
// minute later must not reach back and rewrite it.
|
|
985
|
+
recordToolUse(db, nid, {
|
|
986
|
+
name: LISTEN, status: "completed", source: "hook",
|
|
987
|
+
startedAt: now - 3_600_000, durationMs: 1_000, dedupKey: "toolu_ancient",
|
|
988
|
+
});
|
|
989
|
+
assert.equal(settleDaemonCall(db, nid, LISTEN, now, now + 1_000), null);
|
|
990
|
+
// No row at all is the ordinary case for a listen that resolved before its
|
|
991
|
+
// PreToolUse hook landed; it must not throw.
|
|
992
|
+
assert.equal(settleDaemonCall(db, "claude-nosuch", LISTEN, now, now + 1), null);
|
|
993
|
+
});
|
|
994
|
+
it("settles the listen, not a neighbouring call of another name", () => {
|
|
995
|
+
const db = createTestDb();
|
|
996
|
+
const nid = seedNarrative(db);
|
|
997
|
+
const startedAt = Date.now() - 120_000;
|
|
998
|
+
recordToolUse(db, nid, {
|
|
999
|
+
name: "Bash", status: "pending", source: "hook", startedAt, dedupKey: "toolu_bash",
|
|
1000
|
+
});
|
|
1001
|
+
assert.equal(settleDaemonCall(db, nid, LISTEN, startedAt, startedAt + 120_000), null);
|
|
1002
|
+
const bash = readToolUseWindow(db, nid, {}).find((r) => r.dedup_key === "toolu_bash");
|
|
1003
|
+
assert.equal(bash?.status, "pending", "an unrelated tool keeps whatever its own hooks said");
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
//# sourceMappingURL=tool-uses.test.js.map
|