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
53 changes: 49 additions & 4 deletions src/adapters/opencode.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<string | undefined> {
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 {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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[] } {
Expand Down
48 changes: 44 additions & 4 deletions test/adapters/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -36,7 +36,7 @@ test("OpenCodeAdapter collect parses export payload wrapped in extra text", asyn
],
},
],
}),
})}`,
});

const input = {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -335,7 +375,7 @@ async function createTempDir(): Promise<string> {
class ExportingOpenCodeAdapter extends OpenCodeAdapter {
exportEnv?: Record<string, string>;

constructor(private readonly testOptions: { exportStdout: string }) {
constructor(private readonly testOptions: { exportStdout: string; expectedSessionId?: string }) {
super();
}

Expand All @@ -346,7 +386,7 @@ class ExportingOpenCodeAdapter extends OpenCodeAdapter {
options?: { env?: Record<string, string> },
) {
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");
Expand Down