Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions enchiridion-ts/src/cli.run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
},
);
102 changes: 80 additions & 22 deletions enchiridion-ts/src/transcriptcapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
normalizeExport,
captureOpenCodeSession,
captureSession,
findOpenCodeSessionID,
openCodeSessionIDFromEnv,
isOpenCodeSessionTracked,
ErrTooFewTurns,
CaptureError,
} from "./transcriptcapture.js";
Expand Down Expand Up @@ -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<Uint8Array> =>
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;
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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/);
});
88 changes: 41 additions & 47 deletions enchiridion-ts/src/transcriptcapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,7 @@ export async function captureSession(
);
}
if (openCodeID) {
try {
findOpenCodeSessionID(cwd, lookupEnv);
if (isOpenCodeSessionTracked(cwd, lookupEnv)) {
return captureOpenCodeSession(
wikiRoot,
slug,
Expand All @@ -395,8 +394,6 @@ export async function captureSession(
now,
exportSeam,
);
} catch {
// fall through to Claude Code
}
return captureClaudeCodeSession(wikiRoot, slug, cwd, lookupEnv, now);
}
Expand Down Expand Up @@ -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 <id>` 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");
Expand All @@ -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 `<id>.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
Expand Down Expand Up @@ -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,
Expand All @@ -777,7 +768,10 @@ export async function captureOpenCodeSession(
now: Date,
exportSeam?: Exporter,
): Promise<string> {
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"));
Expand Down
Loading