Files
joelhooks__joelclaw/packages/memory/__tests__/chunking.test.ts
Joel Hooks e357bcc609 feat(memory): Phase 1 slice — embed priority lanes (ADR-0243 Rule 9a)
Validates ADR-0243 Rule 9a with a decisive contention test. Priority-lane
embed scheduling measured at 338ms query latency with 98 ingest-bulk embeds
queued — a ~25x improvement over the 8-10s observed in the spike without
priority discipline.

@joelclaw/inference-router
  - new src/embeddings.ts — PriorityEmbedClient with single-writer queue
    honoring query > ingest-realtime > ingest-bulk; embed() returns queued_ms,
    compute_ms, total_ms for observability
  - new __tests__/embeddings-priority.test.ts — drives 100 ingest-bulk embeds
    then submits one query-priority embed mid-stream; asserts query < 3s
    (observed 338ms); skips gracefully when Ollama is unreachable
  - vitest added as devDependency; tests pass under vitest 4

@joelclaw/memory
  - new __tests__/chunking.test.ts — 12 tests locking in the tool_result
    role detection fix that landed during the spike, format detection
    (claude-code vs pi), meta-entry filtering, thinking-content handling,
    oversized-turn sub-chunk splitting, and malformed-JSON tolerance

packages/system-bus
  - memory/embed.requested + memory/embed.completed event contracts added
    to Events map
  - new functions/memory/embed.ts — Inngest durable wrapper, concurrency:1
    (matches Ollama internal serialization), priority.run expression mapping
    the three lanes to numeric scheduling scores, OTEL emission + fanout
    completed event; calls @joelclaw/inference-router under the hood

Query path bypasses Inngest and calls the router directly (fast, no
durability needed). Only ingest paths fire memory/embed.requested. Both
converge on the same priority queue — that's where the contention is
resolved.

ADR-0243 status: accepted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:15:21 -07:00

201 lines
6.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
chunkTurns,
detectFormat,
extractTurns,
parseJsonl,
type RawJsonlEntry,
} from "../src/chunking";
describe("detectFormat", () => {
it("detects claude-code by permission-mode entry", () => {
const entries: RawJsonlEntry[] = [
{ type: "permission-mode" },
{ type: "user", message: { role: "user", content: "hi" } },
];
expect(detectFormat(entries)).toBe("claude-code");
});
it("detects pi by session entry", () => {
const entries: RawJsonlEntry[] = [
{ type: "session", version: 3 } as RawJsonlEntry,
{ type: "message", message: { role: "user", content: "hi" } },
];
expect(detectFormat(entries)).toBe("pi");
});
it("detects pi when only message entries are present", () => {
const entries: RawJsonlEntry[] = [
{ type: "message", message: { role: "user", content: "hi" } },
];
expect(detectFormat(entries)).toBe("pi");
});
});
describe("extractTurns — claude-code", () => {
it("treats a user-type entry carrying a tool_result as role=tool, not role=user", () => {
// This is the bug the spike retrieval revealed: claude-code wraps
// tool_results inside `type:user` entries; the chunker must detect
// the content shape rather than trust the top-level type field.
const entries: RawJsonlEntry[] = [
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
content: "stdout from the tool call",
},
],
},
timestamp: "2026-04-19T12:00:00.000Z",
} as RawJsonlEntry,
];
const turns = extractTurns(entries, "claude-code");
expect(turns).toHaveLength(1);
expect(turns[0]!.role).toBe("tool");
});
it("preserves user role for genuine user prompts", () => {
const entries: RawJsonlEntry[] = [
{
type: "user",
message: { role: "user", content: "what broke the cluster" },
timestamp: "2026-04-19T12:00:00.000Z",
} as RawJsonlEntry,
];
const turns = extractTurns(entries, "claude-code");
expect(turns).toHaveLength(1);
expect(turns[0]!.role).toBe("user");
});
it("promotes assistant to role=tool when the turn contains tool_use", () => {
const entries: RawJsonlEntry[] = [
{
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
],
},
timestamp: "2026-04-19T12:00:00.000Z",
} as RawJsonlEntry,
];
const turns = extractTurns(entries, "claude-code");
expect(turns).toHaveLength(1);
expect(turns[0]!.role).toBe("tool");
});
it("skips meta entries (permission-mode, file-history-snapshot, system, attachment)", () => {
const entries: RawJsonlEntry[] = [
{ type: "permission-mode" },
{ type: "file-history-snapshot" },
{ type: "system", content: "note" } as RawJsonlEntry,
{ type: "attachment" },
{
type: "user",
message: { role: "user", content: "real message" },
timestamp: "2026-04-19T12:00:00.000Z",
} as RawJsonlEntry,
];
const turns = extractTurns(entries, "claude-code");
expect(turns).toHaveLength(1);
expect(turns[0]!.text).toBe("real message");
});
});
describe("extractTurns — pi", () => {
it("maps pi message roles to user/assistant/tool correctly", () => {
const entries: RawJsonlEntry[] = [
{
type: "message",
message: { role: "user", content: "do the thing" },
timestamp: "2026-04-19T12:00:00.000Z",
} as RawJsonlEntry,
{
type: "message",
message: { role: "assistant", content: "ok doing it" },
timestamp: "2026-04-19T12:00:01.000Z",
} as RawJsonlEntry,
{
type: "message",
message: {
role: "toolResult",
content: [{ type: "text", text: "stdout" }],
},
timestamp: "2026-04-19T12:00:02.000Z",
} as RawJsonlEntry,
];
const turns = extractTurns(entries, "pi");
expect(turns.map((t) => t.role)).toEqual(["user", "assistant", "tool"]);
});
it("includes thinking content with a [thinking] marker", () => {
const entries: RawJsonlEntry[] = [
{
type: "message",
message: {
role: "assistant",
content: [{ type: "thinking", thinking: "internal reasoning" }],
},
timestamp: "2026-04-19T12:00:00.000Z",
} as RawJsonlEntry,
];
const turns = extractTurns(entries, "pi");
expect(turns).toHaveLength(1);
expect(turns[0]!.text).toContain("[thinking]");
expect(turns[0]!.text).toContain("internal reasoning");
});
});
describe("chunkTurns", () => {
it("preserves one chunk per turn for small inputs", () => {
const chunks = chunkTurns([
{
role: "user",
text: "small",
started_at: 1,
token_estimate: 2,
},
{
role: "assistant",
text: "also small",
started_at: 2,
token_estimate: 3,
},
]);
expect(chunks).toHaveLength(2);
expect(chunks[0]!.chunk_idx).toBe(0);
expect(chunks[1]!.chunk_idx).toBe(1);
});
it("splits oversized turns into sub-chunks with overlap", () => {
// 40K chars ≈ 10K tokens — well over the 8K MAX_CHUNK_TOKENS ceiling
const bigText = "x".repeat(40_000);
const chunks = chunkTurns([
{
role: "assistant",
text: bigText,
started_at: 1,
token_estimate: 10_000,
},
]);
expect(chunks.length).toBeGreaterThan(1);
// Sub-chunk indices are idx*1000+sub to stay sortable alongside other turns.
expect(chunks[0]!.chunk_idx).toBe(0);
expect(chunks[1]!.chunk_idx).toBe(1);
});
});
describe("parseJsonl", () => {
it("tolerates empty lines and malformed JSON", () => {
const content = `{"type":"session"}\n\nnot valid json\n{"type":"message","message":{"role":"user","content":"ok"}}\n`;
const entries = parseJsonl(content);
expect(entries).toHaveLength(2);
expect(entries[0]!.type).toBe("session");
expect(entries[1]!.type).toBe("message");
});
});