Dashboard › › Session ses_384e7de8
ses_384e7de8dffeBDc4Z3dK9kfx1kThe prefill error occurred when the nuum gradient transform produced a message array that ended with an assistant message rather than a user message, causing a "400 Bad Request: prompt must end with a human turn" error from Anthropic's API.
The two-layer fix was:
stripToTextOnly() now inserts a '...' placeholder text part if all parts would be removed by stripping, preventing user messages from becoming emptywhile loop that drops trailing non-user messages from the transformed resultDoes that correctly answer the question? (yes/no)
[assistant]: [tool:todowrite] [ { "content": "Fix eval stderr mixing into JSONL output (quick)", "status": "completed", "priority": "high" }, { "content": "Fix orphan reset destroying existing distillations", "status": "completed", "priority": "high" }, { "content": "Add distillation backfill for historical sessions", "status": "completed", "priority": "high" }, { "content": "Re-run coding eval with proper distillation coverage", "status": "in_progress", "priority": "high" }, { "content": "Snapshot test session data for stable eval baselines", "status": "pending", "priority": "medium" } ]
[assistant]: The backfill test query shows the judge interaction is working. Let me run the full eval now:
Coding Memory Eval Mode: default Questions: 15 Concurrency: 3 Output: eval/results/coding_eval_default.jsonl Purged 57 eval sessions (112 messages) from temporal storage Loading session ses_391dfe776ffe... 892 messages, 452208 tokens Using 5 existing distillation(s) Nuum context: 25172 chars Loading session ses_38a242967ffe... 199 messages, 141086 tokens Using 4 existing distillation(s) Nuum context: 22170 chars Loading session ses_389c23eddffe... 226 messages, 95140 tokens Using 5 existing distillation(s) Nuum context: 22026 chars Running 15 evaluations (15 questions × 1 modes)... [1/15] ✓ default nuum-dev "What was the FTS5 search bug fixed in ltm.ts at th..." [2/15] ✗ default nuum-dev "How many existing knowledge entries were bulk-upda..." [3/15] ✗ default nuum-dev "What technique did the nuum gradient context manag..." [4/15] ✗ default nuum-dev "Why did the eval test sessions appear in the OpenC..." [5/15] ✓ default nuum-dev "What type does ...
FTS5 observations mentioning "prefix": None found Bulk-update observations mentioning "43" or "cross": 6c1ddcd9: crossProject: false to crossProject: true so new LTM entries are shared across projects by default. All 50 existing 35991837: observations: crossProject should default to true in both ltm.create() and curator.ts, not just the prompt. Ternary expr 0fedc16b: session-specific memory within conversations. Commit a2a2b21 changed default from false → true. "50 entries" vs "43": 6c1ddcd9: All 50 existing knowledge entries were bulk-updated to cross_project=1 in the database. [assistant]: There it is clearly: **Bulk-update issue:** The observation says "50 existing knowledge entries" but the correct answer is 43. This is a factual error in the distillation — the observer counted wrong (probably estimated). This is an observer accuracy problem, not a retrieval problem. **FTS5 issue:** No observation mentions FTS5 prefix matching at all. ...
=== First distillation gen=1 (covers 306 source messages) === --- EXCERPT --- Date: Feb 19, 2026 🔴 (09:50) nuum FTS5 search uses exact-match queries with * prefix wildcard; ltm.ts ftsQuery() function appends * to each search term for prefix matching 🟡 (09:50) Initial session context: working on opencode-nuum plugin, focusing on LTM search and cross-project memory system 🟡 (09:50) FTS search results sometimes showed poor matches due to exact-term matching rather than prefix matching 🔴 (09:51) Fixed FTS5 prefix matching in ltm.ts by adding ftsQuery() that appends * to each search term; committed as 8e83f14 fix(ltm): use FTS5 prefix matching for better search results [assistant]: The FTS5 fix IS in the observations — "adds ftsQuery() that appends * to each search term for prefix matching" and "committed as 8e83f14". But nuum still fails this question. Let me check what the recall tool returns when searching for this:
Searching for "FTS5 prefix matching fix ltm"... Temporal results: 10 matches user: @FTS5 search results sometimes showed poor matches... user: Based on the context provided, this is how the FTS5 prefix matching bug was fixed in `ltm.ts`:... assistant: [tool:recall] user: What was the FTS5 search bug fixed in ltm.ts at the start of this session, and what was the fix? FTS5 first match content: Based on the context provided, this is how the FTS5 search bug in `ltm.ts` was fixed:... [assistant]: The FTS5 search is finding **eval session messages from previous runs** — a user message asking the exact question, and an assistant message with the answer. These are contaminating the recall results because they're in temporal storage. Let me verify the purge is catching these:
Current temporal stats:
Total messages: 3169
Messages from eval sessions (≤5 msgs): 59
Messages from test sessions:
ses_391dfe776ffe: 892 msgs
ses_38a242967ffe: 199 msgs
ses_389c23eddffe: 226 msgs
Current eval session count: 59 (112 messages, purged at start of each run)
[assistant]: The purge only removes sessions with ≤5 messages, but eval QA sessions have exactly 2 messages each (1 user prompt + 1 assistant response). So they should be caught. But there are 59 messages in these eval sessions = about 30 eval sessions currently in temporal storage. These sessions were created during this eval run — they're being stored in real-time as eval sessions are created.
The issue is that the `shouldSkipSession` logic in the plugin was supposed to skip child sessions, but let me verify it's actually working:
Child session check: ses_38548e17bffe -> has parentID: true Sessions created in last 1 hour: 78 Sessions with ≤5 messages: 78 Expected skipped (child of eval root): checking... [assistant]: [tool:bash] Skip-session test: Checking ses_3856e31dcffe parentID from API: ses_3856e31ddffe [assistant]: So the session created is a child of another session. But is it being stored in temporal storage? Let me check the plugin code — it should be skipping child sessions now:
<path>/home/byk/Code/opencode-nuum/src/index.ts</path>
<path>/home/byk/Code/opencode-nuum/src/index.ts</path>
<type>file</type>
<content>18: export const NuumPlugin: Plugin = async (ctx) => {
19: const projectPath = ctx.worktree || ctx.directory;
20: await load(ctx.directory);
21: ensureProject(projectPath);
22:
23: // Track user turns for periodic curation
24: let turnsSinceCuration = 0;
25:
26: // Track active sessions for distillation
27: const activeSessions = new Set<string>();
28:
29: // Sessions to skip for temporal storage and distillation. Includes worker sessions
30: // (distillation, curator) and child sessions (eval, any other children).
31: // Checked once per session ID and cached to avoid repeated API calls.
32: const skipSessions = new Set<string>();
33:
34: async function shouldSkip(sessionID: string): Promise<boolean> {
35: if (distillation.isWorkerSession(sessionID)) return true;
36: if (skipSessions.has(sessionID)) return true;
37: if (activeSessions.has(sessionID)) return false; ...
The shouldSkip logic looks correct. Now let me see where temporal storage happens to check if the skip is applied there:
Found 4 matches
/home/byk/Code/opencode-nuum/src/index.ts:
Line 34: async function shouldSkip(sessionID: string): Promise<boolean> {
Line 117: if (await shouldSkip(msg.sessionID)) return;
Line 123: temporal.store({
Line 167: if (await shouldSkip(sessionID)) return;
<path>/home/byk/Code/opencode-nuum/src/index.ts</path>
<type>file</type>
<content>110: },
111:
112: // Store all messages in temporal DB for full-text search and distillation.
113: // Skips child sessions (eval, worker) to prevent pollution.
114: event: async ({ event }) => {
115: if (event.type === "message.updated") {
116: const msg = event.properties.info;
117: if (await shouldSkip(msg.sessionID)) return;
118: try {
119: const full = await ctx.client.session.message({
120: path: { id: msg.sessionID, messageID: msg.id },
121: });
122: if (full.data) {
123: temporal.store({
124: projectPath,
125: info: full.data.info,
126: parts: full.data.parts,
127: });
128: activeSessions.add(msg.sessionID);
129: if (msg.role === "user") turnsSinceCuration++;
130:
131: // Incremental distillation: when ...
The shouldSkip logic is in place and checks for parentID. The eval sessions should be child sessions. However, the eval script creates sessions differently — let me check how the eval script creates sessions:
138: body: JSON.stringify({ parentID: evalRoot }),
<path>/home/byk/Code/opencode-nuum/eval/coding_eval.ts</path>
<type>file</type>
<content>125: method: "POST",
126: headers: { "Content-Type": "application/json" },
127: body: JSON.stringify({
128: title: `coding-eval - ${targetMode} - ${new Date().toISOString()}`,
129: }),
130: }).then((r) => r.json() as Promise<{ id: string }>);
131: return res.id;
132: }
133:
134: async function createSession(): Promise<string> {
135: const res = await fetch(`${BASE_URL}/session`, {
136: method: "POST",
137: headers: { "Content-Type": "application/json" },
138: body: JSON.stringify({ parentID: evalRoot }),
139: }).then((r) => r.json() as Promise<{ id: string }>);
140: return res.id;
141: }
142:
143: type MessageInfo = {
144: info: { id: string; role: string; time: { created: number; updated: number } };
145: parts: Array<{ type: string; text?: string; tool?: string; state?: { status: string } }>;
146: };
147:
148: // Wait for the model to finish ...
Good — the eval sessions are created with parentID: evalRoot, so they are child sessions. The shouldSkip function checks session.data?.parentID and should return true for these. So why are there still 59 temporal messages from eval sessions?
Let me check if there's a timing issue — the shouldSkip API call might fail (catch block returns false), or the sessions might have been created before the shouldSkip check was in place:
Usage: bun run [flags] <file or script>
Flags:
--silent Don't print the script command
--elide-lines=<val> Number of lines of script output shown when using --filter (default: 10). Set to 0 to show all lines.
-F, --filter=<val> Run a script in all workspace packages matching the pattern
-b, --bun Force a script or package to use Bun's runtime instead of Node.js (via symlinking node)
--shell=<val> Control the shell used for package.json scripts. Supports either 'bun' or 'system'
--workspaces Run a script in all workspace packages (from the "workspaces" field in package.json)
--parallel Run multiple scripts concurrently with Foreman-style output
--sequential Run multiple scripts sequentially with Foreman-style output
--no-exit-on-error Continue running other scripts when one ...
/home/byk/.sentry-cli-test-58568/test-32lp1jrak8w/cli.db /home/byk/.sentry-cli-test-58568/test-73nzh8h1e65/cli.db /home/byk/.sentry-cli-test-58568/test-clvnfs1pdeu/cli.db /home/byk/.sentry-cli-test-58568/test-qyw20j2if9s/cli.db /home/byk/.sentry-cli-test-58568/test-stnqhf1wizl/cli.db /home/byk/.sentry-cli-test-58568/test-zwkn8m2yic7/cli.db /home/byk/.sentry-cli-test-58568/test-om00lmzi3wd/cli.db /home/byk/.sentry-cli-test-58568/test-p1rxynnv08r/cli.db /home/byk/.sentry-cli-test-58568/test-nbkr7nldsp/cli.db /home/byk/.sentry-cli-test-58568/test-aala3j8o1og/cli.db /home/byk/.sentry-cli-test-58568/test-rsq5hn9i9h/cli.db /home/byk/.sentry-cli-test-58568/test-6fje94d6wjf/cli.db /home/byk/.sentry-cli-test-58568/test-r7j3fdd4cir/cli.db /home/byk/.sentry-cli-test-58568/test-98y6mhrp228/cli.db /home/byk/.sentry-cli-test-58568/test-q1cxn2imxjd/cli.db /home/byk/.sentry-cli-test-58568/test-ki7w3mi308k/cli.db /home/byk/.sentry-cli-test-58568/test-w6mvcgzu5ph/cli.db /home/byk/.sentry-cli-test-58568/...
/home/byk/.bun/install/cache/caniuse-lite@1.0.30001756@@@1/data/features/temporal.js /home/byk/.bun/install/cache/google-logging-utils@1.1.3@@@1/build/src/temporal.js /home/byk/.bun/install/cache/google-logging-utils@1.1.3@@@1/build/src/temporal.d.ts /home/byk/.bun/install/cache/google-logging-utils@1.1.3@@@1/build/src/temporal.js.map /home/byk/.bun/install/cache/@babel/helpers@7.28.6@@@1/lib/helpers/temporalUndefined.js.map /home/byk/.bun/install/cache/@babel/helpers@7.28.6@@@1/lib/helpers/temporalRef.js /home/byk/.bun/install/cache/@babel/helpers@7.28.6@@@1/lib/helpers/temporalUndefined.js /home/byk/.bun/install/cache/@babel/helpers@7.28.6@@@1/lib/helpers/temporalRef.js.map /home/byk/.bun/install/cache/@babel/runtime@7.28.6@@@1/helpers/esm/temporalRef.js /home/byk/.bun/install/cache/@babel/runtime@7.28.6@@@1/helpers/esm/temporalUndefined.js /home/byk/.bun/install/cache/@babel/runtime@7.28.6@@@1/helpers/temporalRef.js /home/byk/.bun/install/cache/@babel/runtime@7.28.6@@@1/helpers/temp...
bunfig.toml bun.lock eval MEMORY_PLAN.md node_modules package.json README.md scripts src test tsconfig.json
The .opencode directory doesn't exist in opencode-nuum. Let me check where nuum stores its data:
Found 2 matches
/home/byk/Code/opencode-nuum/src/db.ts:
Line 141: const path = join(dir, "nuum.db");
/home/byk/Code/opencode-nuum/src/config.ts:
Line 43: const paths = [`${directory}/.opencode/nuum.json`, `${directory}/nuum.json`];
2:import { join } from "path";
3:import { mkdirSync } from "fs";
13: path TEXT NOT NULL UNIQUE,
131: const base = xdg || join(process.env.HOME || "~", ".local", "share");
132: return join(base, "opencode-nuum");
139: const dir = dataDir();
140: mkdirSync(dir, { recursive: true });
141: const path = join(dir, "nuum.db");
142: instance = new Database(path, { create: true });
179:export function ensureProject(path: string, name?: string): string {
181: .query("SELECT id FROM projects WHERE path = ?")
182: .get(path) as { id: string } | null;
187: "INSERT INTO projects (id, path, name, created_at) VALUES (?, ?, ?, ?)",
189: .run(id, path, name ?? path.split("/").pop() ?? "unknown", Date.now());
193:export function projectId(path: string): string | undefined {
195: .query("SELECT id FROM projects WHERE path = ?")
196: .get(path) as { id: string } | null;
nuum.db nuum.db-shm nuum.db-wal
Found it. Let me look at the temporal DB (which is nuum.db):
Tables: projects, temporal_messages, temporal_fts, temporal_fts_data, temporal_fts_idx, temporal_fts_docsize, temporal_fts_config, distillations, knowledge, knowledge_fts, knowledge_fts_data, knowledge_fts_idx, knowledge_fts_docsize, knowledge_fts_config, schema_version Messages schema: undefined
The table is temporal_messages, not messages:
temporal_messages schema: CREATE TABLE temporal_messages (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
tokens INTEGER DEFAULT 0,
distilled INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
metadata TEXT
)
Small sessions (<=5 msgs): 20
ses_384e4b0cdffe msgs:1 time:12:51:53
You are a helpful coding assistant answering questions about past coding sessions.
You have two sources of information:
ses_384e4c08fffe msgs:2 time:12:51:49
You are evaluating whether a hypothesis correctly answers a question about a coding session. Compare the hypothesis agai
ses_384e4c819ffe msgs:1 time:12:51:47
You are a helpful coding assistant answering questions about past coding sessions.
You have two sources of information:
ses_384e4c886ffe msgs:1 time:12:51:47
You are a helpful coding assistant answering questions about past coding sessions. ...
The eval sessions ARE being stored in temporal storage despite the shouldSkip check. The "You are a helpful coding assistant" and "You are evaluating whether" content is clearly from the eval QA sessions and judge sessions. The shouldSkip async check must be racing or failing.
Let me understand the flow better:
<path>/home/byk/Code/opencode-nuum/src/index.ts</path>
<type>file</type>
<content>34: async function shouldSkip(sessionID: string): Promise<boolean> {
35: if (distillation.isWorkerSession(sessionID)) return true;
36: if (skipSessions.has(sessionID)) return true;
37: if (activeSessions.has(sessionID)) return false; // already known good
38: // First encounter — check if this is a child session
39: try {
40: const session = await ctx.client.session.get({ path: { id: sessionID } });
41: if (session.data?.parentID) {
42: skipSessions.add(sessionID);
43: return true;
44: }
45: } catch {
46: // If we can't fetch session info, don't skip
47: }
48: return false;
49: }
50:
51: // Background distillation — debounced, non-blocking
52: let distilling = false;
53: async function backgroundDistill(sessionID: string, force?: boolean) {
54: if (distilling) return;
55: distilling = true;
56: try {
57: ...
The shouldSkip logic works correctly for subsequent calls (uses skipSessions cache), but on the first message from a new session, it hits the API. Let me check — the eval creates sessions with parentID. When the first message from that session arrives, shouldSkip calls ctx.client.session.get().
But wait — there's a subtle issue: the eval sessions use nuum-distill as the agent (line 158 of eval script). Let me check how isWorkerSession works:
17:const workerSessions = new Map<string, string>();
19:// Set of worker session IDs — used to skip storage and distillation for worker sessions
20:// Exported so curator.ts can register its own worker sessions here too
21:export const workerSessionIDs = new Set<string>();
23:export function isWorkerSession(sessionID: string): boolean {
24: return workerSessionIDs.has(sessionID);
31: const existing = workerSessions.get(parentID);
37: workerSessions.set(parentID, id);
38: workerSessionIDs.add(id);
332: const workerID = await ensureWorkerSession(input.client, input.sessionID);
339: path: { id: workerID },
349: path: { id: workerID },
383: const workerID = await ensureWorkerSession(input.client, input.sessionID);
390: path: { id: workerID },
399: path: { id: workerID },
isWorkerSession only knows about sessions created through ensureWorkerSession. The eval QA sessions are created with parentID: evalRoot but not registered as worker sessions. So isWorkerSession returns false for them, and the code falls through to the API check.
The API check IS correct — session.get should return parentID. But let me check: maybe the issue is that the content in those eval sessions doesn't start with "You are a helpful coding assistant" — wait, looking again at the output, the content column shows the system prompt text which is indeed being stored from the eval sessions.
Let me verify the shouldSkip is actually being called for these sessions:
The OpenCode server isn't running right now. Let me check if these sessions were stored during the last eval run. The key question is: why does shouldSkip return false for these eval child sessions?
Let me look more carefully at the eval output again — the eval purges at start:
Purged 57 eval sessions (112 messages) from temporal storage
So the purge IS working. But it purges sessions at the start of the run, and then new ones get created during the run. The last eval run with 15 questions in concurrency 3 would create ~15 QA sessions + 15 judge sessions = ~30 sessions. These are showing up after the purge.
The fundamental issue is: the eval generates new sessions during the run that get stored, and the next run purges the previous run's sessions. This is expected behavior. The problem is that these sessions are currently in temporal storage and pollute real-time recall.
The shouldSkip check should prevent them from being stored. Let me check if OpenCode server is currently running:
byk 1133386 32.9 8.8 75520012 711988 ? R<sl 11:04 35:25 /home/byk/.opencode/bin/opencode serve --hostname=0.0.0.0 --port=4096 byk 1134292 0.0 0.1 137656 10520 ? S< 11:08 0:00 /home/byk/.opencode/bin/opencode x typescript-language-server --stdio byk 1134326 0.0 1.9 1487992 154200 ? S<l 11:08 0:01 /home/byk/.volta/tools/image/node/24.13.0/bin/node /home/byk/Code/opencode/node_modules/.bun/typescript@5.8.2/node_modules/typescript/lib/tsserver.js --serverMode partialSemantic --useInferredProjectPerProjectRoot --disableAutomaticTypingAcquisition --cancellationPipeName /tmp/06f909bed299815abe9a61c102e9a904/tscancellation* --locale en --validateDefaultNpmLocation --useNodeIpc byk 1134327 0.4 5.8 1836408 467044 ? S<l 11:08 0:26 /home/byk/.volta/tools/image/node/24.13.0/bin/node /home/byk/Code/opencode/node_modules/.bun/typescript@5.8.2/node_modules/typescript/lib/tsserver.js --useInferredProjectPerProjectRoot --cancellationPipeName ...
OpenCode is running on port 4096. Now let me verify the eval sessions actually had parentID set:
parentID: None
The eval session has parentID: None (null). The OpenCode API returns null for parentID. In JavaScript, null is falsy, so session.data?.parentID would be null → falsy → shouldSkip returns false. The check works correctly in JavaScript, but None in Python is different.
Wait, but this is TypeScript/JavaScript. null is falsy. The check if (session.data?.parentID) would be if (null) → false. So the skip does NOT happen.
The root cause: The eval session parentID is null even though it was created with parentID: evalRoot. Let me check the eval root session ID that was used:
{
"name": "NotFoundError",
"data": {
"message": "Session not found: ses_384e4b0cdffe"
}
}
The session doesn't exist anymore (it was a past eval run, sessions may have been cleaned). Let me check a recently created session:
Total sessions: 100 ses_384e48127ffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:52:05.464Z ses_384e48139ffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:52:05.446Z ses_384e490fbffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:52:01.412Z ses_384e498caffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:51:59.413Z ses_384e4a11dffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:51:57.282Z ses_384e4a13dffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:51:57.250Z ses_384e4a8a8ffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:51:55.351Z ses_384e4b0cdffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:51:53.266Z ses_384e4c08fffe parentID:ses_384e57b77ffewyphEm6ik6qFPq title:Child session - 2026-02-20T12:51:49.232Z ses_384e4c819ffe ...
The sessions DO have parentID set. So the API returns the parentID correctly. But when I fetched ses_384e4b0cdffe earlier, it said "NotFoundError". Let me check again - the parentID shows ses_384e57b77ffewyphEm6ik6qFPq. That's a weird session ID (with appended characters).
Wait — the shouldSkip check is calling ctx.client.session.get(). Let me verify the client API vs the raw REST API return the same data:
Actually, looking more carefully at the earlier curl output: when I fetched ses_384e4b0cdffe, it returned "parentID": None. But from the session list, ses_384e4b0cdffe shows parentID:ses_384e57b77ffewyphEm6ik6qFPq. There's a discrepancy — the session.get endpoint returned null while the list shows the parentID.
Let me re-check:
{
"name": "NotFoundError",
"data": {
"message": "Session not found: ses_384e4b0cdffe"
}
}
{
"name": "NotFoundError",
"data": {
"message": "Session not found: ses_384e57b77ffe"
}
}
The sessions aren't accessible via the simple GET /session/{id} endpoint - only via the list. This is interesting. Let me check what ctx.client.session.get actually calls:
/home/byk/Code/opencode/packages/opencode/src/server/routes/session.ts:98: operationId: "session.get", /home/byk/Code/opencode/packages/opencode/src/server/routes/session.ts:114: sessionID: Session.get.schema, /home/byk/Code/opencode/packages/opencode/src/server/routes/session.ts:120: const session = await Session.get(sessionID) /home/byk/Code/opencode/packages/opencode/src/server/routes/session.ts:413: const session = await Session.get(sessionID) /home/byk/Code/opencode/packages/opencode/src/server/routes/session.ts:483: const session = await Session.get(sessionID) /home/byk/Code/opencode/packages/opencode/src/server/routes/session.ts:522: const session = await Session.get(sessionID)
29: const parentTitlePrefix = "New session - "
33: return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString()
38: `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`,
48: parentID: Identifier.schema("session").optional(),
133: parentID: Identifier.schema("session").optional(),
140: parentID: input?.parentID,
165: const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
170: ...(parentID && { parentID }),
195: parentID?: string
205: parentID: input.parentID,
206: title: input.title ?? createDefaultTitle(!!input.parentID),
219: if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
325: export const children = fn(Identifier.schema("session"), async (parentID) => {
330: if (session.parentID !== parentID) continue
242: export const get = fn(Identifier.schema("session"), async (id) => {
<path>/home/byk/Code/opencode/packages/opencode/src/session/index.ts</path>
<type>file</type>
<content>242: export const get = fn(Identifier.schema("session"), async (id) => {
243: const read = await Storage.read<Info>(["session", Instance.project.id, id])
244: return read as Info
245: })
246:
247: export const getShare = fn(Identifier.schema("session"), async (id) => {
248: return Storage.read<ShareInfo>(["share", id])
249: })
250:
251: export const share = fn(Identifier.schema("session"), async (id) => {
252: const cfg = await Config.get()
253: if (cfg.share === "disabled") {
254: throw new Error("Sharing is disabled in configuration")
255: }
256: const { ShareNext } = await import("@/share/share-next")
257: const share = await ShareNext.create(id)
258: await update(
259: id,
260: (draft) => {
261: draft.share = {
262: url: share.url,
263: }
264: },
265: { touch: false },
266: ...
Session.get reads from Storage.read<Info>(["session", Instance.project.id, id]). This is a file-based storage — Instance.project.id is the project's ID. The eval QA sessions are created under the nuum-dev project, but the ctx.client.session.get in the nuum plugin is making an HTTP call to the OpenCode server's session.get endpoint.
The key insight: shouldSkip calls ctx.client.session.get() which is an HTTP API call. But the eval sessions are old (from the last eval run) — they no longer exist on the OpenCode server (those session IDs can't be found now), so the API call fails with a "not found" error, and the catch block returns false (don't skip), which means they get stored.
Wait — but the sessions WERE stored at the time they were created. So the issue is:
ses_384e4b0cdffe with parentIDshouldSkip calls ctx.client.session.get(ses_384e4b0cdffe)parentID is set, shouldSkip returns true...But the session WAS stored (we can see 20+ sessions in temporal). Let me check if maybe the ctx.client.session.get endpoint doesn't return parentID in its response schema:
/home/byk/Code/opencode/packages/sdk/js/sst-env.d.ts /home/byk/Code/opencode/packages/sdk/js/src/client.ts /home/byk/Code/opencode/packages/sdk/js/src/server.ts /home/byk/Code/opencode/packages/sdk/js/src/index.ts /home/byk/Code/opencode/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts /home/byk/Code/opencode/packages/sdk/js/src/gen/core/auth.gen.ts /home/byk/Code/opencode/packages/sdk/js/src/gen/core/pathSerializer.gen.ts /home/byk/Code/opencode/packages/sdk/js/src/gen/core/types.gen.ts /home/byk/Code/opencode/packages/sdk/js/src/gen/core/params.gen.ts /home/byk/Code/opencode/packages/sdk/js/src/gen/core/utils.gen.ts js openapi.json
45: roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
60: if (query.roots && session.parentID) continue
grep: /home/byk/Code/opencode/packages/sdk/js/openapi.json: No such file or directory
/home/byk/Code/opencode/packages/sdk/js/node_modules/@hey-api/openapi-ts /home/byk/Code/opencode/packages/sdk/js/node_modules/.bin/openapi-ts /home/byk/Code/opencode/packages/sdk/openapi.json
1099: "description": "Only return root sessions (no parentID)"
1191: "parentID": {
6275: "parentID": {
6344: "parentID",
7840: "parentID": {
Path: /session/{sessionID} get
Response schema: {
"description": "Get session",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Session"
}
}
}
}
{
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^ses.*"
},
"slug": {
"type": "string"
},
"projectID": {
"type": "string"
},
"directory": {
"type": "string"
},
"parentID": {
"type": "string",
"pattern": "^ses.*"
},
"summary": {
"type": "object",
"properties": {
"additions": {
"type": "number"
},
"deletions": {
"type": "number"
},
"files": {
"type": "number"
},
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
}
}
},
"required": [
"additions",
"deletions",
"files"
]
},
"share": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
},
"required": [
"url"
]
},
"title": {
...
The Session schema includes parentID. So the SDK should return it. Let me check the generated SDK client to see what ctx.client.session.get returns:
client client.gen.ts core sdk.gen.ts types.gen.ts
121: parentID: string 537: parentID?: string 2081: parentID?: string
parentID is in the SDK types. Let me look at what ctx.client.session.get returns to understand the shape of session.data:
total 48 drwxrwxr-x 2 byk byk 4096 Jan 23 11:41 . drwxrwxr-x 4 byk byk 4096 Jan 23 11:41 .. -rw-rw-r-- 1 byk byk 849 Jan 23 11:41 auth.gen.ts -rw-rw-r-- 1 byk byk 2193 Jan 23 11:41 bodySerializer.gen.ts -rw-rw-r-- 1 byk byk 3274 Jan 23 11:41 params.gen.ts -rw-rw-r-- 1 byk byk 4144 Jan 23 11:41 pathSerializer.gen.ts -rw-rw-r-- 1 byk byk 2854 Jan 23 11:41 queryKeySerializer.gen.ts -rw-rw-r-- 1 byk byk 6139 Jan 23 11:41 serverSentEvents.gen.ts -rw-rw-r-- 1 byk byk 3194 Jan 23 11:41 types.gen.ts -rw-rw-r-- 1 byk byk 2563 Jan 23 11:41 utils.gen.ts
/home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/index.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/server.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/sdk.gen.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/core/types.gen.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/core/auth.gen.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/core/pathSerializer.gen.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/core/bodySerializer.gen.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.d.ts /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/core/utils.gen.d.ts
107: parentID: string; 469: parentID?: string; 1810: parentID?: string;
54: get<ThrowOnError extends boolean = false>(options: Options<PtyGetData, ThrowOnError>): import("./client/types.gen.js").RequestResult<PtyGetResponses, PtyGetErrors, ThrowOnError, "fields">;
68: get<ThrowOnError extends boolean = false>(options?: Options<ConfigGetData, ThrowOnError>): import("./client/types.gen.js").RequestResult<ConfigGetResponses, unknown, ThrowOnError, "fields">;
98: get<ThrowOnError extends boolean = false>(options?: Options<PathGetData, ThrowOnError>): import("./client/types.gen.js").RequestResult<PathGetResponses, unknown, ThrowOnError, "fields">;
104: get<ThrowOnError extends boolean = false>(options?: Options<VcsGetData, ThrowOnError>): import("./client/types.gen.js").RequestResult<VcsGetResponses, unknown, ThrowOnError, "fields">;
126: get<ThrowOnError extends boolean = false>(options: Options<SessionGetData, ThrowOnError>): import("./client/types.gen.js").RequestResult<SessionGetResponses, SessionGetErrors, ThrowOnError, "fields">;
1885:export type SessionGetData = {
1895:export type SessionGetErrors = {
1905:export type SessionGetError = SessionGetErrors[keyof SessionGetErrors];
1906:export type SessionGetResponses = {
1912:export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses];
<path>/home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts</path>
<type>file</type>
<content>1885: export type SessionGetData = {
1886: body?: never;
1887: path: {
1888: id: string;
1889: };
1890: query?: {
1891: directory?: string;
1892: };
1893: url: "/session/{id}";
1894: };
1895: export type SessionGetErrors = {
1896: /**
1897: * Bad request
1898: */
1899: 400: BadRequestError;
1900: /**
1901: * Not found
1902: */
1903: 404: NotFoundError;
1904: };
1905: export type SessionGetError = SessionGetErrors[keyof SessionGetErrors];
1906: export type SessionGetResponses = {
1907: /**
1908: * Get session
1909: */
1910: 200: Session;
1911: };
1912: export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses];
1913: export type SessionUpdateData = {
1914: body?: {
(Showing lines 1885-1914 of 3380. Use offset=1915 to continue.)
</content>
465:export type Session = {
<path>/home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts</path>
<type>file</type>
<content>465: export type Session = {
466: id: string;
467: projectID: string;
468: directory: string;
469: parentID?: string;
470: summary?: {
471: additions: number;
472: deletions: number;
473: files: number;
474: diffs?: Array<FileDiff>;
475: };
476: share?: {
477: url: string;
478: };
479: title: string;
480: version: string;
481: time: {
482: created: number;
483: updated: number;
484: compacting?: number;
485: };
486: revert?: {
487: messageID: string;
488: partID?: string;
489: snapshot?: string;
490: diff?: string;
491: };
492: };
493: export type EventSessionCreated = {
494: type: "session.created";
(Showing lines 465-494 of 3380. Use offset=495 to continue.)
</content>
The SDK type has parentID?: string — it's optional. So when it's null/undefined/absent in the response, session.data?.parentID is undefined (or null), which is falsy, and shouldSkip returns false.
Now the key question is: does the OpenCode server actually return parentID in the session.get response? Let me check with a live session:
Total: 100, with parentID: 95 ses_384e7de8dffe parentID: ses_384ea1f06ffe ses_384e48139ffe parentID: ses_384e57b77ffe ses_384e48127ffe parentID: ses_384e57b77ffe ses_384e57b77ffe parentID: NONE title: coding-eval - nuum - 2026-02-20T12:51:01 ses_384ea1f06ffe parentID: NONE title: coding-eval - default - 2026-02-20T12:45
parentID: MISSING
data: {
"name": "NotFoundError",
"data": {
"message": "Session not found: ses_384e7de8dffe"
}
}
Found the bug! The GET /session/{id} endpoint returns "NotFoundError" even for sessions that exist in the list. This means when shouldSkip calls ctx.client.session.get(), it gets a "not found" error (caught), falls through to return false, and the session gets stored.
Let me check why session.get fails for these child sessions:
<path>/home/byk/Code/opencode/packages/opencode/src/server/routes/session.ts</path>
<type>file</type>
<content>90: },
91: )
92: .get(
93: "/:sessionID",
94: describeRoute({
95: summary: "Get session",
96: description: "Retrieve detailed information about a specific OpenCode session.",
97: tags: ["Session"],
98: operationId: "session.get",
99: responses: {
100: 200: {
101: description: "Get session",
102: content: {
103: "application/json": {
104: schema: resolver(Session.Info),
105: },
106: },
107: },
108: ...errors(400, 404),
109: },
110: }),
111: validator(
112: "param",
113: z.object({
114: sessionID: Session.get.schema,
115: }),
116: ),
117: async (c) => {
118: const sessionID = c.req.valid("param").sessionID
119: ...
Session.get(sessionID) — I need to see what Session.get.schema does and how it validates:
<path>/home/byk/Code/opencode/packages/opencode/src/session/index.ts</path>
<type>file</type>
<content>242: export const get = fn(Identifier.schema("session"), async (id) => {
243: const read = await Storage.read<Info>(["session", Instance.project.id, id])
244: return read as Info
245: })
246:
247: export const getShare = fn(Identifier.schema("session"), async (id) => {
248: return Storage.read<ShareInfo>(["share", id])
249: })
250:
251: export const share = fn(Identifier.schema("session"), async (id) => {
252: const cfg = await Config.get()
253: if (cfg.share === "disabled") {
254: throw new Error("Sharing is disabled in configuration")
255: }
256: const { ShareNext } = await import("@/share/share-next")
(Showing lines 242-256 of 500. Use offset=257 to continue.)
</content>
Session.get reads from Storage.read<Info>(["session", Instance.project.id, id]). The storage uses Instance.project.id. Let me check what project the child sessions are stored under:
46: projectID: z.string(),
203: projectID: Instance.project.id,
214: await Storage.write(["session", Instance.project.id, result.id], result)
236: const base = Instance.project.vcs
243: const read = await Storage.read<Info>(["session", Instance.project.id, id])
284: const project = Instance.project
285: const result = await Storage.update<Info>(["session", project.id, id], (draft) => {
319: const project = Instance.project
320: for (const item of await Storage.list(["session", project.id])) {
326: const project = Instance.project
328: for (const item of await Storage.list(["session", project.id])) {
337: const project = Instance.project
350: await Storage.remove(["session", project.id, sessionID])
<path>/home/byk/Code/opencode/packages/opencode/src/session/index.ts</path>
<type>file</type>
<content>195: parentID?: string
196: directory: string
197: permission?: PermissionNext.Ruleset
198: }) {
199: const result: Info = {
200: id: Identifier.descending("session", input.id),
201: slug: Slug.create(),
202: version: Installation.VERSION,
203: projectID: Instance.project.id,
204: directory: input.directory,
205: parentID: input.parentID,
206: title: input.title ?? createDefaultTitle(!!input.parentID),
207: permission: input.permission,
208: time: {
209: created: Date.now(),
210: updated: Date.now(),
211: },
212: }
213: log.info("created", result)
214: await Storage.write(["session", Instance.project.id, result.id], result)
215: Bus.publish(Event.Created, {
216: info: result,
217: })
218: const cfg = await Config.get()
219: if (!result.parentID && ...
The session is stored under ["session", Instance.project.id, result.id], and Session.get reads from ["session", Instance.project.id, id]. Both use Instance.project.id.
But wait — Instance.project.id is the current project context when the request is served. When ctx.client.session.get is called from the nuum plugin (running as a plugin in the opencode-nuum project context), the request goes to the server which uses Instance.project.id — but which project? The session was created in the opencode-nuum project. If the nuum plugin is running within the opencode-nuum project, it should be the same project.
Actually, looking at this more carefully: The child sessions are created with parentID in the session list (confirmed from the /session list endpoint). But GET /session/{id} returns "Not Found" for these same sessions. This strongly suggests the GET /session/{id} endpoint is looking in the wrong project context, or there's a timing issue, or Instance.project.id differs between the list call and the get call.
Let me check how the list works:
<path>/home/byk/Code/opencode/packages/opencode/src/session/index.ts</path>
<type>file</type>
<content>316: )
317:
318: export async function* list() {
319: const project = Instance.project
320: for (const item of await Storage.list(["session", project.id])) {
321: yield Storage.read<Info>(item)
322: }
323: }
324:
325: export const children = fn(Identifier.schema("session"), async (parentID) => {
326: const project = Instance.project
327: const result = [] as Session.Info[]
328: for (const item of await Storage.list(["session", project.id])) {
329: const session = await Storage.read<Info>(item)
330: if (session.parentID !== parentID) continue
331: result.push(session)
332: }
333: return result
334: })
335:
336: export const remove = fn(Identifier.schema("session"), async (sessionID) => {
337: const project = Instance.project
338: try {
339: const session = await get(sessionID)
340: for (const ...
Both list() and get() use Instance.project.id. They should be consistent. But why does the GET fail for sessions that appear in the list?
The GET /session/{sessionID} endpoint uses Session.get.schema as a validator. Let me check that schema:
18:import { fn } from "@/util/fn"
44: id: Identifier.schema("session"),
48: parentID: Identifier.schema("session").optional(),
130: export const create = fn(
133: parentID: Identifier.schema("session").optional(),
148: export const fork = fn(
150: sessionID: Identifier.schema("session"),
151: messageID: Identifier.schema("message").optional(),
186: export const touch = fn(Identifier.schema("session"), async (sessionID) => {
242: export const get = fn(Identifier.schema("session"), async (id) => {
247: export const getShare = fn(Identifier.schema("session"), async (id) => {
251: export const share = fn(Identifier.schema("session"), async (id) => {
270: export const unshare = fn(Identifier.schema("session"), async (id) => {
297: export const diff = fn(Identifier.schema("session"), async (sessionID) => {
302: export const messages = fn(
3:export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
import { z } from "zod"
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
const parsed = schema.parse(input)
return cb(parsed)
}
result.force = (input: z.infer<T>) => cb(input)
result.schema = schema
return result
}
10:import { Identifier } from "../id/id"
44: id: Identifier.schema("session"),
48: parentID: Identifier.schema("session").optional(),
import z from "zod"
import { randomBytes } from "crypto"
export namespace Identifier {
const prefixes = {
session: "ses",
message: "msg",
permission: "per",
question: "que",
user: "usr",
part: "prt",
pty: "pty",
tool: "tool",
} as const
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix])
}
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, false, given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, true, given)
}
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
if (!given) {
return create(prefix, descending)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not ...
Identifier.schema("session") is just z.string().startsWith("ses"). So the validation passes for ses_384e7de8dffe.
The "Not Found" error must come from Session.get when the Storage read returns null/undefined. Let me check Storage:
17: export const NotFoundError = NamedError.create(
18: "NotFoundError",
186: await Bun.write(target, JSON.stringify(content, null, 2))
196: await Bun.write(target, JSON.stringify(content, null, 2))
205: throw new NotFoundError({ message: `Resource not found: ${errnoException.path}` })
/home/byk/Code/opencode/packages/opencode/src/storage/storage.ts
114: sessionID: Session.get.schema, 120: const session = await Session.get(sessionID) 413: const session = await Session.get(sessionID) 483: const session = await Session.get(sessionID) 522: const session = await Session.get(sessionID)
/home/byk/.opencode /home/byk/.opencode/command /home/byk/.opencode/command/.git /home/byk/.opencode/command/.git/objects /home/byk/.opencode/command/.git/objects/24 /home/byk/.opencode/command/.git/objects/b3 /home/byk/.opencode/command/.git/objects/d4 /home/byk/.opencode/command/.git/objects/pack /home/byk/.opencode/command/.git/objects/30 /home/byk/.opencode/command/.git/objects/39
bin bun.lock command env messages node_modules opencode.json package.json parts
ses_38f85e354ffepiQ7LAGIn1v9c4 ses_38fa8db10ffe7I4AbL9K9pDM31 ses_38fa952b6ffeD87OqZFepNeKe9 ses_391dfe776ffejfpVRHHJAo3MLh ses_3927c65afffeUHDUomZrdIWuIu ses_392e9f44dffeKFanAiZGTFL3gj ses_394b4b6b8ffeonkTG7nQ4EGEAU ses_3970adf84ffeqL1pv462iPa7Dk ses_397462ca8ffercsUM7SFc76YLW ses_39758edebffeoDM7DD7DZT7PF9 59
The sessions in ~/.opencode/messages/ use compound names like ses_38f85e354ffepiQ7LAGIn1v9c4. The session list API returns ses_384e7de8dffe as the ID. But the actual stored file might have a different key.
Let me check what Storage.read uses as the key:
2:import path from "path" 26: const project = path.resolve(dir, "../project") 34: const fullProjectDir = path.join(project, projectDir) 39: cwd: path.join(project, projectDir), 43: worktree = json.path?.root 63: await Bun.write( 64: path.join(dir, "project", projectID + ".json"), 81: const dest = path.join(dir, "session", projectID, path.basename(sessionFile)) 87: await Bun.write(dest, JSON.stringify(session)) 93: const dest = path.join(dir, "message", session.id, path.basename(msgFile)) 99: await Bun.write(dest, JSON.stringify(message)) 108: const dest = path.join(dir, "part", message.id, path.basename(partFile)) 114: await Bun.write(dest, JSON.stringify(part)) 130: await Bun.file(path.join(dir, "session_diff", session.id + ".json")).write(JSON.stringify(diffs)) 131: await Bun.file(path.join(dir, "session", session.projectID, session.id + ...
Storage.read(["session", Instance.project.id, id]) → path.join(dir, "session", projectID, id) + ".json".
The sessions in ~/.opencode/messages/ — wait, that's messages, not session. Let me find the actual session storage directory:
/home/byk/.local/share/opencode/storage/session_share /home/byk/.local/share/opencode/storage/session /home/byk/.local/share/opencode/storage/session/5f629834ba3bc2090f734fcf0eb052c486cdffa5 /home/byk/.local/share/opencode/storage/session/c00f3ad806d70689c1275c1c1b24e9c47dff83a3 /home/byk/.local/share/opencode/storage/session/3c2e87573d3bd16f61cf08fece0638cc47a4fc22 /home/byk/.local/share/opencode/storage/session/125e3ebeb747c35789e81a4b036ef58ca480e265 /home/byk/.local/share/opencode/storage/session/90fbad67f27e23aabcbf1c1293bad006994f80da /home/byk/.local/share/opencode/storage/session/global /home/byk/.local/share/opencode/storage/session/f9966c0cb8137accfb2b904787b0ce9a2b1b93cb /home/byk/.local/share/opencode/storage/session/0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0
061aceb23955577676134a4c5cbaba657b67bc37 0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0 125e3ebeb747c35789e81a4b036ef58ca480e265 3c2e87573d3bd16f61cf08fece0638cc47a4fc22 4b0ea68d7af9a6031a7ffda7ad66e0cb83315750 ses_4272881e0ffeHlrBp1kmH1R8Ee.json ses_42749ac1affeY8smQ2m78ENHqG.json ses_427b80893ffeDNZ1BD0EyESP8V.json ses_427bff3ddffePwD5n0ARRIFdYZ.json ses_427c83520ffe6J5XzwwYr6P0eQ.json ses_4296df54fffeOGYBGGWod2duVn.json ses_4297a9ce3ffejZQeXMG69fe9cS.json ses_4298454bcffeOe6VYRqQnKOJbQ.json ses_429933b89ffe3efZsG1zmbX0t4.json ses_42994ff51ffeCZKLrl4uQDYGeq.json
The session files have a different format — they include extra characters after the ID. Session IDs like ses_384e7de8dffe are stored as ses_384e7de8dffe<random>.json. Let me look at what the session list returns vs what's in storage:
14
061aceb23955577676134a4c5cbaba657b67bc37 0e8c2f3bbe145a3280d6fb6ed900a5121f382cf0 125e3ebeb747c35789e81a4b036ef58ca480e265 3c2e87573d3bd16f61cf08fece0638cc47a4fc22 4b0ea68d7af9a6031a7ffda7ad66e0cb83315750
The eval sessions (ses_384e*) are NOT in storage at all — they've been cleaned up. This explains why Session.get returns "Not Found". But why does the list endpoint still return them?
Let me check the list endpoint again:
{
"name": "NotFoundError",
"data": {
"message": "Session not found: ses_384e48127ffe"
}
}
Child session: {
"id": "ses_384e7de8dffeBDc4Z3dK9kfx1k",
"slug": "proud-lagoon",
"projectID": "global",
"directory": "/home/byk/Code",
"parentID": "ses_384ea1f06ffeRQvNMBLUyAnUjI",
"title": "Child session - 2026-02-20T12:48:24.946Z",
"version": "1.2.9",
"summary": {
"additions": 0,
"deletio
Found it! The session ID in the list is ses_384e7de8dffeBDc4Z3dK9kfx1k — the full ID including the random suffix. But the session list API returns just ses_384e7de8dffe (truncated). Let me check:
Wait, looking more carefully: ses_384e7de8dffeBDc4Z3dK9kfx1k is the full ID. The Identifier.schema("session") just checks if the string starts with "ses". So ses_384e7de8dffe would also pass validation, but the file is stored as ses_384e7de8dffeBDc4Z3dK9kfx1k.json.
Let me check what the SDK ctx.client.session.get actually receives as the ID:
Looking at the nuum plugin code again:
const session = await ctx.client.session.get({ path: { id: sessionID } });
And sessionID comes from msg.sessionID — the session ID from the message event. What ID format does the event deliver?
Let me check the message events by looking at how messages store their session IDs:
117: sessionID: z.string(),
124: sessionID: z.string().optional(),
150: sessionID: Identifier.schema("session"),
157: const msgs = await messages({ sessionID: input.sessionID })
168: sessionID: session.id,
178: sessionID: session.id,
186: export const touch = fn(Identifier.schema("session"), async (sessionID) => {
187: await update(sessionID, (draft) => {
297: export const diff = fn(Identifier.schema("session"), async (sessionID) => {
298: const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
304: sessionID: Identifier.schema("session"),
309: for await (const msg of MessageV2.stream(input.sessionID)) {
336: export const remove = fn(Identifier.schema("session"), async (sessionID) => {
339: const session = await get(sessionID)
340: for (const child of await children(sessionID)) {
343: await unshare(sessionID).catch(() => {})
344: for (const msg of await ...
At line 168, messages get sessionID: session.id — the full session ID. But let me check how the message event propagates sessionID to the nuum plugin:
Looking at Message.Updated event and how it carries sessionID:
/home/byk/Code/opencode/packages/opencode/src/plugin/codex.ts /home/byk/Code/opencode/packages/opencode/src/plugin/copilot.ts /home/byk/Code/opencode/packages/opencode/src/plugin/index.ts
63: const message = err instanceof Error ? err.message : String(err)
67: error: message,
71: message: `Failed to install built-in plugin ${pkg}@${version}: ${message}`,
80: // Prevent duplicate initialization when plugins export the same function
99: Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
129: hook["event"]?.({
130: event: input,
34: async function shouldSkip(sessionID: string): Promise<boolean> {
35: if (distillation.isWorkerSession(sessionID)) return true;
36: if (skipSessions.has(sessionID)) return true;
37: if (activeSessions.has(sessionID)) return false; // already known good
40: const session = await ctx.client.session.get({ path: { id: sessionID } });
42: skipSessions.add(sessionID);
53: async function backgroundDistill(sessionID: string, force?: boolean) {
58: const pending = temporal.undistilledCount(projectPath, sessionID);
67: sessionID,
79: async function backgroundCurate(sessionID: string) {
86: sessionID,
117: if (await shouldSkip(msg.sessionID)) return;
120: path: { id: msg.sessionID, messageID: msg.id },
128: activeSessions.add(msg.sessionID);
138: const pending = temporal.undistilledCount(projectPath, msg.sessionID);
141: `[nuum] incremental distillation: ${pending} undistilled messages in ...
At line 117, msg.sessionID comes from event.properties.info (line 116). The event carries the info object which has sessionID. Let me check the event structure:
The key issue is: what format is msg.sessionID? The full ID with random suffix (ses_384e7de8dffeBDc4Z3dK9kfx1k) or truncated (ses_384e7de8dffe)?
Actually, re-reading the earlier output: When I did curl -s "http://localhost:4096/session", the child session showed "id": "ses_384e7de8dffeBDc4Z3dK9kfx1k". So the session list uses the FULL ID. And GET /session/{id} uses the full ID too.
But the temporal DB shows ses_384e7de8dffe (without the random suffix) as session_id. And the temporal purge uses ≤5 messages as the threshold.
Wait — let me re-read the session IDs more carefully. Looking at the curl output:
ses_384e7de8dffeBDc4Z3dK9kfx1k (with suffix)ses_384e7de8dffe (without suffix)So the message events deliver short session IDs (without the random suffix), but the actual session storage uses the full ID with random suffix. This means Session.get(short_id) always fails because the file is stored under the full ID!
Let me verify:
Full ID from list: ses_384e7de8dffeBDc4Z3dK9kfx1k Short prefix: ses_384e7de8dffe GET with full ID: ses_384e7de8dffeBDc4Z3dK9kfx1k GET with short ID (16 chars): NotFoundError
Confirmed! The message events deliver session IDs in short format (ses_384e7de8dffe), but Session.get only works with the full ID (ses_384e7de8dffeBDc4Z3dK9kfx1k). The shouldSkip function calls ctx.client.session.get({ path: { id: sessionID } }) with the short ID → gets "NotFoundError" → catch block returns false → session gets stored.
Now I need to understand:
Let me check how the message event delivers sessionID:
145: sessionID: z.string(),
145: sessionID: z.string(),
/home/byk/Code/opencode/packages/opencode/src/session/revert.ts:25: const all = await Session.messages({ sessionID: input.sessionID })
/home/byk/Code/opencode/packages/opencode/src/session/revert.ts:95: let msgs = await Session.messages({ sessionID })
/home/byk/Code/opencode/packages/opencode/src/session/revert.ts:100: await Storage.remove(["message", sessionID, msg.info.id])
/home/byk/Code/opencode/packages/opencode/src/session/index.ts:157: const msgs = await messages({ sessionID: input.sessionID })
/home/byk/Code/opencode/packages/opencode/src/session/index.ts:344: for (const msg of await Storage.list(["message", sessionID])) {
/home/byk/Code/opencode/packages/opencode/src/session/index.ts:360: await Storage.write(["message", msg.sessionID, msg.id], msg)
/home/byk/Code/opencode/packages/opencode/src/session/index.ts:373: await Storage.remove(["message", input.sessionID, ...
28: sessionID: string
59: SessionStatus.set(input.sessionID, { type: "busy" })
69: sessionID: input.assistantMessage.sessionID,
107: sessionID: input.assistantMessage.sessionID,
160: sessionID: input.assistantMessage.sessionID,
230: sessionID: input.sessionID,
250: sessionID: input.assistantMessage.sessionID,
262: sessionID: input.sessionID,
271: sessionID: input.sessionID,
283: sessionID: input.assistantMessage.sessionID,
311: sessionID: input.sessionID,
349: SessionStatus.set(input.sessionID, {
360: sessionID: input.assistantMessage.sessionID,
370: sessionID: input.sessionID,
The sessionID is carried from the input input.sessionID. This is set when a prompt starts. Let me check the prompt entry and how it gets the session ID:
Actually, the key fact is clear: msg.sessionID in the nuum event handler is the short form (e.g., ses_384e7de8dffe) while the actual session is stored with the full ID including random suffix (ses_384e7de8dffeBDc4Z3dK9kfx1k).
The ctx.client.session.get({ path: { id: shortId } }) fails because the storage key lookup fails. The fix should use a different approach to check if a session has a parentID.
Let me check if there's a session.list or session.children API, or if we can use a different endpoint:
Actually, looking at the session list output: it returned ses_384e7de8dffeBDc4Z3dK9kfx1k with parentID. If we search for sessions where the ID starts with ses_384e7de8dffe, we'd find it. But that's expensive.
A better approach: Use the /session list endpoint and search for the session by prefix. Or, more efficiently, just check the format of the session ID — if it's a "short" ID, it means we're dealing with a child session created by the eval that OpenCode serves via the event but stores differently.
Actually wait — let me think about this differently. If msg.sessionID is the short ID, then:
ctx.client.session.get(short_id) → failsctx.client.session.message({ path: { id: msg.sessionID, messageID: msg.id } }) — also uses the short ID, line 120If the message API also fails for child sessions, we'd never store them anyway (line 121-122 only stores if full.data is set). But clearly the sessions ARE being stored (we see the content in temporal DB).
Let me check if the message API uses a different lookup:
Child full ID: ses_384e7de8dffeBDc4Z3dK9kfx1k Short ID: ses_384e7de8dffe Messages with full ID: 101 messages Messages with short ID: 0 messages
Interesting! The messages API with short ID returns 0 messages (not error). So when shouldSkip calls ctx.client.session.get(shortId) → fails → returns false (don't skip). Then ctx.client.session.message({ path: { id: shortId, messageID: msgId } }) → returns empty/not found. But the code stores full.data if it's defined...
Wait — but full.data returns null or undefined when not found? Let me check:
Actually looking at the code again at line 122: if (full.data). If message API returns an error or null, full.data would be undefined/null, so it wouldn't store. But the sessions ARE being stored...
Let me re-examine. The message events come from the plugin system, which runs in the OpenCode process. The ctx.client is a local client. Let me check if ctx.client.session.message works differently than the HTTP API:
/home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/sdk.gen.js /home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.js
144: * Fork an existing session at a specific message
172: * Create and send a new message to a session
176: * Get a message from a session
178: message<ThrowOnError extends boolean = false>(options: Options<SessionMessageData, ThrowOnError>): import("./client/types.gen.js").RequestResult<SessionMessageResponses, SessionMessageErrors, ThrowOnError, "fields">;
180: * Create and send a new message to a session, start if needed and return immediately
192: * Revert a message
ctx.client.session.message is an HTTP call to the server. When the short ID is used, the server returns 0 messages. But full.data is an object from the response... Let me check what full looks like:
Actually, looking at the SDK client pattern, ctx.client.session.message(...) returns a RequestResult with a .data field. If the server returns an empty array or null, full.data would be the empty array or null.
But we see the messages are being stored! Let me check if perhaps the messages API for short IDs does return data:
Short ID: ses_384e7de8dffe Full ID: ses_384e7de8dffeBDc4Z3dK9kfx1k
First message ID: msg_c7b1821790019jMz
Message with short sessionID: {"info":{"role":"user","time":{"created":1771591704953},"summary":{"diffs":[]},"agent":"nuum-distill","model":{"providerID":"anthropic","modelID":"claude-sonnet-4-6"},"id":"msg_c7b1821790019jMzQqDWNn6
The message API WORKS with the short session ID! It returns the message data. So the session.message call succeeds and stores the message in temporal.
Now the full picture is clear:
session.get(shortId) → fails → shouldSkip returns false → session considered activesession.message({ sessionID: shortId, messageID: ... }) → succeeds → message stored in temporalThe session.get API uses Storage.read(["session", projectID, id]) which requires an exact match. But session.message (via MessageV2 storage) uses Storage.read(["message", sessionID, messageID]) — the messages are stored with the short session ID as the key!
So the sessions created by the eval use short IDs for message storage but full IDs for session storage. This is inconsistent. Or more likely, the message storage always uses the short ID regardless of what session.get expects.
The fix for shouldSkip needs a different approach. Instead of using session.get (which fails for short IDs), we should use a different way to detect child sessions. Options:
/session list with filtering to find the parentID by ID prefixshouldSkip to try session.get with both short and full IDs/session list endpoint with a direct lookupOption 2 (check title) is fragile. Option 4 is cleanest — filter the list by ID. Option 1 is the same as option 4.
Actually, the most robust fix: when session.get(shortId) fails, try to search the session list for a session whose full ID starts with shortId. Let me implement this:
<path>/home/byk/Code/opencode-nuum/src/index.ts</path>
<type>file</type>
<content>34: async function shouldSkip(sessionID: string): Promise<boolean> {
35: if (distillation.isWorkerSession(sessionID)) return true;
36: if (skipSessions.has(sessionID)) return true;
37: if (activeSessions.has(sessionID)) return false; // already known good
38: // First encounter — check if this is a child session
39: try {
40: const session = await ctx.client.session.get({ path: { id: sessionID } });
41: if (session.data?.parentID) {
42: skipSessions.add(sessionID);
43: return true;
44: }
45: } catch {
46: // If we can't fetch session info, don't skip
47: }
48: return false;
49: }
50:
51: // Background distillation — debounced, non-blocking
52: let distilling = false;
53: async function backgroundDistill(sessionID: string, force?: boolean) {
(Showing lines 34-53 of 309. Use offset=54 to continue.)
</content>
The fix: when session.get fails, fall back to searching the session list for a session whose full ID starts with sessionID:
Edit applied successfully.
Now let me verify the SDK has a session.list method:
136: * Get the todo list for a session
No session.list in the SDK. Let me check what session methods are available:
390: session: Session;
26: event<ThrowOnError extends boolean = false>(options?: Options<GlobalEventData, ThrowOnError>): Promise<import("./core/serverSentEvents.gen.js").ServerSentEventsResult<GlobalEventResponses, unknown>>;
32: list<ThrowOnError extends boolean = false>(options?: Options<ProjectListData, ThrowOnError>): import("./client/types.gen.js").RequestResult<ProjectListResponses, unknown, ThrowOnError, "fields">;
36: current<ThrowOnError extends boolean = false>(options?: Options<ProjectCurrentData, ThrowOnError>): import("./client/types.gen.js").RequestResult<ProjectCurrentResponses, unknown, ThrowOnError, "fields">;
42: list<ThrowOnError extends boolean = false>(options?: Options<PtyListData, ThrowOnError>): import("./client/types.gen.js").RequestResult<PtyListResponses, unknown, ThrowOnError, "fields">;
46: create<ThrowOnError extends boolean = false>(options?: Options<PtyCreateData, ThrowOnError>): import("./client/types.gen.js").RequestResult<PtyCreateResponses, ...
session.list() is available at line 110. My fix uses ctx.client.session.list() which should work. But fetching ALL sessions just to check one is expensive. A better approach: use session.children with the parent ID... but we don't know the parent ID.
Actually, there's a simpler and more efficient fix: the session title for child sessions starts with "Child session -". But that's fragile.
Even better: use the roots query parameter on the list to filter to non-root sessions. Or just check the session title.
Actually wait — looking again. The real issue is that fetching the entire session list on every first encounter of a session is expensive. But it only happens once per session ID (the result is cached in skipSessions). And eval concurrency is 3, so at most 3 concurrent calls. This should be acceptable.
However, there's another approach that's even cleaner: use session.children with the evalRoot ID. But we don't have that in the plugin.
Actually the simplest fix that avoids the list call entirely: use a fetch directly to /session/{fullId} where we try to reconstruct the full ID. But we can't reconstruct the random suffix.
Another option: look for the session in the recent event history since the session was just created. Or use session.list({ query: { roots: false } }) if such filtering exists.
Let me check the SessionListData:
1793:export type SessionListData = {
<path>/home/byk/Code/opencode-nuum/node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts</path>
<type>file</type>
<content>1793: export type SessionListData = {
1794: body?: never;
1795: path?: never;
1796: query?: {
1797: directory?: string;
1798: };
1799: url: "/session";
1800: };
1801: export type SessionListResponses = {
1802: /**
1803: * List of sessions
1804: */
1805: 200: Array<Session>;
1806: };
1807: export type SessionListResponse = SessionListResponses[keyof SessionListResponses];
1808: export type SessionCreateData = {
1809: body?: {
1810: parentID?: string;
1811: title?: string;
1812: };
(Showing lines 1793-1812 of 3380. Use offset=1813 to continue.)
</content>
No roots filter in the SDK session.list. Let me keep my current fix using session.list() since it only happens once per new session and is cached. The current implementation is functionally correct.
But wait — there's a smarter alternative. Instead of the list approach, we could look at the temporal storage itself. If the session has stored messages that look like eval sessions (short title "Child session"), we could detect that. But that's also hacky.
Actually, the cleanest fix is to catch the pattern in the event payload itself. The event.properties.info has a sessionID. Let me check if the event also carries parentID:
/home/byk/Code/opencode/packages/opencode/src/session/message-v2.ts:401: "message.updated",
/home/byk/Code/opencode/packages/opencode/src/cli/cmd/tui/context/sync.tsx:228: case "message.updated": {
<path>/home/byk/Code/opencode/packages/opencode/src/session/message-v2.ts</path>
<type>file</type>
<content>395: ref: "Message",
396: })
397: export type Info = z.infer<typeof Info>
398:
399: export const Event = {
400: Updated: BusEvent.define(
401: "message.updated",
402: z.object({
403: info: Info,
404: }),
405: ),
406: Removed: BusEvent.define(
407: "message.removed",
408: z.object({
409: sessionID: z.string(),
410: messageID: z.string(),
411: }),
412: ),
413: PartUpdated: BusEvent.define(
414: "message.part.updated",
(Showing lines 395-414 of 747. Use offset=415 to continue.)
</content>
41: sessionID: z.string(),
302: sessionID: z.string(),
409: sessionID: z.string(),
423: sessionID: z.string(),
609: export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
610: const list = await Array.fromAsync(await Storage.list(["message", sessionID]))
613: sessionID,
631: sessionID: Identifier.schema("session"),
636: info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
<path>/home/byk/Code/opencode/packages/opencode/src/session/message-v2.ts</path>
<type>file</type>
<content>35: }),
36: )
37: export type APIError = z.infer<typeof APIError.Schema>
38:
39: const PartBase = z.object({
40: id: z.string(),
41: sessionID: z.string(),
42: messageID: z.string(),
43: })
44:
45: export const SnapshotPart = PartBase.extend({
46: type: z.literal("snapshot"),
47: snapshot: z.string(),
48: }).meta({
49: ref: "SnapshotPart",
50: })
51: export type SnapshotPart = z.infer<typeof SnapshotPart>
52:
53: export const PatchPart = PartBase.extend({
54: type: z.literal("patch"),
(Showing lines 35-54 of 747. Use offset=55 to continue.)
</content>
<path>/home/byk/Code/opencode/packages/opencode/src/session/message-v2.ts</path>
<type>file</type>
<content>295: }).meta({
296: ref: "ToolPart",
297: })
298: export type ToolPart = z.infer<typeof ToolPart>
299:
300: const Base = z.object({
301: id: z.string(),
302: sessionID: z.string(),
303: })
304:
305: export const User = Base.extend({
306: role: z.literal("user"),
307: time: z.object({
308: created: z.number(),
309: }),
310: summary: z
311: .object({
312: title: z.string().optional(),
313: body: z.string().optional(),
314: diffs: Snapshot.FileDiff.array(),
315: })
316: .optional(),
317: agent: z.string(),
318: model: z.object({
319: providerID: z.string(),
(Showing lines 295-319 of 747. Use offset=320 to continue.)
</content>
The Info (message) carries sessionID: z.string(). The message event doesn't carry parentID of the session. So we can't determine child status from the event alone.
My current fix is correct — use session.list() as a fallback. Let me also check if maybe there's a more direct route. Looking at the server routes:
Actually, let me look at this from a completely different angle. The messages API works with the short session ID (GET /session/{shortId}/message/{msgId} returns data). This means the server can match messages by short session ID prefix somehow. But session.get doesn't do that prefix matching.
Let me look at how session.message is handled on the server:
6:import { MessageV2 } from "../../session/message-v2"
590: operationId: "session.message",
617: const message = await MessageV2.get({
<path>/home/byk/Code/opencode/packages/opencode/src/server/routes/session.ts</path>
<type>file</type>
<content>610: z.object({
611: sessionID: z.string().meta({ description: "Session ID" }),
612: messageID: z.string().meta({ description: "Message ID" }),
613: }),
614: ),
615: async (c) => {
616: const params = c.req.valid("param")
617: const message = await MessageV2.get({
618: sessionID: params.sessionID,
619: messageID: params.messageID,
620: })
621: return c.json(message)
622: },
623: )
624: .delete(
625: "/:sessionID/message/:messageID/part/:partID",
626: describeRoute({
627: description: "Delete a part from a message",
628: operationId: "part.delete",
629: responses: {
(Showing lines 610-629 of 939. Use offset=630 to continue.)
</content>
629: export const get = fn(
<path>/home/byk/Code/opencode/packages/opencode/src/session/message-v2.ts</path>
<type>file</type>
<content>629: export const get = fn(
630: z.object({
631: sessionID: Identifier.schema("session"),
632: messageID: Identifier.schema("message"),
633: }),
634: async (input): Promise<WithParts> => {
635: return {
636: info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
637: parts: await parts(input.messageID),
638: }
639: },
640: )
641:
642: export async function filterCompacted(stream: AsyncIterable<MessageV2.WithParts>) {
643: const result = [] as MessageV2.WithParts[]
644: const completed = new Set<string>()
645: for await (const msg of stream) {
646: result.push(msg)
647: if (
648: msg.info.role === "user" &&
(Showing lines 629-648 of 747. Use offset=649 to continue.)
</content>
MessageV2.get uses Storage.read(["message", sessionID, messageID]). Messages are stored under ["message", sessionID, messageID]. If messages are stored with the short session ID, then the read works.
But wait, messages are stored at line 360 in session/index.ts: Storage.write(["message", msg.sessionID, msg.id], msg). And msg.sessionID here would be... whatever is in the message's sessionID field, which comes from the message creation.
Let me trace back: when a prompt is processed, where does message.sessionID get set?
1210: sessionID: userMessage.info.sessionID, 1221: sessionID: userMessage.info.sessionID, 1241: sessionID: userMessage.info.sessionID, 1260: sessionID: userMessage.info.sessionID,
1210: sessionID: userMessage.info.sessionID, 1221: sessionID: userMessage.info.sessionID, 1241: sessionID: userMessage.info.sessionID, 1260: sessionID: userMessage.info.sessionID,
1201: const userMessage = input.messages.findLast((msg) => msg.info.role === "user") 1210: sessionID: userMessage.info.sessionID, 1221: sessionID: userMessage.info.sessionID, 1241: sessionID: userMessage.info.sessionID, 1260: sessionID: userMessage.info.sessionID,
669: messages: input.messages, 1201: const userMessage = input.messages.findLast((msg) => msg.info.role === "user") 1202: if (!userMessage) return input.messages 1216: const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") 1227: return input.messages 1231: const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") 1249: return input.messages 1335: return input.messages 1337: return input.messages
152: export const prompt = fn(PromptInput, async (input) => {
<path>/home/byk/Code/opencode/packages/opencode/src/session/prompt.ts</path>
<type>file</type>
<content>100: .describe(
101: "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
102: ),
103: system: z.string().optional(),
104: variant: z.string().optional(),
105: parts: z.array(
106: z.discriminatedUnion("type", [
107: MessageV2.TextPart.omit({
108: messageID: true,
109: sessionID: true,
110: })
111: .partial({
112: id: true,
113: })
114: .meta({
115: ref: "TextPartInput",
116: }),
117: MessageV2.FilePart.omit({
118: messageID: true,
119: sessionID: true,
120: })
121: .partial({
122: id: true,
123: })
124: .meta({
125: ref: "FilePartInput",
126: }),
127: MessageV2.AgentPart.omit({
128: ...
156: const message = await createUserMessage(input)
828: async function createUserMessage(input: PromptInput) {
<path>/home/byk/Code/opencode/packages/opencode/src/session/prompt.ts</path>
<type>file</type>
<content>828: async function createUserMessage(input: PromptInput) {
829: const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
830: const info: MessageV2.Info = {
831: id: input.messageID ?? Identifier.ascending("message"),
832: role: "user",
833: sessionID: input.sessionID,
834: time: {
835: created: Date.now(),
836: },
837: tools: input.tools,
838: agent: agent.name,
839: model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
840: system: input.system,
841: variant: input.variant,
842: }
843: using _ = defer(() => InstructionPrompt.clear(info.id))
844:
845: const parts = await Promise.all(
846: input.parts.map(async (part): Promise<MessageV2.Part[]> => {
847: if (part.type === "file") {
848: // before checking the protocol we check if ...
sessionID: input.sessionID — so messages are stored with whatever session ID was passed to prompt(). The prompt.ts:153 calls Session.get(input.sessionID) which uses the same ID. If the session is looked up with the short ID and fails there, the prompt would also fail...
But this means the prompt works fine — so Session.get(input.sessionID) must succeed with the short ID. Let me re-check the Storage read:
The Storage read call: Storage.read<Info>(["session", Instance.project.id, id]) → file path: ~/.local/share/opencode/storage/session/{projectID}/{id}.json.
If the file is ses_384e7de8dffeBDc4Z3dK9kfx1k.json and the lookup is for ses_384e7de8dffe.json, it would fail with NotFound.
But Session.get.schema = Identifier.schema("session") = z.string().startsWith("ses"). So ses_384e7de8dffe passes validation.
Wait — let me actually look at what prompt() does at line 153 with a child session:
Actually, at line 152, prompt is fn(PromptInput, ...). And PromptInput has sessionID: Identifier.schema("session"). Let me check what session ID the eval sends:
Looking at the eval script (line 160): await fetch(\${BASE_URL}/session/${sessionID}/prompt_async`, ...). The sessionIDhere comes fromcreateSession()which returnsres.id. And the server returns the full ID including random suffix. So prompt_async` receives the full ID.
But then message events fire with the short ID? Let me verify that:
Full session ID: ses_384e7de8dffeBDc4Z3dK9kfx1k First message sessionID: ses_384e7de8dffeBDc4Z3dK9kfx1k First message id: msg_c7b1821790019jMz
The message's sessionID IS the full ID (ses_384e7de8dffeBDc4Z3dK9kfx1k)! So the event would carry the full ID. But the temporal DB shows ses_384e7de8dffe as the session_id...
Wait — let me re-check what's actually in the temporal DB. Maybe I was looking at old data from before the shouldSkip logic was added, and the current session IDs in temporal ARE the full IDs but they look like ses_384e7de8dffe because I was only seeing 16 chars of a longer ID:
Small sessions (<=5 msgs): 10 session_id: "ses_384e48127ffeUHBw64NIJIpYU6" (len=30) msgs:2 time:12:52:05 You are evaluating whether a hypothesis correctly answers a question about a cod session_id: "ses_384e48139ffeoVc9PJMLLRezms" (len=30) msgs:2 time:12:52:05 You are evaluating whether a hypothesis correctly answers a question about a cod session_id: "ses_384e490fbffeDaeV2uS1bVlsoW" (len=30) msgs:2 time:12:52:01 You are a helpful coding assistant answering questions about past coding session session_id: "ses_384e498caffeJH21m7YHIE9l9q" (len=30) msgs:2 time:12:51:59 You are a helpful coding assistant answering questions about past coding session session_id: "ses_384e4a11dffeu2u3PgnkSejy5K" (len=30) msgs:2 time:12:51:57 You are evaluating whether a hypothesis correctly answers a question about a cod session_id: "ses_384e4a13dffeZro2aYu1FGGzwP" (len=30) msgs:2 time:12:51:57 You are evaluating whether a hypothesis correctly answers a question about a cod session_id: ...
The session IDs ARE full IDs (length 30). My earlier display was cutting them off at 16 chars. So the shouldSkip is being called with the full session ID ses_384e4b0cdffe5ZMBLyF0gt4nvb, but session.get("ses_384e4b0cdffe5ZMBLyF0gt4nvb") fails because...
Let me test with the full ID:
{
"id": "ses_384e4b0cdffe5ZMBLyF0gt4nvb",
"slug": "playful-meadow",
"projectID": "global",
"directory": "/home/byk/Code",
"parentID": "ses_384e57b77ffewyphEm6ik6qFPq",
"title": "Child session - 2026-02-20T12:51:53.266Z",
"version": "1.2.9",
"summary": {
"additions": 0,
session.get works with the full ID! And it returns parentID. So the issue is NOT what I thought. The shouldSkip with the full ID should work correctly.
But then why are these eval child sessions in temporal storage? Let me think...
The shouldSkip function:
distillation.isWorkerSession → false (eval sessions not registered)skipSessions.has → false (first encounter)activeSessions.has → false (first encounter)ctx.client.session.get({ path: { id: "ses_384e4b0cdffe5ZMBLyF0gt4nvb" } })session.data?.parentID → would be ses_384e57b77ffewyphEm6ik6qFPq → truthy → adds to skipSessions → returns trueSo shouldSkip should return true for these sessions. But they're still in temporal...
Unless the issue is that shouldSkip is async but the event handler doesn't properly await it, or there's a race condition where the session check runs concurrently with the storage operation. Let me re-read the event handler:
<path>/home/byk/Code/opencode-nuum/src/index.ts</path>
<type>file</type>
<content>112: const cfg = input as Record<string, unknown>;
113: cfg.compaction = { auto: false, prune: false };
114: cfg.agent = {
115: ...(cfg.agent as Record<string, unknown> | undefined),
116: "nuum-distill": {
117: hidden: true,
118: description: "Nuum memory distillation worker",
119: },
120: "nuum-curator": {
121: hidden: true,
122: description: "Nuum knowledge curator worker",
123: },
124: };
125: },
126:
127: // Store all messages in temporal DB for full-text search and distillation.
128: // Skips child sessions (eval, worker) to prevent pollution.
129: event: async ({ event }) => {
130: if (event.type === "message.updated") {
131: const msg = event.properties.info;
132: if (await shouldSkip(msg.sessionID)) return;
133: try {
134: const full = ...
The await shouldSkip(msg.sessionID) is properly awaited. So the flow should correctly skip eval sessions.
Wait — could this be a timing issue? When the first message from the eval session arrives:
shouldSkip is called with the full session IDctx.client.session.get(fullId) — an HTTP callawait, another concurrent call with the same session ID might also pass throughBut skipSessions is a Set that's checked synchronously. The second call would also find shouldSkip returning false at step 2 (since the first call hasn't returned yet), and also call session.get. Then both would find parentID and add to skipSessions. That would be idempotent — both would return true.
Actually, the issue might be simpler: The ctx.client.session.get might fail for a different reason. The eval script creates sessions concurrently in 3 threads. When shouldSkip is called, the session might not be fully persisted yet, causing a temporary "not found" error.
Let me look at the timing:
createSession() → POST /session → server creates and stores sessionpromptAndWait(sessionID, ...) is called → POST /session/{id}/prompt_asyncmessage.updated eventshouldSkip(sessionID) → calls session.get(sessionID) → session should exist by nowThe session should definitely exist when the message event fires, since the session was created before the prompt was sent.
Let me add some debug logging to understand what's actually happening. Actually, let me just check: is there maybe a different project context issue? The eval creates sessions in the /home/byk/Code directory (from the createSession implementation). The Session.get uses Instance.project.id. If the project ID changes between requests...
Actually, the OpenCode server runs with a single project context. ...
132dddc Fix JSONL summary parser and update eval results a17468b Update default eval results from final clean run (11/15 = 73.3%) 807d8f0 Add backfill script and populate sentry-cli + auth-api distillations 1d02e1d Fix FTS5 content-sync purge: rebuild index after content table deletes 763ee8f Skip child sessions in temporal storage and purge eval contamination e06369c Update MEMORY_PLAN with nuum+recall score (93.3% vs 73.3% default) e8f38f4 Add coding eval results: nuum 93.3% vs default 80.0% 8934b83 Incremental distillation + recall-enabled coding eval 21743b7 Update eval harness with windowed oracle context and refresh coding eval dataset 2bdc4c3 Fix infinite tool-call loop caused by unconditionally dropping trailing assistant messages
commit 763ee8fc2693334f753b57341d31f85745aa1d45
Author: Burak Yigit Kaya <byk@sentry.io>
Date: Fri Feb 20 12:11:21 2026 +0000
Skip child sessions in temporal storage and purge eval contamination
Two fixes for recall tool contamination during eval:
1. Plugin (src/index.ts): shouldSkip() checks if a session has a parentID
(child session) and caches the result. Child sessions (eval, worker,
any other children) are skipped for temporal storage and distillation.
Replaces the narrow isWorkerSession check.
2. Eval (eval/coding_eval.ts): purgeEvalMessages() runs at startup and
deletes temporal messages from small sessions (≤5 messages) that aren't
in the test dataset. Prevents prior eval runs from polluting recall
results.
eval/coding_eval.ts | 39 +++++++++++++++++++++++++
eval/results/coding_eval_default.jsonl | 5 +++-
eval/results/coding_eval_nuum.jsonl | 30 ++++++++++----------
...
The shouldSkip was added at 12:11. The eval ran at ~12:51. But the eval sessions ARE in temporal storage despite shouldSkip being active. So there's a real bug.
Let me check what the previous shouldSkip looked like vs the current one:
// Checked once per session ID and cached to avoid repeated API calls.
const skipSessions = new Set<string>();
async function shouldSkip(sessionID: string): Promise<boolean> {
if (distillation.isWorkerSession(sessionID)) return true;
if (skipSessions.has(sessionID)) return true;
if (activeSessions.has(sessionID)) return false; // already known good
// First encounter — check if this is a child session
try {
const session = await ctx.client.session.get({ path: { id: sessionID } });
if (session.data?.parentID) {
skipSessions.add(sessionID);
return true;
}
} catch {
// If we can't fetch session info, don't skip
}
return false;
}
// Background distillation — debounced, non-blocking
let distilling = false;
async function backgroundDistill(sessionID: string, force?: boolean) {
if (distilling) return;
distilling = true;
try {
const cfg = config();
const pending = ...
This is the same as the current implementation (before my fix). The session.get call should work for the full ID. But maybe the OpenCode instance running currently doesn't have the updated plugin code?
Let me check what version of the plugin is loaded:
{
"dependencies": {
"@opencode-ai/plugin": "1.2.9"
}
}@opencode-ai
zod
The opencode-nuum plugin is NOT installed in ~/.opencode/node_modules. How is it running then?
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-opus-4-6",
"theme": "system",
"autoupdate": true,
"server": {
"port": 4096,
"hostname": "0.0.0.0",
"mdns": false,
"cors": [
"http://labs:4096",
"https://opencode.sheep-fir.ts.net"
]
},
"default_agent": "plan",
"mode": {
"build": {
"model": "anthropic/claude-sonnet-4-6"
},
"plan": {
"model": "anthropic/claude-opus-4-6",
"tools": {
"bash": true,
"read": true,
"grep": true,
"glob": true,
"write": false,
"edit": false,
"list": true,
"patch": false,
"todowrite": true,
"todoread": true,
"webfetch": true
}
}
}
}
/home/byk/Code/opencode/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json
{
"mcp": {
"Sentry": {
"type": "remote",
"url": "https://mcp.sentry.dev/mcp",
"oauth": {}
},
"notion": {
"type": "remote",
"url": "https://mcp.notion.com/mcp",
"oauth": {}
}
},
"plugin": [
"opencode-anthropic-auth@latest"
],
"$schema": "https://opencode.ai/config.json"
}
/home/byk/.opencode/opencode.json /home/byk/Code/opencode/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json /home/byk/.config/opencode/opencode.json
/home/byk/Code/opencode/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json
The nuum plugin isn't configured as a plugin anywhere in the OpenCode config files. How is it being loaded?
auth.json bin log mcp-auth.json opencode.db opencode.db-shm opencode.db-wal snapshot storage tool-output worktree /home/byk/.local/share/opencode/worktree/3c2e87573d3bd16f61cf08fece0638cc47a4fc22/happy-panda/fixtures/artifact_bundle_duplicated_debug_ids/manifest.json /home/byk/.local/share/opencode/worktree/3c2e87573d3bd16f61cf08fece0638cc47a4fc22/happy-panda/fixtures/artifact_bundle/manifest.json /home/byk/.local/share/opencode/worktree/3c2e87573d3bd16f61cf08fece0638cc47a4fc22/happy-panda/fixtures/backup/invalid-user.json /home/byk/.local/share/opencode/worktree/3c2e87573d3bd16f61cf08fece0638cc47a4fc22/happy-panda/fixtures/backup/user-with-roles-no-superadmin.json /home/byk/.local/share/opencode/worktree/3c2e87573d3bd16f61cf08fece0638cc47a4fc22/happy-panda/fixtures/backup/user-with-minimum-privileges.json /home/byk/.local/share/opencode/worktree/3c2e87573d3bd16f61cf08fece0638cc47a4fc22/happy-panda/fixtures/backup/user-with-maximum-privileges.json /home/byk/.local/share/opencode/worktr...