diff --git a/enchiridion-ts/src/cli.run.test.ts b/enchiridion-ts/src/cli.run.test.ts index 1bff296..7b93680 100644 --- a/enchiridion-ts/src/cli.run.test.ts +++ b/enchiridion-ts/src/cli.run.test.ts @@ -206,15 +206,22 @@ test( // calling run(). This test simulates the result of that injection by setting // OPENCODE_SESSION_ID directly in process.env, then calling run(['save-session', // ...]). We assert that the command moves past the "neither ID is set" check -// (i.e., it reads the env var) before failing on the tracker state check, -// which is the expected failure when no .opencode/wiki-knowledge/sessions/ -// directory exists in cwd's ancestor chain. +// (i.e., it reads the env var) before failing further downstream. Since #402 +// an untracked OpenCode session no longer errors on tracker state — it falls +// through to `opencode export`, so in CI (no opencode CLI on PATH) the expected +// failure is the missing-CLI error, which still proves the env var was read. test( "run(['save-session']): reads OPENCODE_SESSION_ID from process.env (not 'neither ID' error)", { skip: skipReason }, async () => { const prevSessionID = process.env.OPENCODE_SESSION_ID; + const prevClaudeID = process.env.CLAUDE_CODE_SESSION_ID; process.env.OPENCODE_SESSION_ID = "test-opencode-session-id"; + // Isolate the OpenCode dispatch: with both host IDs set the tie-break would + // route an untracked session to Claude Code. Unset the Claude ID so only the + // OpenCode path runs, regardless of the ambient session (this suite may run + // inside a Claude Code session that sets CLAUDE_CODE_SESSION_ID). + delete process.env.CLAUDE_CODE_SESSION_ID; try { const result = await run(["save-session", "--slug", "test-session"]); // Must not succeed (no real OpenCode session), but the failure must NOT be @@ -225,12 +232,18 @@ test( !result.stderr.includes("Neither $CLAUDE_CODE_SESSION_ID"), `Expected OPENCODE_SESSION_ID to be read; got: ${result.stderr.trim()}`, ); - // The expected error is about the tracker state (state not located), not - // about the ID being absent. - assert.match(result.stderr, /OPENCODE_SESSION_ID|session-tracker/); + // The expected failure is downstream of the ID being read: either the + // env/tracker diagnostics, or (post-#402, untracked → `opencode export`) + // the missing-CLI error when opencode is absent from PATH. + assert.match( + result.stderr, + /OPENCODE_SESSION_ID|session-tracker|opencode CLI/, + ); } finally { if (prevSessionID === undefined) delete process.env.OPENCODE_SESSION_ID; else process.env.OPENCODE_SESSION_ID = prevSessionID; + if (prevClaudeID === undefined) delete process.env.CLAUDE_CODE_SESSION_ID; + else process.env.CLAUDE_CODE_SESSION_ID = prevClaudeID; } }, ); diff --git a/enchiridion-ts/src/transcriptcapture.test.ts b/enchiridion-ts/src/transcriptcapture.test.ts index 671adf3..bb1de12 100644 --- a/enchiridion-ts/src/transcriptcapture.test.ts +++ b/enchiridion-ts/src/transcriptcapture.test.ts @@ -18,7 +18,8 @@ import { normalizeExport, captureOpenCodeSession, captureSession, - findOpenCodeSessionID, + openCodeSessionIDFromEnv, + isOpenCodeSessionTracked, ErrTooFewTurns, CaptureError, } from "./transcriptcapture.js"; @@ -516,26 +517,73 @@ test("captureOpenCodeSession fails when the export seam errors", async () => { ); }); -test("findOpenCodeSessionID throws when OPENCODE_SESSION_ID is unset", () => { +test("captureOpenCodeSession captures a session that predates the tracker", async () => { + // No `.opencode/` state dir at all: the session was started before the + // session-tracker plugin was installed, so it was never recorded. `opencode + // export` still has the transcript, so the capture must succeed anyway (#402). + const project = tmp(); + const sessionID = "oc-pre-plugin-777"; + const lookupEnv = env({ OPENCODE_SESSION_ID: sessionID }); + const doc = JSON.stringify({ + info: { id: sessionID }, + messages: [ + { info: { role: "user" }, parts: [{ type: "text", text: "hi" }] }, + { info: { role: "assistant" }, parts: [{ type: "text", text: "hello" }] }, + ], + }); + const exportSeam = async (): Promise => + new TextEncoder().encode(doc); + + const wikiRoot = tmp(); + const rel = await captureOpenCodeSession( + wikiRoot, + "", + project, + lookupEnv, + NOW, + exportSeam, + ); + assert.match(rel, /-oc\.md$/); + const written = fs.readFileSync(path.join(wikiRoot, rel), "utf8"); + assert.match(written, /^# Session oc-pre-plugin-777/); +}); + +test("openCodeSessionIDFromEnv throws when OPENCODE_SESSION_ID is unset", () => { assert.throws( - () => findOpenCodeSessionID("", env({})), + () => openCodeSessionIDFromEnv(env({})), (err: unknown) => err instanceof CaptureError && /OPENCODE_SESSION_ID is not set/.test(err.message), ); }); -test("findOpenCodeSessionID throws when the session is untracked", () => { - const { project, sessionID } = openCodeEnvAndState(); +test("openCodeSessionIDFromEnv returns the id whether or not it is tracked", () => { + const id = openCodeSessionIDFromEnv(env({ OPENCODE_SESSION_ID: "oc-x" })); + assert.equal(id, "oc-x"); +}); + +test("isOpenCodeSessionTracked is true when the tracker recorded the session", () => { + const { project, lookupEnv } = openCodeEnvAndState(); + assert.equal(isOpenCodeSessionTracked(project, lookupEnv), true); +}); + +test("isOpenCodeSessionTracked is false when the session is untracked", () => { + const { project } = openCodeEnvAndState(); // A different id than the tracked one. const other = env({ OPENCODE_SESSION_ID: "oc-other" }); - assert.throws( - () => findOpenCodeSessionID(project, other), - (err: unknown) => - err instanceof CaptureError && - /No state recorded for session oc-other/.test(err.message), + assert.equal(isOpenCodeSessionTracked(project, other), false); +}); + +test("isOpenCodeSessionTracked is false when the env var is unset", () => { + const { project } = openCodeEnvAndState(); + assert.equal(isOpenCodeSessionTracked(project, env({})), false); +}); + +test("isOpenCodeSessionTracked is false when there is no state directory", () => { + assert.equal( + isOpenCodeSessionTracked(tmp(), env({ OPENCODE_SESSION_ID: "oc-x" })), + false, ); - void sessionID; }); // --------------------------------------------------------------------------- @@ -610,16 +658,26 @@ test("captureSession falls back to Claude Code when OpenCode id is untracked", a assert.match(rel, /^raw\/conversations\/2026-01-02-0304-transcript\.md$/); }); -test("captureSession dispatches to OpenCode when only OPENCODE_SESSION_ID is set", async () => { - // Only OPENCODE_SESSION_ID set: the host is OpenCode regardless of tracker - // state, so with no `.opencode` marker the OpenCode path fails before it - // ever shells out — the assertion is about *which* host path ran. - const lookupEnv = env({ OPENCODE_SESSION_ID: "oc-nope" }); - await assert.rejects( - () => captureSession(tmp(), "", tmp(), lookupEnv, NOW), - (err: unknown) => - err instanceof CaptureError && - /session-tracker/.test(err.message) && - !/CLAUDE_CODE_SESSION_ID/.test(err.message), +test("captureSession dispatches to OpenCode when only OPENCODE_SESSION_ID is set, even untracked", async () => { + // Only OPENCODE_SESSION_ID set and no tracker state at all: the host is + // OpenCode regardless, and the export path must still run and succeed (#402). + const lookupEnv = env({ OPENCODE_SESSION_ID: "oc-nope-123" }); + const doc = JSON.stringify({ + messages: [ + { info: { role: "user" }, parts: [{ type: "text", text: "hi" }] }, + { info: { role: "assistant" }, parts: [{ type: "text", text: "hello" }] }, + ], + }); + const wikiRoot = tmp(); + const rel = await captureSession( + wikiRoot, + "", + tmp(), + lookupEnv, + NOW, + async () => new TextEncoder().encode(doc), ); + assert.match(rel, /-oc\.md$/); + const written = fs.readFileSync(path.join(wikiRoot, rel), "utf8"); + assert.match(written, /\*\*Source:\*\* OpenCode session transcript/); }); diff --git a/enchiridion-ts/src/transcriptcapture.ts b/enchiridion-ts/src/transcriptcapture.ts index 5cd60aa..5ad68de 100644 --- a/enchiridion-ts/src/transcriptcapture.ts +++ b/enchiridion-ts/src/transcriptcapture.ts @@ -385,8 +385,7 @@ export async function captureSession( ); } if (openCodeID) { - try { - findOpenCodeSessionID(cwd, lookupEnv); + if (isOpenCodeSessionTracked(cwd, lookupEnv)) { return captureOpenCodeSession( wikiRoot, slug, @@ -395,8 +394,6 @@ export async function captureSession( now, exportSeam, ); - } catch { - // fall through to Claude Code } return captureClaudeCodeSession(wikiRoot, slug, cwd, lookupEnv, now); } @@ -522,16 +519,15 @@ function openCodeSessionIsTracked( } /** - * Returns the sessionID if tracked, or raises a CaptureError. + * Reads `$OPENCODE_SESSION_ID`, or raises a CaptureError. * - * Three distinct failures, kept distinct so the user can tell them apart: no - * `$OPENCODE_SESSION_ID` (the session-tracker plugin's `shell.env` hook must - * inject it); state directory not located (no `.opencode/` ancestor of cwd, so - * the plugin has never recorded state in this project); located but no entry - * for this session (started before the plugin was installed). + * This is the *only* hard prerequisite for an OpenCode capture. `opencode + * export ` returns the transcript whether or not the session-tracker plugin + * ever recorded the session, so a session started before the plugin was + * installed can still be saved (#402) — the tracker state is consulted only as + * tie-break evidence when both host ids are set (isOpenCodeSessionTracked). */ -export function findOpenCodeSessionID( - cwd: string, +export function openCodeSessionIDFromEnv( lookupEnv: LookupEnv = processLookupEnv, ): string { const [sessionIDRaw, ok] = lookupEnv("OPENCODE_SESSION_ID"); @@ -543,42 +539,34 @@ export function findOpenCodeSessionID( "installed and loaded in this project?)", ); } + return sessionID; +} + +/** + * Whether the session-tracker plugin recorded this OpenCode session in this + * project — the tie-break evidence when both host session-id variables are set. + * + * Never throws: false when `$OPENCODE_SESSION_ID` is unset, no `.opencode/` + * state directory exists, or no matching `.json` entry was written. It is a + * signal about *which host is innermost*, not a capture prerequisite, so a + * false result no longer blocks a save — captureOpenCodeSession exports + * regardless. + */ +export function isOpenCodeSessionTracked( + cwd: string, + lookupEnv: LookupEnv = processLookupEnv, +): boolean { + const [sessionIDRaw, ok] = lookupEnv("OPENCODE_SESSION_ID"); + const sessionID = sessionIDRaw ?? ""; + if (!ok || sessionID === "") return false; const stateDir = openCodeSessionsDir(cwd); - let stat: fs.Stats; try { - stat = fs.statSync(stateDir); + if (!fs.statSync(stateDir).isDirectory()) return false; } catch { - throw openCodeStateNotLocated(cwd, stateDir); - } - if (!stat.isDirectory()) { - throw openCodeStateNotLocated(cwd, stateDir); - } - - if (!openCodeSessionIsTracked(sessionID, stateDir)) { - throw new CaptureError( - "No state recorded for session " + - sessionID + - " under " + - stateDir + - ", per the session-tracker plugin. (If this session was " + - "started before the plugin was installed, it was never recorded; " + - "start a new session and try again.)", - ); + return false; } - - return sessionID; -} - -function openCodeStateNotLocated(cwd: string, stateDir: string): CaptureError { - return new CaptureError( - "Could not locate OpenCode session-tracker state. Searched " + - cwd + - " and its ancestors for a '.opencode/' directory, and found no " + - stateDir + - ". (Has the session-tracker plugin ever run in this " + - "project? Start a new session in the project root and try again.)", - ); + return openCodeSessionIsTracked(sessionID, stateDir); } /** Fetches one OpenCode session's export document. Injectable so the pipeline @@ -765,9 +753,12 @@ export function normalizeExport(exportDoc: Uint8Array): Turn[] { * Resolves the current OpenCode session, exports and normalizes its * transcript, and writes the capture; returns its vault-relative path. * - * The whole pipeline (findOpenCodeSessionID -> export -> normalizeExport -> - * transcriptToPage -> writeCapture) in one call. exportSeam is the injectable - * fetch seam; undefined runs the real `opencode export`. + * The whole pipeline (openCodeSessionIDFromEnv -> export -> normalizeExport -> + * transcriptToPage -> writeCapture) in one call. The session id comes from + * `$OPENCODE_SESSION_ID` alone — no tracker state is required, so a session + * that predates the plugin still captures via `opencode export` (#402). + * exportSeam is the injectable fetch seam; undefined runs the real + * `opencode export`. */ export async function captureOpenCodeSession( wikiRoot: string, @@ -777,7 +768,10 @@ export async function captureOpenCodeSession( now: Date, exportSeam?: Exporter, ): Promise { - const sessionID = findOpenCodeSessionID(cwd, lookupEnv); + // cwd is retained for capture-function signature parity with the Claude Code + // path; the OpenCode capture needs no vault-root walk, only the env var. + void cwd; + const sessionID = openCodeSessionIDFromEnv(lookupEnv); const timestamp = now.getTime() === 0 ? new Date() : now; const fetch = exportSeam ?? ((id: string) => exportTranscript(id, "opencode"));