Skip to content

Commit ac9dda4

Browse files
authored
Fix OpenCode artifact collection (#42)
1 parent 2693d64 commit ac9dda4

2 files changed

Lines changed: 93 additions & 8 deletions

File tree

src/adapters/opencode.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from "node:path";
2-
import { copyFile, readFile } from "node:fs/promises";
2+
import { copyFile, readdir, readFile } from "node:fs/promises";
33
import type {
44
ExplainInput,
55
ExplainResult,
@@ -160,7 +160,10 @@ export class OpenCodeAdapter extends BaseAdapter implements RunnerAdapter {
160160
throw new Error(`OpenCode run failed: ${runError}`);
161161
}
162162

163-
const sessionId = this.extractSessionId(stdout) ?? this.extractSessionId(stderr);
163+
const sessionId =
164+
this.extractSessionId(stdout) ??
165+
this.extractSessionId(stderr) ??
166+
(await this.extractSessionIdFromRuntimeLog(input));
164167

165168
if (sessionId === undefined) {
166169
throw new Error(
@@ -393,6 +396,33 @@ export class OpenCodeAdapter extends BaseAdapter implements RunnerAdapter {
393396
const match = text.match(/\b(ses_[A-Za-z0-9]+)\b/);
394397
return match?.[1];
395398
}
399+
400+
private async extractSessionIdFromRuntimeLog(input: RunInput): Promise<string | undefined> {
401+
const logDir = path.join(getOpenCodePaths(input).dataHome, "opencode", "log");
402+
let entries: string[];
403+
404+
try {
405+
entries = await readdir(logDir);
406+
} catch (error) {
407+
if (isMissingFileError(error)) {
408+
return undefined;
409+
}
410+
throw error;
411+
}
412+
413+
for (const entry of entries.toSorted().toReversed()) {
414+
if (!entry.endsWith(".log")) {
415+
continue;
416+
}
417+
418+
const sessionId = this.extractSessionId(await readFile(path.join(logDir, entry), "utf8"));
419+
if (sessionId !== undefined) {
420+
return sessionId;
421+
}
422+
}
423+
424+
return undefined;
425+
}
396426
}
397427

398428
function getOpenCodeRuntimeRoot(input: RunInput): string {
@@ -533,8 +563,12 @@ function parseOpenCodeExport(value: string): OpenCodeExport & { messages: OpenCo
533563
try {
534564
parsed = JSON.parse(value);
535565
} catch (error) {
536-
const reason = error instanceof Error ? error.message : String(error);
537-
throw new Error(`OpenCode export returned invalid JSON: ${reason}`);
566+
try {
567+
parsed = JSON.parse(extractJsonObject(value));
568+
} catch {
569+
const reason = error instanceof Error ? error.message : String(error);
570+
throw new Error(`OpenCode export returned invalid JSON: ${reason}`);
571+
}
538572
}
539573

540574
if (!isOpenCodeExport(parsed)) {
@@ -544,6 +578,17 @@ function parseOpenCodeExport(value: string): OpenCodeExport & { messages: OpenCo
544578
return parsed;
545579
}
546580

581+
function extractJsonObject(value: string): string {
582+
const start = value.indexOf("{");
583+
const end = value.lastIndexOf("}");
584+
585+
if (start === -1 || end === -1 || end < start) {
586+
throw new Error("OpenCode export output did not contain a JSON object.");
587+
}
588+
589+
return value.slice(start, end + 1);
590+
}
591+
547592
function isOpenCodeExport(
548593
value: unknown,
549594
): value is OpenCodeExport & { messages: OpenCodeMessage[] } {

test/adapters/opencode.test.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ afterEach(async () => {
1616
test("OpenCodeAdapter collect parses export payload wrapped in extra text", async () => {
1717
const tempDir = await createTempDir();
1818
const adapter = new ExportingOpenCodeAdapter({
19-
exportStdout: JSON.stringify({
19+
exportStdout: `Exporting session: ses_123\n${JSON.stringify({
2020
info: { id: "ses_123" },
2121
messages: [
2222
{
@@ -36,7 +36,7 @@ test("OpenCodeAdapter collect parses export payload wrapped in extra text", asyn
3636
],
3737
},
3838
],
39-
}),
39+
})}`,
4040
});
4141

4242
const input = {
@@ -88,6 +88,46 @@ test("OpenCodeAdapter collect parses export payload wrapped in extra text", asyn
8888
});
8989
});
9090

91+
test("OpenCodeAdapter collect falls back to runtime log session id", async () => {
92+
const tempDir = await createTempDir();
93+
const adapter = new ExportingOpenCodeAdapter({
94+
exportStdout: JSON.stringify({
95+
info: { id: "ses_fromlog" },
96+
messages: [],
97+
}),
98+
expectedSessionId: "ses_fromlog",
99+
});
100+
101+
const input = {
102+
...createRunInput(),
103+
cwd: tempDir,
104+
artifactsDir: path.join(tempDir, "artifacts"),
105+
} satisfies RunInput;
106+
const logDir = path.join(input.artifactsDir, "opencode-xdg", "data", "opencode", "log");
107+
await mkdir(logDir, { recursive: true });
108+
await writeFile(
109+
path.join(logDir, "2026-05-15T115637.log"),
110+
"INFO service=session id=ses_fromlog created\n",
111+
"utf8",
112+
);
113+
114+
const stdoutPath = path.join(tempDir, "stdout.log");
115+
const stderrPath = path.join(tempDir, "stderr.log");
116+
await writeFile(stdoutPath, "", "utf8");
117+
await writeFile(stderrPath, "Database migration complete.\n", "utf8");
118+
119+
const artifacts = await adapter.collect(
120+
{
121+
...createArtifacts(),
122+
stdoutPath,
123+
stderrPath,
124+
},
125+
input,
126+
);
127+
128+
expect(artifacts.sessionId).toBe("ses_fromlog");
129+
});
130+
91131
test("OpenCodeAdapter collect fails when export output is invalid JSON", async () => {
92132
const tempDir = await createTempDir();
93133
const adapter = new ExportingOpenCodeAdapter({
@@ -335,7 +375,7 @@ async function createTempDir(): Promise<string> {
335375
class ExportingOpenCodeAdapter extends OpenCodeAdapter {
336376
exportEnv?: Record<string, string>;
337377

338-
constructor(private readonly testOptions: { exportStdout: string }) {
378+
constructor(private readonly testOptions: { exportStdout: string; expectedSessionId?: string }) {
339379
super();
340380
}
341381

@@ -346,7 +386,7 @@ class ExportingOpenCodeAdapter extends OpenCodeAdapter {
346386
options?: { env?: Record<string, string> },
347387
) {
348388
expect(command).toBe("opencode");
349-
expect(args).toEqual(["export", "ses_123"]);
389+
expect(args).toEqual(["export", this.testOptions.expectedSessionId ?? "ses_123"]);
350390
this.exportEnv = options?.env;
351391

352392
const stdoutPath = path.join(input.artifactsDir, "stdout.log");

0 commit comments

Comments
 (0)