Skip to content

Commit 32edb49

Browse files
committed
feat(providers): add codex notify argv support
1 parent 29ee011 commit 32edb49

4 files changed

Lines changed: 271 additions & 5 deletions

File tree

packages/providers/src/codex/definition.test.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,42 @@ describe("Codex Provider Definition", () => {
113113
expect(result.argv.some((a: string) => a.startsWith("notify="))).toBe(false);
114114
});
115115

116+
it("injects notify bridge arguments when bridgeScriptPath is provided", () => {
117+
const config: ProviderConfig = { additionalArgs: ["--flag"], envVars: {} };
118+
const ctx = {
119+
sessionId: "session-123",
120+
workspacePath: "/workspace",
121+
bridgeScriptPath: "/home/test/.coder-studio/hooks/codex-bridge.js",
122+
};
123+
124+
const result = codexDefinition.buildCommand(config, ctx);
125+
126+
expect(result.argv).toEqual([
127+
"codex",
128+
"-c",
129+
'notify=["node","/home/test/.coder-studio/hooks/codex-bridge.js"]',
130+
"--flag",
131+
]);
132+
});
133+
134+
it("keeps notify injection ahead of additional args with paths that contain spaces", () => {
135+
const config: ProviderConfig = { additionalArgs: ["--flag"], envVars: {} };
136+
const ctx = {
137+
sessionId: "session-123",
138+
workspacePath: "/workspace",
139+
bridgeScriptPath: "/home/with space/codex bridge.js",
140+
};
141+
142+
const result = codexDefinition.buildCommand(config, ctx);
143+
144+
expect(result.argv).toEqual([
145+
"codex",
146+
"-c",
147+
'notify=["node","/home/with space/codex bridge.js"]',
148+
"--flag",
149+
]);
150+
});
151+
116152
it("should include additional arguments and env vars", () => {
117153
const config: ProviderConfig = {
118154
additionalArgs: ["--flag"],
@@ -132,6 +168,40 @@ describe("Codex Provider Definition", () => {
132168
});
133169
});
134170

171+
describe("buildResumeCommand", () => {
172+
it("builds a resume command and retains notify bridge injection", () => {
173+
const config: ProviderConfig = { additionalArgs: ["--flag"], envVars: { TOKEN: "abc" } };
174+
const ctx = {
175+
sessionId: "session-123",
176+
workspacePath: "/workspace",
177+
bridgeScriptPath: "/bridge.js",
178+
};
179+
180+
const result = codexDefinition.buildResumeCommand?.(config, ctx, "thread-uuid-1");
181+
182+
expect(result).toEqual({
183+
argv: ["codex", "resume", "thread-uuid-1", "-c", 'notify=["node","/bridge.js"]', "--flag"],
184+
env: {
185+
TOKEN: "abc",
186+
CODER_STUDIO_SESSION_ID: "session-123",
187+
},
188+
cwd: "/workspace",
189+
});
190+
});
191+
192+
it("omits notify injection on resume when bridgeScriptPath is missing", () => {
193+
const config: ProviderConfig = { additionalArgs: ["--flag"], envVars: {} };
194+
const ctx = {
195+
sessionId: "session-123",
196+
workspacePath: "/workspace",
197+
};
198+
199+
const result = codexDefinition.buildResumeCommand?.(config, ctx, "thread-uuid-1");
200+
201+
expect(result?.argv).toEqual(["codex", "resume", "thread-uuid-1", "--flag"]);
202+
});
203+
});
204+
135205
describe("buildSupervisorEvalCommand", () => {
136206
it("builds a supervisor eval command with codex exec --json", () => {
137207
const result = codexDefinition.buildSupervisorEvalCommand?.(
@@ -197,11 +267,17 @@ describe("Codex Provider Definition", () => {
197267
expect(codexDefinition.idleHeuristics?.idleDebounceMs).toBe(3000);
198268
});
199269

200-
it("does not expose legacy hooks or transcript helpers", () => {
270+
it("keeps legacy hook fields absent while exposing resume and transcript resolution", () => {
201271
expect("hooks" in codexDefinition).toBe(false);
202-
expect("buildResumeCommand" in codexDefinition).toBe(false);
203-
expect("resolveTranscriptPath" in codexDefinition).toBe(false);
272+
expect(codexDefinition.buildResumeCommand).toBeTypeOf("function");
273+
expect(codexDefinition.resolveTranscriptPath).toBeTypeOf("function");
204274
expect("readTranscriptExcerpt" in codexDefinition).toBe(false);
205275
});
206276
});
277+
278+
describe("resolveTranscriptPath", () => {
279+
it("returns undefined when providerSessionId is absent", () => {
280+
expect(codexDefinition.resolveTranscriptPath?.({ id: "sess-1" })).toBeUndefined();
281+
});
282+
});
207283
});

packages/providers/src/codex/definition.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import type { ProviderConfig, ProviderDefinition } from "@coder-studio/core";
22

33
import { type CodexConfig, codexConfigSchema } from "./config-schema.js";
4+
import { resolveCodexTranscriptPath } from "./resolve-transcript.js";
45
import { idleDebounceMs, idlePromptPatterns, sessionIdPatterns } from "./stdout-heuristics.js";
56
import { buildCodexSupervisorEvalCommand } from "./supervisor-eval.js";
67

8+
function notifyArgv(bridgeScriptPath?: string): string[] {
9+
if (!bridgeScriptPath) {
10+
return [];
11+
}
12+
13+
return ["-c", `notify=${JSON.stringify(["node", bridgeScriptPath])}`];
14+
}
15+
716
export const codexInstallMetadata = {
817
prerequisites: ["npm"],
918
manualGuideKeys: ["provider.install.nodejs.manual", "provider.install.codex.manual"],
@@ -79,7 +88,26 @@ export const codexDefinition: ProviderDefinition = {
7988
const cfg = codexConfigSchema.parse(config);
8089

8190
return {
82-
argv: ["codex", ...cfg.additionalArgs],
91+
argv: ["codex", ...notifyArgv(ctx.bridgeScriptPath), ...cfg.additionalArgs],
92+
env: {
93+
...cfg.envVars,
94+
CODER_STUDIO_SESSION_ID: ctx.sessionId,
95+
},
96+
cwd: ctx.workspacePath,
97+
};
98+
},
99+
100+
buildResumeCommand(config: ProviderConfig, ctx, resumeId) {
101+
const cfg = codexConfigSchema.parse(config);
102+
103+
return {
104+
argv: [
105+
"codex",
106+
"resume",
107+
resumeId,
108+
...notifyArgv(ctx.bridgeScriptPath),
109+
...cfg.additionalArgs,
110+
],
83111
env: {
84112
...cfg.envVars,
85113
CODER_STUDIO_SESSION_ID: ctx.sessionId,
@@ -88,8 +116,8 @@ export const codexDefinition: ProviderDefinition = {
88116
};
89117
},
90118

91-
// Full mode: no resume support yet (Codex CLI may support it later)
92119
buildSupervisorEvalCommand: buildCodexSupervisorEvalCommand,
120+
resolveTranscriptPath: resolveCodexTranscriptPath,
93121

94122
// ===== Configuration =====
95123
configSchema: codexConfigSchema,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { mkdirSync, rmSync, utimesSync, writeFileSync } from "fs";
2+
import { tmpdir } from "os";
3+
import { join } from "path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { resolveCodexTranscriptPath } from "./resolve-transcript.js";
6+
7+
describe("resolveCodexTranscriptPath", () => {
8+
const tempDirs: string[] = [];
9+
10+
afterEach(() => {
11+
for (const dir of tempDirs) {
12+
rmSync(dir, { recursive: true, force: true });
13+
}
14+
tempDirs.length = 0;
15+
});
16+
17+
function createSessionsRoot(): string {
18+
const root = join(
19+
tmpdir(),
20+
`coder-studio-codex-sessions-${Date.now()}-${Math.random().toString(36).slice(2)}`
21+
);
22+
mkdirSync(root, { recursive: true });
23+
tempDirs.push(root);
24+
return root;
25+
}
26+
27+
it("returns the persisted transcript path when one is already present", () => {
28+
expect(
29+
resolveCodexTranscriptPath({
30+
transcriptPath: "/tmp/existing.jsonl",
31+
providerSessionId: "thread-1",
32+
})
33+
).toBe("/tmp/existing.jsonl");
34+
});
35+
36+
it("returns undefined when providerSessionId is absent", () => {
37+
const sessionsRoot = createSessionsRoot();
38+
39+
expect(resolveCodexTranscriptPath({}, { sessionsRoot })).toBeUndefined();
40+
});
41+
42+
it("returns undefined when the Codex sessions directory does not exist", () => {
43+
expect(
44+
resolveCodexTranscriptPath(
45+
{ providerSessionId: "thread-1" },
46+
{ sessionsRoot: join(tmpdir(), "missing-codex-sessions-root") }
47+
)
48+
).toBeUndefined();
49+
});
50+
51+
it("finds the newest rollout transcript matching the provider session id", () => {
52+
const sessionsRoot = createSessionsRoot();
53+
const olderDir = join(sessionsRoot, "2026", "05", "09");
54+
const newerDir = join(sessionsRoot, "2026", "05", "10");
55+
mkdirSync(olderDir, { recursive: true });
56+
mkdirSync(newerDir, { recursive: true });
57+
58+
const olderPath = join(olderDir, "rollout-older-thread-1.jsonl");
59+
const newerPath = join(newerDir, "rollout-newer-thread-1.jsonl");
60+
const otherPath = join(newerDir, "rollout-other-thread-2.jsonl");
61+
writeFileSync(olderPath, "{}\n");
62+
writeFileSync(newerPath, "{}\n");
63+
writeFileSync(otherPath, "{}\n");
64+
utimesSync(olderPath, new Date("2026-05-09T00:00:00Z"), new Date("2026-05-09T00:00:00Z"));
65+
utimesSync(newerPath, new Date("2026-05-10T00:00:00Z"), new Date("2026-05-10T00:00:00Z"));
66+
67+
expect(resolveCodexTranscriptPath({ providerSessionId: "thread-1" }, { sessionsRoot })).toBe(
68+
newerPath
69+
);
70+
});
71+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { existsSync, readdirSync, statSync } from "fs";
2+
import { homedir } from "os";
3+
import { basename, join } from "path";
4+
5+
interface ResolveCodexTranscriptSession {
6+
transcriptPath?: string;
7+
providerSessionId?: string;
8+
}
9+
10+
interface ResolveCodexTranscriptOptions {
11+
sessionsRoot?: string;
12+
}
13+
14+
interface TranscriptCandidate {
15+
path: string;
16+
mtimeMs: number;
17+
}
18+
19+
function findTranscriptCandidates(root: string, suffix: string): TranscriptCandidate[] {
20+
const candidates: TranscriptCandidate[] = [];
21+
const pending = [root];
22+
23+
while (pending.length > 0) {
24+
const current = pending.pop();
25+
if (!current) {
26+
continue;
27+
}
28+
29+
let entries;
30+
try {
31+
entries = readdirSync(current, { withFileTypes: true });
32+
} catch {
33+
continue;
34+
}
35+
36+
for (const entry of entries) {
37+
const entryPath = join(current, entry.name);
38+
if (entry.isDirectory()) {
39+
pending.push(entryPath);
40+
continue;
41+
}
42+
43+
if (!entry.isFile()) {
44+
continue;
45+
}
46+
47+
if (
48+
entry.name.startsWith("rollout-") &&
49+
entry.name.endsWith(suffix) &&
50+
entry.name.endsWith(".jsonl")
51+
) {
52+
try {
53+
candidates.push({
54+
path: entryPath,
55+
mtimeMs: statSync(entryPath).mtimeMs,
56+
});
57+
} catch {
58+
continue;
59+
}
60+
}
61+
}
62+
}
63+
64+
return candidates;
65+
}
66+
67+
export function resolveCodexTranscriptPath(
68+
session: ResolveCodexTranscriptSession,
69+
options: ResolveCodexTranscriptOptions = {}
70+
): string | undefined {
71+
if (session.transcriptPath) {
72+
return session.transcriptPath;
73+
}
74+
75+
if (!session.providerSessionId) {
76+
return undefined;
77+
}
78+
79+
const sessionsRoot = options.sessionsRoot ?? join(homedir(), ".codex", "sessions");
80+
if (!existsSync(sessionsRoot)) {
81+
return undefined;
82+
}
83+
84+
const suffix = `-${session.providerSessionId}.jsonl`;
85+
const candidates = findTranscriptCandidates(sessionsRoot, suffix).sort(
86+
(left, right) =>
87+
right.mtimeMs - left.mtimeMs || basename(right.path).localeCompare(basename(left.path))
88+
);
89+
90+
return candidates[0]?.path;
91+
}

0 commit comments

Comments
 (0)