From 163fdf8a72c820002f65f3dcc238dcfe861d67dc Mon Sep 17 00:00:00 2001 From: DevMello Date: Tue, 4 Aug 2026 18:48:29 -0700 Subject: [PATCH] fix(backend): stop capturing tool results as the user query Claude Code transcripts store tool results as user-role records, so the Stop-event transcript fallback could save tool output to memory as the user's query. Classify tool_result records as tool messages so the fallback finds the real prompt. --- .../templates/memmy-resume-hook.ts | 13 ++- .../templates/tests/memmy-resume-hook.test.ts | 86 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts index e775f64c..c7a21478 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts @@ -293,7 +293,13 @@ function transcriptMessageFromRecord(record) { const role = normalizeText(message.role) || normalizeText(record.role) || (record.type === "user" || record.type === "assistant" ? record.type : ""); if (role === "user" || role === "assistant") { - const text = contentText(message.content || record.content || record.text); + const content = message.content || record.content || record.text; + if (role === "user" && hasToolResultContent(content)) { + // Claude Code transcripts store tool results as user records; capturing + // them as user text would turn tool output into the turn's query. + return { role: "tool", text: contentText(content) || "tool" }; + } + const text = contentText(content); return text ? { role, text } : null; } if (role === "tool") { @@ -371,6 +377,11 @@ function contentText(value) { return ""; } +function hasToolResultContent(value) { + return Array.isArray(value) && + value.some((item) => item && typeof item === "object" && item.type === "tool_result"); +} + function parseResumeQuery(prompt) { const text = normalizeText(prompt); const commandArguments = parseResumeCommandArguments(text); diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts b/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts new file mode 100644 index 00000000..9a0df59c --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts @@ -0,0 +1,86 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderMemmyResumeHookScript } from "../memmy-resume-hook.js"; + +describe("memmy resume hook stop capture", () => { + let tempDir = ""; + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ""; + } + }); + + it("captures the user prompt instead of the last tool result when turn state is missing", async () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-resume-hook-stop-")); + const requests: Array<{ path: string; body: Record }> = []; + const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { + let body = ""; + for await (const chunk of request) { + body += chunk; + } + requests.push({ path: request.url ?? "", body: body ? JSON.parse(body) : {} }); + response.setHeader("content-type", "application/json"); + if (request.url === "/api/v1/sessions/open") { + response.end(JSON.stringify({ sessionId: "server-session", status: "open" })); + return; + } + response.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + + try { + const hookScriptPath = join(tempDir, "memmy-resume-hook.mjs"); + writeFileSync(hookScriptPath, renderMemmyResumeHookScript({ source: "claude_code", mode: "claude-code" })); + writeFileSync(join(tempDir, "memmy-memory-config.json"), JSON.stringify({ + memmy_config_path: join(tempDir, "missing-config.yaml"), + endpoint: `http://127.0.0.1:${port}`, + token: "" + })); + + const toolResultText = "src/auth/login.ts\n42: if (password == storedHash) { grantSession(user); }"; + const transcriptPath = join(tempDir, "transcript.jsonl"); + writeFileSync(transcriptPath, [ + JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: "please fix the login bug in auth" }] } }), + JSON.stringify({ type: "assistant", message: { role: "assistant", content: [ + { type: "text", text: "Let me look at the code first." }, + { type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "src/auth/login.ts" } } + ] } }), + JSON.stringify({ type: "user", message: { role: "user", content: [ + { type: "tool_result", tool_use_id: "tool-1", content: [{ type: "text", text: toolResultText }] } + ] } }), + JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "Fixed: login.ts now compares hashes." }] } }) + ].join("\n") + "\n"); + + const result = await new Promise<{ status: number | null; stderr: string }>((resolve) => { + const child = spawn(process.execPath, [hookScriptPath], { + env: { ...process.env, MEMMY_CONFIG: join(tempDir, "missing-config.yaml") } + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("close", (status) => resolve({ status, stderr })); + child.stdin.end(JSON.stringify({ + hook_event_name: "Stop", + session_id: "stop-capture-session", + transcript_path: transcriptPath, + stop_hook_active: false + })); + }); + + expect(result.status).toBe(0); + const complete = requests.find((request) => request.path.includes("/complete")); + expect(complete?.body?.query).toBe("please fix the login bug in auth"); + expect(complete?.body?.answer).toBe("Fixed: login.ts now compares hashes."); + } finally { + server.close(); + } + }, 30000); +});