diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts index f54fc6be1..e83eb4f67 100644 --- a/plugin/opencode/agentmemory-capture.ts +++ b/plugin/opencode/agentmemory-capture.ts @@ -147,6 +147,29 @@ function safeSlice(v: unknown, max: number): string { try { return JSON.stringify(v).slice(0, max); } catch { return ""; } } +function normalizePatchData(part: Record): { files: string[]; title: string } { + const raw = (part as Record)?.files; + const files = Array.isArray(raw) ? (raw as unknown[]).filter((f): f is string => typeof f === "string").slice(0, 50) : []; + return { files, title: `Applied patch to ${files.length} file(s)` }; +} + +function normalizeCommandData(props: Record): { name: string | undefined; arguments: string; title: string } { + const name = typeof props?.name === "string" ? props.name : undefined; + return { + name, + arguments: safeSlice(props?.arguments, 2000), + title: `Executed command: ${name ?? ""}`, + }; +} + +function normalizeSubagentTitle(part: Record): string { + return `Started subagent: ${safeSlice((part as Record)?.description || (part as Record)?.agent || (part as Record)?.prompt, 120)}`; +} + +function normalizeTaskTitle(completed: unknown[], todos: unknown[]): string { + return `Task completed: ${completed.length}/${todos.length} items`; +} + const AGENTMEMORY_INSTRUCTIONS = ` You have access to agentmemory for persistent cross-session memory. Use these tools proactively. @@ -412,6 +435,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { agent: part.agent, prompt: safeSlice(part.prompt, 4000), description: safeSlice(part.description, 2000), + title: normalizeSubagentTitle(part as Record), }); return; } @@ -489,10 +513,12 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { } if (part.type === "patch") { + const { files, title } = normalizePatchData(part as Record); await observe(sid, "patch_applied", { messageID: part.messageID, hash: (part as any).hash, - files: (part as any).files || [], + files, + title, }); return; } @@ -574,6 +600,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { completed: completed.map((t: any) => ({ content: t.content, priority: t.priority })), in_progress: active.map((t: any) => ({ content: t.content, priority: t.priority })), total: todos.length, + title: normalizeTaskTitle(completed, todos), }); } @@ -581,9 +608,11 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { if (type === "command.executed") { const sid = props.sessionID || activeSessionId; if (sid) { + const { title } = normalizeCommandData(props as Record); await observe(sid, "command_executed", { name: props.name, arguments: props.arguments || "", + title, }); } } @@ -745,3 +774,12 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { }, }; }; + +Object.assign(AgentmemoryCapturePlugin, { + normalizePatchData, + normalizeCommandData, + normalizeSubagentTitle, + normalizeTaskTitle, +}); + +export default AgentmemoryCapturePlugin; diff --git a/src/functions/compress-synthetic.ts b/src/functions/compress-synthetic.ts index 14f757ce1..49f9bf8a0 100644 --- a/src/functions/compress-synthetic.ts +++ b/src/functions/compress-synthetic.ts @@ -3,25 +3,23 @@ import type { CompressedObservation, ObservationType, } from "../types.js"; - -// Zero-LLM compression path. Converts a RawObservation into a -// CompressedObservation using only heuristics — no Claude call, no token -// spend. This is the default as of 0.8.8 (#138); users who want richer -// LLM-generated summaries set AGENTMEMORY_AUTO_COMPRESS=true. +import { TELEMETRY_HOOKS } from "../types.js"; +export { TELEMETRY_HOOKS } from "../types.js"; function inferType( toolName: string | undefined, hookType: string, ): ObservationType { + if (TELEMETRY_HOOKS.has(hookType as never)) return "other"; if (hookType === "post_tool_failure") return "error"; if (hookType === "prompt_submit") return "conversation"; - if (hookType === "subagent_stop" || hookType === "task_completed") + if (hookType === "patch_applied") return "file_edit"; + if (hookType === "command_executed") return "command_run"; + if (hookType === "subagent_start" || hookType === "subagent_stop" || hookType === "task_completed") return "subagent"; if (hookType === "notification") return "notification"; if (!toolName) return "other"; - // Normalize camelCase and kebab-case into word chunks so we can match - // substrings like "WebFetch" -> "web" / "fetch". const n = toolName .replace(/([a-z])([A-Z])/g, "$1_$2") .replace(/[-\s]+/g, "_") @@ -42,7 +40,13 @@ function inferType( } function extractFiles(input: unknown): string[] { - if (!input || typeof input !== "object") return []; + if (!input) return []; + if (Array.isArray(input)) { + return (input as unknown[]).filter( + (v): v is string => typeof v === "string" && v.length > 0 && v.length < 512, + ) as string[]; + } + if (typeof input !== "object") return []; const o = input as Record; const out = new Set(); for (const key of [ @@ -80,22 +84,119 @@ export function buildSyntheticCompression( const inputStr = stringifyForNarrative(raw.toolInput); const outputStr = stringifyForNarrative(raw.toolOutput); const promptStr = raw.userPrompt ?? ""; + const contentStr = raw.content ?? ""; + const titleStr = typeof raw.title === "string" ? raw.title : ""; + + if (raw.isTelemetry || TELEMETRY_HOOKS.has(raw.hookType as never)) { + const result: CompressedObservation = { + id: raw.id, + sessionId: raw.sessionId, + timestamp: raw.timestamp, + type: inferType(toolName, raw.hookType), + title: truncate(titleStr || toolName || "observation", 80), + subtitle: undefined, + facts: [], + narrative: "", + concepts: [], + files: [], + importance: 5, + confidence: 0.3, + isTelemetry: true, + }; + if (raw.modality) result.modality = raw.modality; + if (raw.imageData) result.imageData = raw.imageData; + if (raw.agentId) result.agentId = raw.agentId; + if (raw.origin) result.origin = raw.origin; + return result; + } + + const isZeroContent = + titleStr.trim().length === 0 && + inputStr.trim().length === 0 && + outputStr.trim().length === 0 && + promptStr.trim().length === 0 && + contentStr.trim().length === 0 && + (!Array.isArray(raw.files) || raw.files.length === 0); + + if (isZeroContent) { + const result: CompressedObservation = { + id: raw.id, + sessionId: raw.sessionId, + timestamp: raw.timestamp, + type: inferType(toolName, raw.hookType), + title: "", + subtitle: undefined, + facts: [], + narrative: "", + concepts: [], + files: [], + importance: 5, + confidence: 0.3, + }; + if (raw.modality) result.modality = raw.modality; + if (raw.imageData) result.imageData = raw.imageData; + if (raw.agentId) result.agentId = raw.agentId; + if (raw.origin) result.origin = raw.origin; + return result; + } - const narrativeParts = [promptStr, inputStr, outputStr].filter( + let narrative: string; + const narrativeParts = [promptStr, inputStr, outputStr, contentStr].filter( (s) => s.length > 0, ); + narrative = narrativeParts.join(" | "); + if (narrative.length === 0 && titleStr.length > 0) narrative = titleStr; + + const filesFromInput = extractFiles(raw.toolInput); + let files: string[]; + if (Array.isArray(raw.files) && raw.files.length > 0) { + const dedup = new Set(); + for (const f of filesFromInput) dedup.add(f); + for (const f of raw.files) { + if (typeof f === "string" && f.length > 0 && f.length < 512) { + dedup.add(f); + if (dedup.size >= 20) break; + } + } + files = [...dedup].slice(0, 20); + } else { + files = filesFromInput; + if (Array.isArray(raw.files) && raw.files.length === 0) { + files = []; + } + if (Array.isArray(raw.toolInput) && files.length === 0) { + const arrFiles = (raw.toolInput as unknown[]).filter( + (v): v is string => typeof v === "string" && v.length > 0 && v.length < 512, + ) as string[]; + if (arrFiles.length > 0) files = arrFiles.slice(0, 20); + } + } + + if (Array.isArray(raw.files) && raw.files.length > 0 && files.length === 0) { + const rawFileList = raw.files.filter( + (v): v is string => typeof v === "string" && v.length > 0 && v.length < 512, + ) as string[]; + files = rawFileList.slice(0, 20); + } + + const effectiveTitle = titleStr.length > 0 ? titleStr : truncate(toolName || "observation", 80); + const effectiveSubtitle = inputStr + ? truncate(inputStr, 120) + : titleStr + ? truncate(titleStr, 120) + : undefined; const result: CompressedObservation = { id: raw.id, sessionId: raw.sessionId, timestamp: raw.timestamp, type: inferType(toolName, raw.hookType), - title: truncate(toolName || "observation", 80), - subtitle: inputStr ? truncate(inputStr, 120) : undefined, + title: truncate(effectiveTitle, 80), + subtitle: effectiveSubtitle, facts: [], - narrative: truncate(narrativeParts.join(" | "), 400), + narrative: truncate(narrative, 400), concepts: [], - files: extractFiles(raw.toolInput), + files, importance: 5, confidence: 0.3, }; diff --git a/src/functions/observe.ts b/src/functions/observe.ts index c1c9f499b..1d8f0113f 100644 --- a/src/functions/observe.ts +++ b/src/functions/observe.ts @@ -1,7 +1,21 @@ import { TriggerAction, type ISdk } from "iii-sdk"; import type { RawObservation, HookPayload, Origin } from "../types.js"; +import { TELEMETRY_HOOKS } from "../types.js"; const TOOL_HOOKS = new Set(["pre_tool_use", "post_tool_use", "post_tool_failure"]); + +function extractStringFiles(value: unknown, cap: number): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + for (const item of value) { + if (typeof item === "string" && item.length > 0) { + out.push(item); + if (out.length >= cap) break; + } + } + return out; +} + import { KV, STREAM, generateId } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { stripPrivateData } from "./privacy.js"; @@ -128,6 +142,74 @@ export function registerObserveFunction( } if (payload.hookType === "prompt_submit") { raw.userPrompt = d["prompt"] as string | undefined; + const promptFiles = extractStringFiles(d["files"], 20); + if (promptFiles.length > 0) raw.files = promptFiles; + } + if (payload.hookType === "patch_applied") { + const files = extractStringFiles(d["files"], 50); + raw.files = files; + raw.title = `Applied patch to ${files.length} file(s)`; + } + if (payload.hookType === "command_executed") { + const nameVal = d["name"]; + const isStringName = typeof nameVal === "string"; + const name = isStringName ? nameVal : undefined; + if (name) { + raw.toolName = name; + if (raw.origin) raw.origin.detail = name; + } else if (nameVal !== undefined && nameVal !== null) { + raw.toolName = String(nameVal); + } + const args = d["arguments"]; + if (args !== undefined && args !== null) { + const s = String(args); + if (s.length > 0) raw.toolInput = s.length > 2000 ? s.slice(0, 2000) : s; + } + const titleName = isStringName ? nameVal : String(nameVal ?? "unknown"); + raw.title = `Executed command: ${titleName}`; + } + if (payload.hookType === "subagent_start") { + const desc = typeof d["description"] === "string" ? d["description"] : undefined; + const agent = typeof d["agent"] === "string" ? d["agent"] : undefined; + const promptVal = typeof d["prompt"] === "string" ? d["prompt"] : undefined; + let titleSeed: string | undefined = desc || agent; + if (!titleSeed && promptVal) titleSeed = promptVal.slice(0, 120); + if (!titleSeed) titleSeed = "unknown"; + raw.title = `Started subagent: ${titleSeed}`; + if (promptVal !== undefined) { + raw.toolInput = promptVal.length > 4000 ? promptVal.slice(0, 4000) : promptVal; + } else if (d["prompt"] !== undefined && d["prompt"] !== null) { + const s = String(d["prompt"]); + raw.toolInput = s.length > 4000 ? s.slice(0, 4000) : s; + } + if (raw.toolName === undefined && agent) { + raw.toolName = agent; + if (raw.origin) raw.origin.detail = agent; + } + } + if (payload.hookType === "task_completed") { + const completed = d["completed"]; + const completedLen = Array.isArray(completed) ? completed.length : 0; + let total = 0; + if (typeof d["total"] === "number") total = d["total"]; + else if (typeof d["total"] === "string") total = Number(d["total"]) || 0; + raw.title = `Task completed: ${completedLen}/${total} items`; + if (Array.isArray(completed)) { + const contents = (completed as unknown[]) + .map((item) => { + if (item && typeof item === "object" && typeof (item as Record).content === "string") { + return (item as Record).content as string; + } + return ""; + }) + .filter(Boolean) + .join("; "); + if (contents.length > 0) raw.toolInput = contents.slice(0, 4000); + else if (completedLen > 0) raw.toolInput = `${completedLen} items`; + } + } + if (TELEMETRY_HOOKS.has(payload.hookType)) { + raw.isTelemetry = true; } extractedImage = extractImage(sanitizedRaw); diff --git a/src/functions/summarize.ts b/src/functions/summarize.ts index 4c501ca8c..93936cf7d 100644 --- a/src/functions/summarize.ts +++ b/src/functions/summarize.ts @@ -50,6 +50,30 @@ function getChunkConcurrency(): number { return Number.isFinite(n) && n > 0 ? n : CHUNK_CONCURRENCY_DEFAULT; } +export function filterObservationsForSummary( + observations: CompressedObservation[], +): CompressedObservation[] { + const out: CompressedObservation[] = []; + for (const o of observations) { + if (o.isTelemetry === true) continue; + const anyO = o as unknown as Record; + const hasTitle = typeof o.title === "string" && o.title.trim().length > 0; + const hasNarrative = typeof o.narrative === "string" && o.narrative.trim().length > 0; + const hasFacts = Array.isArray(o.facts) && o.facts.length > 0; + const hasFiles = Array.isArray(o.files) && o.files.length > 0; + const hasToolInput = anyO["toolInput"] !== undefined && anyO["toolInput"] !== null && String(anyO["toolInput"]).trim().length > 0; + const hasToolOutput = anyO["toolOutput"] !== undefined && anyO["toolOutput"] !== null && String(anyO["toolOutput"]).trim().length > 0; + const hasUserPrompt = typeof anyO["userPrompt"] === "string" && (anyO["userPrompt"] as string).trim().length > 0; + const hasContent = typeof anyO["content"] === "string" && (anyO["content"] as string).trim().length > 0; + const hasSubtitle = typeof anyO["subtitle"] === "string" && (anyO["subtitle"] as string).trim().length > 0; + if (!hasTitle && !hasNarrative && !hasFacts && !hasFiles && !hasToolInput && !hasToolOutput && !hasUserPrompt && !hasContent && !hasSubtitle) { + continue; + } + out.push(o); + } + return out; +} + // One chunk call with retry-once. Returns null when both attempts fail — // whether by parse failure, provider 4xx (content rejected by upstream // filters), or transient network/5xx errors that didn't recover on retry. @@ -251,7 +275,7 @@ export function registerSummarizeFunction( const observations = await kv.list( KV.observations(sessionId), ); - const compressed = observations.filter((o) => o.title); + const compressed = filterObservationsForSummary(observations); if (compressed.length === 0) { logger.info("No observations to summarize", { diff --git a/src/prompts/summary.ts b/src/prompts/summary.ts index bd0402127..df639ea50 100644 --- a/src/prompts/summary.ts +++ b/src/prompts/summary.ts @@ -31,8 +31,19 @@ export function buildSummaryPrompt(observations: Array<{ concepts: string[] }>): string { const lines = observations.map((obs, i) => { - const facts = obs.facts.map((f) => ` - ${f}`).join('\n') - return `[${i + 1}] ${obs.type}: ${obs.title}\n${obs.narrative}\nFacts:\n${facts}\nFiles: ${obs.files.join(', ')}` + const header = `[${i + 1}] ${obs.type}: ${obs.title}`; + const parts: string[] = [header]; + const narrative = typeof obs.narrative === "string" ? obs.narrative : ""; + if (narrative && narrative.trim().length > 0) parts.push(narrative); + const facts = Array.isArray(obs.facts) ? obs.facts : []; + if (facts.length > 0) { + parts.push(`Facts:\n${facts.map((f) => ` - ${f}`).join("\n")}`); + } + const files = Array.isArray(obs.files) ? obs.files : []; + if (files.length > 0) { + parts.push(`Files: ${files.join(", ")}`); + } + return parts.join("\n"); }) return `Session observations (${observations.length} total):\n\n${lines.join('\n\n---\n\n')}` } diff --git a/src/types.ts b/src/types.ts index d2c63efa6..c0055e231 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export interface Session { cwd: string; startedAt: string; endedAt?: string; + updatedAt?: string; status: "active" | "completed" | "abandoned"; observationCount: number; model?: string; @@ -53,7 +54,11 @@ export interface RawObservation { toolInput?: unknown; toolOutput?: unknown; userPrompt?: string; + content?: string; assistantResponse?: string; + title?: string; + files?: string[]; + isTelemetry?: boolean; raw: unknown; modality?: "text" | "image" | "mixed"; imageData?: string; @@ -80,6 +85,7 @@ export interface CompressedObservation { modality?: "text" | "image" | "mixed"; agentId?: string; origin?: Origin; + isTelemetry?: boolean; } export type ObservationType = @@ -148,7 +154,45 @@ export type HookType = | "notification" | "task_completed" | "stop" - | "session_end"; + | "session_end" + | "patch_applied" + | "command_executed" + | "assistant_message" + | "session_status" + | "session_updated" + | "session_compacted" + | "step_finish" + | "reasoning" + | "llm_params" + | "config_loaded" + | "message_removed" + | "permission_replied" + | "compaction_event" + | "retry_attempt" + | "session_diff" + | "invalid" + | "council_session" + | "permission_prompt"; + +export const TELEMETRY_HOOKS: ReadonlySet = new Set([ + "assistant_message", + "session_status", + "session_updated", + "session_compacted", + "config_loaded", + "llm_params", + "reasoning", + "step_finish", + "message_removed", + "permission_replied", + "compaction_event", + "session_diff", + "invalid", + "notification", + "retry_attempt", + "council_session", + "permission_prompt", +]); export interface HookPayload { hookType: HookType; diff --git a/test/observe-telemetry.test.ts b/test/observe-telemetry.test.ts new file mode 100644 index 000000000..9e9f1d5da --- /dev/null +++ b/test/observe-telemetry.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { RawObservation } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + update: async (scope: string, key: string, updates: Array<{ path: string; value: unknown }>) => { + const m = store.get(scope); + if (!m) return; + const v = (m.get(key) as Record) ?? {}; + for (const u of updates) v[u.path] = u.value; + m.set(key, v); + }, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const fns = new Map(); + return { + fns, + registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + fns.set(id, fn); + }, + registerTrigger: vi.fn(), + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown; action?: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : (idOrInput.payload as unknown); + const fn = fns.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +function payload(hookType: string, data: unknown) { + return { + sessionId: "ses_telemetry", + project: "/home/user/myrepo", + cwd: "/home/user/myrepo", + hookType, + timestamp: new Date().toISOString(), + data, + }; +} + +async function observeAndGetRaw(hookType: string, data: unknown): Promise { + process.env["AGENTMEMORY_AUTO_COMPRESS"] = "true"; + const { registerObserveFunction } = await import("../src/functions/observe.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never); + const result = (await sdk.trigger("mem::observe", payload(hookType, data))) as { observationId: string }; + expect(result.observationId).toBeTruthy(); + const scope = `mem:obs:ses_telemetry`; + const stored = kv.store.get(scope); + expect(stored).toBeTruthy(); + const entry = Array.from(stored!.values())[0] as RawObservation & Record; + process.env["AGENTMEMORY_AUTO_COMPRESS"] = "false"; + return entry as RawObservation; +} + +describe("observe telemetry layer 1", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("patch_applied extracts files + title (strings only, cap 50)", async () => { + const raw = await observeAndGetRaw("patch_applied", { + files: ["src/a.ts", "src/b.ts", 123, null, "src/c.ts"], + hash: "abc123", + }); + expect(raw.files).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]); + expect(raw.title).toBe("Applied patch to 3 file(s)"); + }); + + it("patch_applied with no files array yields empty files and Applied patch to 0 file(s) title", async () => { + const raw = await observeAndGetRaw("patch_applied", { hash: "abc123" }); + expect(raw.files).toEqual([]); + expect(raw.title).toBe("Applied patch to 0 file(s)"); + }); + + it("patch_applied caps files at 50", async () => { + const many = Array.from({ length: 60 }, (_, i) => `src/file${i}.ts`); + const raw = await observeAndGetRaw("patch_applied", { files: many }); + expect(raw.files!.length).toBe(50); + expect(raw.title).toBe("Applied patch to 50 file(s)"); + }); + + it("command_executed extracts toolName/toolInput + title", async () => { + const raw = await observeAndGetRaw("command_executed", { + name: "npm test", + arguments: "npm run test --coverage", + }); + expect(raw.toolName).toBe("npm test"); + expect(raw.toolInput).toBe("npm run test --coverage"); + expect(raw.title).toBe("Executed command: npm test"); + }); + + it("command_executed slices long arguments to 2000", async () => { + const longArgs = "x".repeat(3000); + const raw = await observeAndGetRaw("command_executed", { + name: "bash", + arguments: longArgs, + }); + expect((raw.toolInput as string).length).toBe(2000); + }); + + it("command_executed with missing arguments yields undefined toolInput", async () => { + const raw = await observeAndGetRaw("command_executed", { name: "ls" }); + expect(raw.toolInput).toBeUndefined(); + expect(raw.title).toBe("Executed command: ls"); + }); + + it("subagent_start title from description", async () => { + const raw = await observeAndGetRaw("subagent_start", { + description: "Explore codebase", + agent: "Explore", + prompt: "find all files", + }); + expect(raw.title).toBe("Started subagent: Explore codebase"); + expect(raw.toolInput).toBe("find all files"); + }); + + it("subagent_start title falls back to agent when description missing", async () => { + const raw = await observeAndGetRaw("subagent_start", { + agent: "Plan", + prompt: "make a plan", + }); + expect(raw.title).toBe("Started subagent: Plan"); + }); + + it("subagent_start title falls back to prompt slice 120 when description and agent missing", async () => { + const longPrompt = "a".repeat(200); + const raw = await observeAndGetRaw("subagent_start", { + prompt: longPrompt, + }); + expect(raw.title).toBe(`Started subagent: ${longPrompt.slice(0, 120)}`); + }); + + it("subagent_start toolInput sliced to 4000 when prompt is long", async () => { + const longPrompt = "y".repeat(5000); + const raw = await observeAndGetRaw("subagent_start", { + prompt: longPrompt, + description: "desc", + }); + expect((raw.toolInput as string).length).toBe(4000); + }); + + it("task_completed title counts completed/total", async () => { + const raw = await observeAndGetRaw("task_completed", { + completed: [{ content: "done 1" }, { content: "done 2" }], + total: 5, + }); + expect(raw.title).toBe("Task completed: 2/5 items"); + }); + + it("task_completed handles missing completed/total", async () => { + const raw = await observeAndGetRaw("task_completed", {}); + expect(raw.title).toBe("Task completed: 0/0 items"); + }); + + it("prompt_submit keeps userPrompt and extracts files cap 20 strings only", async () => { + const raw = await observeAndGetRaw("prompt_submit", { + prompt: "hello world", + files: ["src/a.ts", "src/b.ts", 42, null, "src/c.ts"], + }); + expect(raw.userPrompt).toBe("hello world"); + expect(raw.files).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]); + }); + + it("prompt_submit files capped at 20", async () => { + const many = Array.from({ length: 30 }, (_, i) => `src/file${i}.ts`); + const raw = await observeAndGetRaw("prompt_submit", { prompt: "hi", files: many }); + expect(raw.files!.length).toBe(20); + }); + + it("assistant_message is marked isTelemetry", async () => { + const raw = await observeAndGetRaw("assistant_message", { text: "hi" }); + expect(raw.isTelemetry).toBe(true); + }); + + it("session_status is marked isTelemetry", async () => { + const raw = await observeAndGetRaw("session_status", {}); + expect(raw.isTelemetry).toBe(true); + }); + + it("step_finish is marked isTelemetry", async () => { + const raw = await observeAndGetRaw("step_finish", {}); + expect(raw.isTelemetry).toBe(true); + }); + + it("llm_params is marked isTelemetry", async () => { + const raw = await observeAndGetRaw("llm_params", {}); + expect(raw.isTelemetry).toBe(true); + }); + + it("reasoning is marked isTelemetry", async () => { + const raw = await observeAndGetRaw("reasoning", {}); + expect(raw.isTelemetry).toBe(true); + }); + + it("config_loaded is marked isTelemetry", async () => { + const raw = await observeAndGetRaw("config_loaded", {}); + expect(raw.isTelemetry).toBe(true); + }); + + it("session_updated is marked isTelemetry", async () => { + const raw = await observeAndGetRaw("session_updated", {}); + expect(raw.isTelemetry).toBe(true); + }); + + it("notification is marked isTelemetry", async () => { + const raw = await observeAndGetRaw("notification", { message: "hi" }); + expect(raw.isTelemetry).toBe(true); + }); + + it("tool hooks unchanged (tool_input/tool_output still extracted)", async () => { + const raw = await observeAndGetRaw("post_tool_use", { + tool_name: "Read", + tool_input: { file_path: "src/foo.ts" }, + tool_output: "contents", + }); + expect(raw.toolName).toBe("Read"); + expect(raw.toolInput).toEqual({ file_path: "src/foo.ts" }); + expect(raw.toolOutput).toBe("contents"); + expect(raw.isTelemetry).toBeUndefined(); + }); + + it("post_tool_failure still extracts tool fields", async () => { + const raw = await observeAndGetRaw("post_tool_failure", { + tool_name: "Bash", + tool_input: { command: "ls" }, + error: "failed", + }); + expect(raw.toolName).toBe("Bash"); + expect(raw.toolOutput).toBe("failed"); + }); + + it("dedup still works for distinct non-tool events", async () => { + const { registerObserveFunction } = await import("../src/functions/observe.js"); + const { DedupMap } = await import("../src/functions/dedup.js"); + const sdk = mockSdk(); + const kv = mockKV(); + const dedup = new DedupMap(); + registerObserveFunction(sdk as never, kv as never, dedup); + + const first = (await sdk.trigger("mem::observe", payload("patch_applied", { files: ["a.ts"] }))) as { observationId?: string; deduplicated?: boolean }; + const second = (await sdk.trigger("mem::observe", payload("patch_applied", { files: ["b.ts"] }))) as { observationId?: string; deduplicated?: boolean }; + expect(first.observationId).toBeTruthy(); + expect(second.observationId).toBeTruthy(); + expect(second.deduplicated).toBeUndefined(); + + const third = (await sdk.trigger("mem::observe", payload("patch_applied", { files: ["a.ts"] }))) as { deduplicated?: boolean }; + expect(third.deduplicated).toBe(true); + }); +}); + +describe("compress-synthetic layer 2", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("uses title as narrative seed when narrative would otherwise be empty", async () => { + const { buildSyntheticCompression } = await import("../src/functions/compress-synthetic.js"); + const raw: RawObservation = { + id: "obs_1", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + hookType: "patch_applied" as never, + title: "Applied patch to 2 file(s)", + files: ["src/a.ts", "src/b.ts"], + raw: {}, + }; + const synth = buildSyntheticCompression(raw); + expect(synth.narrative).toBe("Applied patch to 2 file(s)"); + expect(synth.title).toBe("Applied patch to 2 file(s)"); + }); + + it("includes files from obs.files (dedup, cap 20)", async () => { + const { buildSyntheticCompression } = await import("../src/functions/compress-synthetic.js"); + const many = Array.from({ length: 25 }, (_, i) => `src/file${i}.ts`); + const raw: RawObservation = { + id: "obs_2", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + hookType: "command_executed" as never, + title: "Executed command: npm test", + toolName: "npm test", + toolInput: { file_path: "src/a.ts" }, + files: many, + raw: {}, + }; + const synth = buildSyntheticCompression(raw); + expect(synth.files.length).toBeLessThanOrEqual(20); + expect(synth.files).toContain("src/a.ts"); + expect(synth.title).toBe("Executed command: npm test"); + }); + + it("telemetry with no title/files/tool fields keeps empty narrative/files", async () => { + const { buildSyntheticCompression } = await import("../src/functions/compress-synthetic.js"); + const raw: RawObservation = { + id: "obs_3", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + hookType: "assistant_message" as never, + isTelemetry: true, + raw: {}, + }; + const synth = buildSyntheticCompression(raw); + expect(synth.narrative).toBe(""); + expect(synth.files).toEqual([]); + }); + + it("keeps existing behavior for toolInput/toolOutput/userPrompt", async () => { + const { buildSyntheticCompression } = await import("../src/functions/compress-synthetic.js"); + const raw: RawObservation = { + id: "obs_4", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + hookType: "post_tool_use", + toolName: "Read", + toolInput: { file_path: "src/foo.ts" }, + toolOutput: "file contents", + raw: {}, + }; + const synth = buildSyntheticCompression(raw); + expect(synth.narrative).toContain("file contents"); + expect(synth.files).toContain("src/foo.ts"); + expect(synth.type).toBe("file_read"); + }); +}); diff --git a/test/opencode-plugin-loader-compatibility.test.ts b/test/opencode-plugin-loader-compatibility.test.ts new file mode 100644 index 000000000..a1f71de9d --- /dev/null +++ b/test/opencode-plugin-loader-compatibility.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; + +function isServerPlugin(value: any): boolean { + return typeof value === "function"; +} + +function getServerPlugin(value: any): any { + if (isServerPlugin(value)) return value; + if (!value || typeof value !== "object" || !("server" in value)) return; + if (!isServerPlugin(value.server)) return; + return value.server; +} + +// Exact implementation of OpenCode's plugin loader from app.asar +function getLegacyPlugins(mod: any): any[] { + const seen = new Set(); + const result: any[] = []; + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue; + seen.add(entry); + const plugin = getServerPlugin(entry); + if (!plugin) throw new TypeError("Plugin export is not a function"); + result.push(plugin); + } + return result; +} + +describe("OpenCode plugin loader compatibility", () => { + it("exports only valid Plugin functions to satisfy OpenCode getLegacyPlugins", async () => { + const mod = await import("../plugin/opencode/agentmemory-capture.ts"); + + // OpenCode loads the module and runs getLegacyPlugins + const plugins = getLegacyPlugins(mod); + + // Must find exactly 1 unique plugin function + expect(plugins.length).toBe(1); + + // When OpenCode executes the plugin function, it must not throw and must return plugin hooks + const pluginFn = plugins[0]; + const input = { + worktree: "/tmp/project", + directory: "/tmp/project", + project: { id: "test-proj", directory: "/tmp/project" }, + }; + const options = undefined; + + const hooks = await pluginFn(input, options); + expect(hooks).toBeDefined(); + expect(typeof hooks.event).toBe("function"); + expect(typeof hooks.config).toBe("function"); + expect(typeof hooks["tool.execute.before"]).toBe("function"); + expect(typeof hooks["chat.message"]).toBe("function"); + }); +}); diff --git a/test/opencode-plugin-standard-fields.test.ts b/test/opencode-plugin-standard-fields.test.ts new file mode 100644 index 000000000..0b2b0000a --- /dev/null +++ b/test/opencode-plugin-standard-fields.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +describe("OpenCode plugin standard fields", () => { + let fetchMock: ReturnType; + let capturedRequests: Array<{ url: string; body: any }>; + + beforeEach(() => { + capturedRequests = []; + fetchMock = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { + const body = init?.body ? JSON.parse(init.body as string) : {}; + capturedRequests.push({ url, body }); + if (typeof url === "string" && url.includes("/session/start")) { + return { ok: true, json: async () => ({ context: "## Start Context" }) }; + } + if (typeof url === "string" && url.includes("/context")) { + return { ok: true, json: async () => ({ context: "## Context" }) }; + } + if (typeof url === "string" && url.includes("/enrich")) { + return { ok: true, json: async () => ({ context: "" }) }; + } + return { ok: true, json: async () => ({}) }; + }); + vi.stubGlobal("fetch", fetchMock); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function createHandlers() { + const { AgentmemoryCapturePlugin } = await import("../plugin/opencode/agentmemory-capture.ts"); + const handlers = await (AgentmemoryCapturePlugin as any)({ worktree: process.cwd() }); + return handlers as any; + } + + async function initSession(handlers: any, sid: string) { + await handlers.event({ + event: { type: "session.created", properties: { info: { id: sid, directory: process.cwd() } } }, + }); + capturedRequests.length = 0; + fetchMock.mockClear(); + } + + it("patch part with files ['a.ts','b.ts'] produces observe payload with title and filtered files", async () => { + const handlers = await createHandlers(); + const sid = "sess-patch-1"; + await initSession(handlers, sid); + + await handlers.event({ + event: { + type: "message.part.updated", + properties: { + sessionID: sid, + part: { type: "patch", messageID: "msg-1", hash: "abc123", files: ["a.ts", "b.ts"] }, + }, + }, + }); + + const observeCalls = capturedRequests.filter((r) => r.url.includes("/observe")); + expect(observeCalls.length).toBe(1); + expect(observeCalls[0].body.hookType).toBe("patch_applied"); + expect(observeCalls[0].body.data.files).toEqual(["a.ts", "b.ts"]); + expect(observeCalls[0].body.data.title).toBe("Applied patch to 2 file(s)"); + expect(observeCalls[0].body.data.messageID).toBe("msg-1"); + expect(observeCalls[0].body.data.hash).toBe("abc123"); + }); + + it("patch normalizes non-string files and caps at 50", async () => { + const { AgentmemoryCapturePlugin } = await import("../plugin/opencode/agentmemory-capture.ts"); + const { normalizePatchData } = AgentmemoryCapturePlugin as any; + const raw = ["a.ts", 123 as any, null as any, "b.ts", undefined as any, {} as any]; + const { files, title } = normalizePatchData({ files: raw } as any); + expect(files).toEqual(["a.ts", "b.ts"]); + expect(title).toBe("Applied patch to 2 file(s)"); + + const many = Array.from({ length: 60 }, (_, i) => `f${i}.ts`); + const capped = normalizePatchData({ files: many } as any); + expect(capped.files.length).toBe(50); + expect(capped.files[0]).toBe("f0.ts"); + expect(capped.files[49]).toBe("f49.ts"); + expect(capped.title).toBe("Applied patch to 50 file(s)"); + + const empty = normalizePatchData({} as any); + expect(empty.files).toEqual([]); + expect(empty.title).toBe("Applied patch to 0 file(s)"); + }); + + it("command_executed includes name/arguments/title (no dead snake_case fields)", async () => { + const handlers = await createHandlers(); + const sid = "sess-cmd-1"; + await initSession(handlers, sid); + + await handlers.event({ + event: { type: "command.executed", properties: { sessionID: sid, name: "my-cmd", arguments: "arg1 --flag" } }, + }); + + const observeCalls = capturedRequests.filter((r) => r.url.includes("/observe")); + expect(observeCalls.length).toBe(1); + expect(observeCalls[0].body.hookType).toBe("command_executed"); + expect(observeCalls[0].body.data.name).toBe("my-cmd"); + expect(observeCalls[0].body.data.arguments).toBe("arg1 --flag"); + expect(observeCalls[0].body.data.title).toBe("Executed command: my-cmd"); + expect(observeCalls[0].body.data.tool_name).toBeUndefined(); + expect(observeCalls[0].body.data.tool_input).toBeUndefined(); + }); + + it("normalizeCommandData safe against undefined fields and truncates, typed return", async () => { + const { AgentmemoryCapturePlugin } = await import("../plugin/opencode/agentmemory-capture.ts"); + const { normalizeCommandData } = AgentmemoryCapturePlugin as any; + const res = normalizeCommandData({} as any); + expect(res.name).toBeUndefined(); + expect(res.arguments).toBe(""); + expect(res.title).toBe("Executed command: "); + + const longArgs = "x".repeat(5000); + const res2 = normalizeCommandData({ name: "cmd", arguments: longArgs } as any); + expect(res2.arguments.length).toBe(2000); + expect(res2.title).toBe("Executed command: cmd"); + expect((res2 as Record).tool_name).toBeUndefined(); + expect((res2 as Record).tool_input).toBeUndefined(); + }); + + it("subagent_start includes title derived from description/agent/prompt", async () => { + const handlers = await createHandlers(); + const sid = "sess-subagent-1"; + await initSession(handlers, sid); + + await handlers.event({ + event: { + type: "message.part.updated", + properties: { + sessionID: sid, + part: { type: "subtask", id: "sub-1", agent: "explore", prompt: "do things", description: "Explore codebase" }, + }, + }, + }); + + const observeCalls = capturedRequests.filter((r) => r.url.includes("/observe")); + expect(observeCalls.length).toBe(1); + expect(observeCalls[0].body.hookType).toBe("subagent_start"); + expect(observeCalls[0].body.data.title).toBe("Started subagent: Explore codebase"); + }); + + it("normalizeSubagentTitle prefers description over agent over prompt and safe slices", async () => { + const { AgentmemoryCapturePlugin } = await import("../plugin/opencode/agentmemory-capture.ts"); + const { normalizeSubagentTitle } = AgentmemoryCapturePlugin as any; + expect(normalizeSubagentTitle({ description: "desc", agent: "ag", prompt: "pr" } as any)).toBe("Started subagent: desc"); + expect(normalizeSubagentTitle({ agent: "ag", prompt: "pr" } as any)).toBe("Started subagent: ag"); + expect(normalizeSubagentTitle({ prompt: "pr" } as any)).toBe("Started subagent: pr"); + expect(normalizeSubagentTitle({} as any)).toBe("Started subagent: "); + const long = "y".repeat(500); + expect(normalizeSubagentTitle({ description: long } as any).length).toBe("Started subagent: ".length + 120); + }); + + it("task_completed includes title with counts", async () => { + const handlers = await createHandlers(); + const sid = "sess-task-1"; + await initSession(handlers, sid); + + await handlers.event({ + event: { + type: "todo.updated", + properties: { + sessionID: sid, + todos: [ + { content: "a", priority: "high", status: "completed" }, + { content: "b", priority: "low", status: "in_progress" }, + { content: "c", priority: "medium", status: "completed" }, + ], + }, + }, + }); + + const observeCalls = capturedRequests.filter((r) => r.url.includes("/observe")); + expect(observeCalls.length).toBe(1); + expect(observeCalls[0].body.hookType).toBe("task_completed"); + expect(observeCalls[0].body.data.title).toBe("Task completed: 2/3 items"); + expect(observeCalls[0].body.data.total).toBe(3); + expect(observeCalls[0].body.data.completed).toHaveLength(2); + }); + + it("normalizeTaskTitle pure function", async () => { + const { AgentmemoryCapturePlugin } = await import("../plugin/opencode/agentmemory-capture.ts"); + const { normalizeTaskTitle } = AgentmemoryCapturePlugin as any; + expect(normalizeTaskTitle([{}, {}], [{}, {}, {}] as any)).toBe("Task completed: 2/3 items"); + expect(normalizeTaskTitle([], [] as any)).toBe("Task completed: 0/0 items"); + }); + + it("assistant_message payload has no title", async () => { + const handlers = await createHandlers(); + const sid = "sess-assistant-1"; + await initSession(handlers, sid); + + await handlers.event({ + event: { + type: "message.updated", + properties: { + sessionID: sid, + info: { + role: "assistant", + id: "msg-assistant-1", + parentID: "parent-1", + modelID: "model-1", + providerID: "provider-1", + mode: "chat", + cost: 0.01, + tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + error: null, + time: { created: 1000, completed: 2000 }, + }, + }, + }, + }); + + const observeCalls = capturedRequests.filter((r) => r.url.includes("/observe")); + expect(observeCalls.length).toBe(1); + expect(observeCalls[0].body.hookType).toBe("assistant_message"); + expect(observeCalls[0].body.data.title).toBeUndefined(); + expect(observeCalls[0].body.data.messageID).toBe("msg-assistant-1"); + }); + + it("telemetry events leave payload without injected title where not specified", async () => { + const handlers = await createHandlers(); + const sid = "sess-telemetry-1"; + await initSession(handlers, sid); + + await handlers.event({ + event: { type: "message.updated", properties: { sessionID: sid, info: { role: "assistant", id: "msg-x", parentID: "p1", modelID: "m1", providerID: "pr", mode: "chat", cost: 0, tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "stop", error: null, time: { created: 1000, completed: 2000 } } } }, + }); + const assistantCalls = capturedRequests.filter((r) => r.url.includes("/observe") && r.body.hookType === "assistant_message"); + expect(assistantCalls.length).toBe(1); + expect(assistantCalls[0].body.data.title).toBeUndefined(); + + capturedRequests.length = 0; + await handlers.event({ + event: { type: "message.part.updated", properties: { sessionID: sid, part: { type: "reasoning", messageID: "msg-x", text: "thinking" } } }, + }); + const reasoningCalls = capturedRequests.filter((r) => r.url.includes("/observe")); + expect(reasoningCalls.length).toBe(1); + expect(reasoningCalls[0].body.hookType).toBe("reasoning"); + expect(reasoningCalls[0].body.data.title).toBeUndefined(); + + capturedRequests.length = 0; + await handlers.event({ + event: { type: "session.compacted", properties: { sessionID: sid } }, + }); + const compacted = capturedRequests.filter((r) => r.url.includes("/observe") && r.body.hookType === "session_compacted"); + expect(compacted.length).toBe(1); + expect(compacted[0].body.data.title).toBeUndefined(); + }); +}); diff --git a/test/summarize-telemetry.test.ts b/test/summarize-telemetry.test.ts new file mode 100644 index 000000000..85ff7fffe --- /dev/null +++ b/test/summarize-telemetry.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { buildSummaryPrompt } from "../src/prompts/summary.js"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/schema.js", () => ({ + KV: { + sessions: "sessions", + summaries: "summaries", + summaryPartials: (sessionId: string) => `summary_partials:${sessionId}`, + observations: (sessionId: string) => `obs:${sessionId}`, + audit: "audit", + }, +})); + +vi.mock("../src/eval/schemas.js", () => ({ + SummaryOutputSchema: {}, +})); + +vi.mock("../src/eval/validator.js", () => ({ + validateOutput: () => ({ valid: true, result: { errors: [] } }), +})); + +vi.mock("../src/eval/quality.js", () => ({ + scoreSummary: () => 100, +})); + +vi.mock("../src/functions/audit.js", () => ({ + safeAudit: vi.fn(), +})); + +import { registerSummarizeFunction, filterObservationsForSummary } from "../src/functions/summarize.js"; +import type { CompressedObservation, Session, MemoryProvider, RawObservation } from "../src/types.js"; +import { buildSyntheticCompression } from "../src/functions/compress-synthetic.js"; + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + functions, + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async () => ({}), + }; +} + +function makeProvider(responses: string[]): MemoryProvider & { calls: Array<{ system: string; user: string }> } { + const calls: Array<{ system: string; user: string }> = []; + let i = 0; + return { + name: "test", + calls, + compress: async () => "", + summarize: async (system: string, user: string) => { + calls.push({ system, user }); + const r = responses[i] ?? responses[responses.length - 1]; + i += 1; + return r; + }, + }; +} + +function summaryXml(opts: { title: string }): string { + return `${opts.title}n`; +} + +function baseObs(overrides: Partial & Record): CompressedObservation { + return { + id: "obs_" + Math.random().toString(36).slice(2, 6), + sessionId: "ses_test", + timestamp: new Date().toISOString(), + type: "other", + title: "Test title", + facts: [], + narrative: "some narrative", + concepts: [], + files: [], + importance: 5, + ...overrides, + } as CompressedObservation; +} + +describe("buildSummaryPrompt layer 4", () => { + it("omits Facts: label when facts empty but files present", () => { + const prompt = buildSummaryPrompt([ + { type: "file_edit", title: "Applied patch to 2 file(s)", narrative: "Applied patch to 2 file(s)", facts: [], files: ["src/a.ts", "src/b.ts"], concepts: [] }, + ]); + expect(prompt).not.toContain("Facts:"); + expect(prompt).toContain("Files: src/a.ts, src/b.ts"); + expect(prompt).toContain("[1] file_edit: Applied patch to 2 file(s)"); + }); + + it("omits Files: label when files empty but facts present", () => { + const prompt = buildSummaryPrompt([ + { type: "other", title: "Some title", narrative: "narrative", facts: ["fact one"], files: [], concepts: [] }, + ]); + expect(prompt).not.toContain("Files:"); + expect(prompt).toContain("Facts:"); + expect(prompt).toContain(" - fact one"); + }); + + it("emits neither label when both empty (header-only line, no dangling labels)", () => { + const prompt = buildSummaryPrompt([ + { type: "conversation", title: "Hello", narrative: "Hello world", facts: [], files: [], concepts: [] }, + ]); + expect(prompt).not.toContain("Facts:"); + expect(prompt).not.toContain("Files:"); + expect(prompt).toContain("[1] conversation: Hello"); + expect(prompt).toContain("Hello world"); + }); + + it("uses title in header when present", () => { + const prompt = buildSummaryPrompt([ + { type: "file_edit", title: "Applied patch to 2 file(s)", narrative: "Applied patch to 2 file(s)", facts: [], files: [], concepts: [] }, + ]); + expect(prompt).toContain("Applied patch to 2 file(s)"); + expect(prompt).toContain("[1] file_edit: Applied patch to 2 file(s)"); + }); + + it("prompt_submit userPrompt-only renders header-only without dangling labels (regression)", () => { + const prompt = buildSummaryPrompt([ + { type: "conversation", title: "User prompt", narrative: "hello from user", facts: [], files: [], concepts: [] }, + ]); + expect(prompt).toContain("[1] conversation: User prompt"); + expect(prompt).toContain("hello from user"); + expect(prompt).not.toContain("Facts:"); + expect(prompt).not.toContain("Files:"); + }); +}); + +describe("summarize pipeline layer 3 filtering", () => { + async function setupWithObservations(observations: CompressedObservation[]) { + const sdk = mockSdk(); + const kv = mockKV(); + const session: Session = { + id: "ses_test", + project: "test-project", + cwd: "/tmp", + startedAt: new Date().toISOString(), + status: "completed", + observationCount: observations.length, + }; + await kv.set("sessions", "ses_test", session); + for (const o of observations) { + await kv.set(`obs:ses_test`, o.id, o); + } + const provider = makeProvider([summaryXml({ title: "Summary" })]); + registerSummarizeFunction(sdk as any, kv as any, provider); + const handler = sdk.functions.get("mem::summarize")!; + return { handler, kv, provider }; + } + + it("filters out isTelemetry observations (pipeline-produced rows, not hand-set)", async () => { + const keep = baseObs({ id: "keep_1", title: "Keep me", narrative: "keep narrative", facts: ["keep fact"], files: ["src/keep.ts"] }); + const telemetryRaw: RawObservation = { + id: "tele_raw_1", + sessionId: "ses_test", + timestamp: new Date().toISOString(), + hookType: "assistant_message", + raw: {}, + }; + const telemetry = buildSyntheticCompression(telemetryRaw); + expect(telemetry.isTelemetry).toBe(true); + expect(filterObservationsForSummary([telemetry]).length).toBe(0); + const { handler, provider } = await setupWithObservations([keep, telemetry]); + const result: any = await handler({ sessionId: "ses_test" }); + expect(result.success).toBe(true); + const prompt = provider.calls[0].user; + expect(prompt).toContain("Keep me"); + expect(prompt).not.toContain("assistant_message"); + }); + + it("end-to-end: telemetry raw produces CompressedObservation with isTelemetry===true and is dropped", async () => { + const raw: RawObservation = { + id: "e2e_tele_1", + sessionId: "ses_test", + timestamp: new Date().toISOString(), + hookType: "assistant_message", + raw: {}, + }; + const compressed = buildSyntheticCompression(raw); + expect(compressed.isTelemetry).toBe(true); + expect(compressed.narrative).toBe(""); + expect(compressed.files).toEqual([]); + expect(filterObservationsForSummary([compressed]).length).toBe(0); + const keep = baseObs({ id: "keep_e2e", title: "Keep e2e", narrative: "something" }); + const { handler, provider } = await setupWithObservations([keep, compressed]); + const result: any = await handler({ sessionId: "ses_test" }); + expect(result.success).toBe(true); + expect(provider.calls[0].user).toContain("Keep e2e"); + expect(provider.calls[0].user).not.toContain("e2e_tele_1"); + }); + + it("drops zero-content observations (empty narrative/facts/files/tool fields)", async () => { + const keep = baseObs({ id: "keep_2", title: "Keep", narrative: "has content", facts: [], files: [] }); + const zero = baseObs({ id: "zero_1", title: "", narrative: "", facts: [], files: [] }) as CompressedObservation & Record; + // ensure zero has no tool fields + delete (zero as Record).toolInput; + delete (zero as Record).toolOutput; + delete (zero as Record).userPrompt; + (zero as CompressedObservation).title = ""; + (zero as CompressedObservation).narrative = ""; + const { handler, provider } = await setupWithObservations([keep, zero as CompressedObservation]); + const result: any = await handler({ sessionId: "ses_test" }); + expect(result.success).toBe(true); + const prompt = provider.calls[0].user; + expect(prompt).toContain("Keep"); + expect(prompt).not.toContain("[2]"); + expect(prompt).toContain("Session observations (1 total)"); + }); + + it("KEEPS tool observations (toolInput present) and prompt_submit (userPrompt present)", async () => { + const toolObs = baseObs({ id: "tool_1", title: "Read src/foo.ts", narrative: "", facts: [], files: [] }) as CompressedObservation & Record; + (toolObs as Record).toolInput = { file_path: "src/foo.ts" }; + const promptObs = baseObs({ id: "prompt_1", title: "User prompt", narrative: "", facts: [], files: [] }) as CompressedObservation & Record; + (promptObs as Record).userPrompt = "hello world"; + const { handler, provider } = await setupWithObservations([toolObs as CompressedObservation, promptObs as CompressedObservation]); + const result: any = await handler({ sessionId: "ses_test" }); + expect(result.success).toBe(true); + const prompt = provider.calls[0].user; + expect(prompt).toContain("Read src/foo.ts"); + expect(prompt).toContain("User prompt"); + expect(prompt).toContain("Session observations (2 total)"); + }); +});