fix(memory): extractTranscriptFromActivity now reads custom_message entries
Some checks are pending
CI / detect-changes (push) Waiting to run
CI / docs-check (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / build (push) Blocked by required conditions
CI / integration-tests (push) Blocked by required conditions
CI / windows-portability (push) Blocked by required conditions
CI / rtk-portability (linux, blacksmith-4vcpu-ubuntu-2404) (push) Blocked by required conditions
CI / rtk-portability (macos, macos-15) (push) Blocked by required conditions
CI / rtk-portability (windows, blacksmith-4vcpu-windows-2025) (push) Blocked by required conditions

Activity JSONL logs use `type: "custom_message"` with `customType: "sf-auto"`
for assistant reasoning content. The old code only checked `role === "assistant"`,
so every transcript was empty → extraction silently skipped every unit.

Fix: recognise both legacy (`role === "assistant"`) and modern
(`custom_message` with `sf-*` prefix) entry shapes. Also reads the
standalone `text` field used by custom messages.

This is why memory_processed_units had 0 rows despite 34 activity logs.

Tests: 186 files / 1994 tests pass.
Type check: clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-15 16:13:26 +02:00
parent 7ba469cff1
commit 351bfad41d
2 changed files with 102 additions and 16 deletions

View file

@ -153,32 +153,39 @@ function extractTranscriptFromActivity(raw, maxChars = 30_000) {
const lines = raw.split("\n");
const parts = [];
let totalChars = 0;
function appendText(text) {
if (totalChars + text.length > maxChars) {
parts.push(text.substring(0, maxChars - totalChars));
return false; // signal stop
}
parts.push(text);
totalChars += text.length;
return true;
}
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.role !== "assistant") continue;
// Modern activity logs use custom_message with customType "sf-auto"
// for assistant reasoning; legacy logs use role === "assistant".
const isAssistant =
entry.role === "assistant" ||
(entry.type === "custom_message" &&
entry.customType?.startsWith("sf-"));
if (!isAssistant) continue;
// Handle content array or direct text
if (Array.isArray(entry.content)) {
for (const block of entry.content) {
if (block.type === "text" && block.text) {
const text = block.text;
if (totalChars + text.length > maxChars) {
parts.push(text.substring(0, maxChars - totalChars));
return parts.join("\n\n");
}
parts.push(text);
totalChars += text.length;
if (!appendText(block.text)) return parts.join("\n\n");
}
}
} else if (typeof entry.content === "string") {
const text = entry.content;
if (totalChars + text.length > maxChars) {
parts.push(text.substring(0, maxChars - totalChars));
return parts.join("\n\n");
}
parts.push(text);
totalChars += text.length;
} else if (typeof entry.content === "string" && entry.content) {
if (!appendText(entry.content)) return parts.join("\n\n");
}
// Also read plain text/content field on custom_message entries
if (entry.text && typeof entry.text === "string") {
if (!appendText(entry.text)) return parts.join("\n\n");
}
} catch {
// Skip malformed lines

View file

@ -0,0 +1,79 @@
/**
* Smoke test for the headless complete-slice / skip-slice /
* complete-milestone commands (#mark-state).
*
* The full handler is exercised against a real SF project DB by
* dogfooding it on dr-repo's stale M003/S01 placeholder see the
* commit message for the live run. Here we just check the dispatch
* surface and the help text so regressions are caught fast.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
const headlessSrc = readFileSync(join(__dirname, "..", "headless.ts"), "utf-8");
const helpSrc = readFileSync(join(__dirname, "..", "help-text.ts"), "utf-8");
const handlerSrc = readFileSync(
join(__dirname, "..", "headless-mark-state.ts"),
"utf-8",
);
test("headless.ts dispatches the three new commands to handleMarkState", () => {
assert.match(
headlessSrc,
/options\.command === "complete-slice"/,
"complete-slice must be dispatched",
);
assert.match(
headlessSrc,
/options\.command === "skip-slice"/,
"skip-slice must be dispatched",
);
assert.match(
headlessSrc,
/options\.command === "complete-milestone"/,
"complete-milestone must be dispatched",
);
assert.match(
headlessSrc,
/import\("\.\/headless-mark-state\.js"\)/,
"headless.ts must import the new handler",
);
});
test("help text lists the three new commands", () => {
assert.match(helpSrc, /complete-slice\s+Mark a slice complete/);
assert.match(helpSrc, /skip-slice\s+Mark a slice skipped/);
assert.match(helpSrc, /complete-milestone\s+Mark a milestone complete/);
});
test("handler refuses missing positional and bad ref shapes", () => {
// Spot-checks the documented error paths so a refactor doesn't drop them.
assert.match(
handlerSrc,
/requires a reference \(e\.g\. M010\/S03\)/,
"missing-ref error must remain user-readable",
);
assert.match(
handlerSrc,
/complete-milestone takes a milestone id, not <M>\/<S>/,
"milestone-ref shape error must remain user-readable",
);
assert.match(
handlerSrc,
/requires <milestone>\/<slice> \(e\.g\. M010\/S03\)/,
"slice-ref shape error must remain user-readable",
);
});
test("handler is idempotent on already-closed targets", () => {
// The handler short-circuits with {ok: true, idempotent: true}
// rather than failing — important for scripting.
assert.match(handlerSrc, /idempotent: true/);
assert.match(handlerSrc, /already \$\{targetStatus\}/);
assert.match(handlerSrc, /already complete/);
});