diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index 5806950..3ef4413 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { copyFile, readFile } from "node:fs/promises"; +import { copyFile, readdir, readFile } from "node:fs/promises"; import type { ExplainInput, ExplainResult, @@ -160,7 +160,10 @@ export class OpenCodeAdapter extends BaseAdapter implements RunnerAdapter { throw new Error(`OpenCode run failed: ${runError}`); } - const sessionId = this.extractSessionId(stdout) ?? this.extractSessionId(stderr); + const sessionId = + this.extractSessionId(stdout) ?? + this.extractSessionId(stderr) ?? + (await this.extractSessionIdFromRuntimeLog(input)); if (sessionId === undefined) { throw new Error( @@ -393,6 +396,33 @@ export class OpenCodeAdapter extends BaseAdapter implements RunnerAdapter { const match = text.match(/\b(ses_[A-Za-z0-9]+)\b/); return match?.[1]; } + + private async extractSessionIdFromRuntimeLog(input: RunInput): Promise { + const logDir = path.join(getOpenCodePaths(input).dataHome, "opencode", "log"); + let entries: string[]; + + try { + entries = await readdir(logDir); + } catch (error) { + if (isMissingFileError(error)) { + return undefined; + } + throw error; + } + + for (const entry of entries.toSorted().toReversed()) { + if (!entry.endsWith(".log")) { + continue; + } + + const sessionId = this.extractSessionId(await readFile(path.join(logDir, entry), "utf8")); + if (sessionId !== undefined) { + return sessionId; + } + } + + return undefined; + } } function getOpenCodeRuntimeRoot(input: RunInput): string { @@ -533,8 +563,12 @@ function parseOpenCodeExport(value: string): OpenCodeExport & { messages: OpenCo try { parsed = JSON.parse(value); } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error(`OpenCode export returned invalid JSON: ${reason}`); + try { + parsed = JSON.parse(extractJsonObject(value)); + } catch { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`OpenCode export returned invalid JSON: ${reason}`); + } } if (!isOpenCodeExport(parsed)) { @@ -544,6 +578,17 @@ function parseOpenCodeExport(value: string): OpenCodeExport & { messages: OpenCo return parsed; } +function extractJsonObject(value: string): string { + const start = value.indexOf("{"); + const end = value.lastIndexOf("}"); + + if (start === -1 || end === -1 || end < start) { + throw new Error("OpenCode export output did not contain a JSON object."); + } + + return value.slice(start, end + 1); +} + function isOpenCodeExport( value: unknown, ): value is OpenCodeExport & { messages: OpenCodeMessage[] } { diff --git a/test/adapters/opencode.test.ts b/test/adapters/opencode.test.ts index fdc8a90..41a6a20 100644 --- a/test/adapters/opencode.test.ts +++ b/test/adapters/opencode.test.ts @@ -16,7 +16,7 @@ afterEach(async () => { test("OpenCodeAdapter collect parses export payload wrapped in extra text", async () => { const tempDir = await createTempDir(); const adapter = new ExportingOpenCodeAdapter({ - exportStdout: JSON.stringify({ + exportStdout: `Exporting session: ses_123\n${JSON.stringify({ info: { id: "ses_123" }, messages: [ { @@ -36,7 +36,7 @@ test("OpenCodeAdapter collect parses export payload wrapped in extra text", asyn ], }, ], - }), + })}`, }); const input = { @@ -88,6 +88,46 @@ test("OpenCodeAdapter collect parses export payload wrapped in extra text", asyn }); }); +test("OpenCodeAdapter collect falls back to runtime log session id", async () => { + const tempDir = await createTempDir(); + const adapter = new ExportingOpenCodeAdapter({ + exportStdout: JSON.stringify({ + info: { id: "ses_fromlog" }, + messages: [], + }), + expectedSessionId: "ses_fromlog", + }); + + const input = { + ...createRunInput(), + cwd: tempDir, + artifactsDir: path.join(tempDir, "artifacts"), + } satisfies RunInput; + const logDir = path.join(input.artifactsDir, "opencode-xdg", "data", "opencode", "log"); + await mkdir(logDir, { recursive: true }); + await writeFile( + path.join(logDir, "2026-05-15T115637.log"), + "INFO service=session id=ses_fromlog created\n", + "utf8", + ); + + const stdoutPath = path.join(tempDir, "stdout.log"); + const stderrPath = path.join(tempDir, "stderr.log"); + await writeFile(stdoutPath, "", "utf8"); + await writeFile(stderrPath, "Database migration complete.\n", "utf8"); + + const artifacts = await adapter.collect( + { + ...createArtifacts(), + stdoutPath, + stderrPath, + }, + input, + ); + + expect(artifacts.sessionId).toBe("ses_fromlog"); +}); + test("OpenCodeAdapter collect fails when export output is invalid JSON", async () => { const tempDir = await createTempDir(); const adapter = new ExportingOpenCodeAdapter({ @@ -335,7 +375,7 @@ async function createTempDir(): Promise { class ExportingOpenCodeAdapter extends OpenCodeAdapter { exportEnv?: Record; - constructor(private readonly testOptions: { exportStdout: string }) { + constructor(private readonly testOptions: { exportStdout: string; expectedSessionId?: string }) { super(); } @@ -346,7 +386,7 @@ class ExportingOpenCodeAdapter extends OpenCodeAdapter { options?: { env?: Record }, ) { expect(command).toBe("opencode"); - expect(args).toEqual(["export", "ses_123"]); + expect(args).toEqual(["export", this.testOptions.expectedSessionId ?? "ses_123"]); this.exportEnv = options?.env; const stdoutPath = path.join(input.artifactsDir, "stdout.log");