authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 21:29:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 21:29:20-07:00
log6391be2729566a1fe1045ff8617f003580b37bfd
treead41c538db15f48d02303b4d9d682915c269df74
parentab70c8943f48c08e67e3597f62941c169bc58c16
signature Signed by PGP key B5690EEEBB952194

fix(server): newer Codex models get T3 Code's instructions again (#13547)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

9 files changed, 322 insertions(+), 149 deletions(-)

apps/server/src/provider/CodexDeveloperInstructions.ts+43-33
......@@ -1,23 +1,18 @@
11import type { ProviderInteractionMode } from "@t3tools/contracts";
2import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema";
23import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
34
4const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `
5
6## T3 Code collaborative browser
5const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `## T3 Code collaborative browser
76
87You are running inside T3 Code. The \`t3-code\` MCP server is the product-native collaborative browser shared with the user. When it exposes \`preview_*\` tools, prefer those tools for browser navigation, inspection, interaction, screenshots, and recordings.
98
109For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates.
1110
12Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.
13`;
14
15const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `
11Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.`;
1612
17## T3 Code devices
13const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices
1814
19The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.
20`;
15The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.`;
2116
2217export interface T3CodeToolAvailability {
2318 readonly browser: boolean;
......@@ -36,16 +31,17 @@ const normalizeAvailability = (
3631 * from Playwright, agent-browser, and raw simctl/adb, so leaving them in would
3732 * talk it out of the only automation it still has.
3833 */
39const browserToolInstructions = (availability: boolean | T3CodeToolAvailability): string => {
34const toolInstructions = (availability: boolean | T3CodeToolAvailability): string => {
4035 const tools = normalizeAvailability(availability);
41 return `${tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""}${
42 tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : ""
43 }`;
36 return [
37 tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : "",
38 tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "",
39 ]
40 .filter(Boolean)
41 .join("\n\n");
4442};
4543
46const codexPlanModeDeveloperInstructions = (
47 browserToolsAvailable: boolean | T3CodeToolAvailability,
48): string => `<collaboration_mode># Plan Mode (Conversational)
44const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
4945
5046You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.
5147
......@@ -173,12 +169,9 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o
173169Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.
174170
175171If the user stays in Plan mode and asks for revisions after a prior \`<proposed_plan>\`, any new \`<proposed_plan>\` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a \`<proposed_plan>\` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior \`<proposed_plan>\` unchanged.
176${browserToolInstructions(browserToolsAvailable)}
177172</collaboration_mode>`;
178173
179const codexDefaultModeDeveloperInstructions = (
180 browserToolsAvailable: boolean | T3CodeToolAvailability,
181): string => `<collaboration_mode># Collaboration Mode: Default
174const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Collaboration Mode: Default
182175
183176You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
184177
......@@ -189,29 +182,46 @@ Your active mode changes only when new developer instructions with a different \
189182Use the \`request_user_input\` tool only when it is listed in the available tools for this turn.
190183
191184In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
192${browserToolInstructions(browserToolsAvailable)}
193185</collaboration_mode>`;
194186
195187export interface CodexRuntimeInfo {
196188 readonly model: string;
189 readonly modelName?: string | undefined;
197190 readonly reasoningEffort: string;
198191}
199192
200export function buildCodexDeveloperInstructions(
201 interactionMode: ProviderInteractionMode,
193/** Mode prompt for `turn/start.collaborationMode.settings.developer_instructions`. */
194export function buildCodexDeveloperInstructions(interactionMode: ProviderInteractionMode): string {
195 return interactionMode === "plan"
196 ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
197 : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS;
198}
199
200/**
201 * T3 Code context for `turn/start.additionalContext`. Codex renders each entry
202 * as a `<key>value</key>` developer message and resends it only when the value
203 * changes.
204 *
205 * This must stay out of the collaboration mode: when the model catalog ships
206 * its own text for a mode, as newer models do, Codex uses that text and drops
207 * the client's `developer_instructions` entirely.
208 */
209export function buildCodexAdditionalContext(
202210 runtime: CodexRuntimeInfo,
203211 /**
204212 * Whether the `t3-code` MCP server is attached to this turn. Callers derive
205213 * it from the session's actual MCP configuration rather than re-reading the
206214 * setting, so the prompt cannot claim tools the turn doesn't have.
207215 */
208 browserToolsAvailable: boolean | T3CodeToolAvailability = true,
209): string {
210 const base =
211 interactionMode === "plan"
212 ? codexPlanModeDeveloperInstructions(browserToolsAvailable)
213 : codexDefaultModeDeveloperInstructions(browserToolsAvailable);
214 return `${base}
215
216${buildRuntimeInstructions({ harness: "Codex", ...runtime })}`;
216 toolsAvailable: boolean | T3CodeToolAvailability = true,
217): Record<string, V2TurnStartParams__AdditionalContextEntry> {
218 const tools = toolInstructions(toolsAvailable);
219 // Separate keys keep each value under Codex's per-entry token cap.
220 return {
221 t3_code_runtime: {
222 kind: "application",
223 value: buildRuntimeInstructions({ harness: "Codex", ...runtime }),
224 },
225 ...(tools ? { t3_code_tools: { kind: "application", value: tools } } : {}),
226 };
217227}
apps/server/src/provider/Drivers/CodexDriver.ts+14-17
......@@ -172,18 +172,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
172172 ),
173173 );
174174
175 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error
176 // channels at construction time — their failure modes are all on the
177 // per-operation closures they return. No `mapError` wrapper is needed
178 // here; the registry only has to worry about snapshot-build and
179 // spawner-availability failures surfaced from `checkCodexProviderStatus`
180 // below.
181 const adapter = yield* makeCodexAdapter(effectiveConfig, {
182 instanceId,
183 environment: processEnv,
184 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
185 });
186
187175 // Build a managed snapshot whose settings never change — mutations come
188176 // in as instance rebuilds from the registry rather than in-place
189177 // updates. Pre-provide `ChildProcessSpawner` so the check fits
......@@ -238,11 +226,20 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
238226 }),
239227 ),
240228 );
241 const textGeneration = yield* makeCodexTextGeneration(
242 effectiveConfig,
243 processEnv,
244 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
245 );
229 const models = snapshot.getSnapshot.pipe(Effect.map((value) => value.models));
230 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error
231 // channels at construction time — their failure modes are all on the
232 // per-operation closures they return. No `mapError` wrapper is needed
233 // here; the registry only has to worry about snapshot-build and
234 // spawner-availability failures surfaced from `checkCodexProviderStatus`
235 // above.
236 const adapter = yield* makeCodexAdapter(effectiveConfig, {
237 instanceId,
238 environment: processEnv,
239 models,
240 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
241 });
242 const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv, models);
246243 const snapshotForCwd = (cwd: string) =>
247244 !effectiveConfig.enabled
248245 ? snapshot.getSnapshot
apps/server/src/provider/Layers/CodexAdapter.ts+4
......@@ -22,6 +22,7 @@ import {
2222 type ToolActivityNativeAppReference,
2323 type ToolActivitySource,
2424 type ProviderUserInputAnswers,
25 type ServerProviderModel,
2526 RuntimeItemId,
2627 RuntimeRequestId,
2728 RuntimeTaskId,
......@@ -89,6 +90,8 @@ const PROVIDER = ProviderDriverKind.make("codex");
8990export interface CodexAdapterLiveOptions {
9091 readonly instanceId?: ProviderInstanceId;
9192 readonly environment?: NodeJS.ProcessEnv;
93 /** The provider's model list; supplies model display names for runtime info. */
94 readonly models?: Effect.Effect<ReadonlyArray<ServerProviderModel>>;
9295 readonly makeRuntime?: (
9396 options: CodexSessionRuntimeOptions,
9497 ) => Effect.Effect<
......@@ -2277,6 +2280,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
22772280 providerInstanceId: boundInstanceId,
22782281 cwd: input.cwd ?? process.cwd(),
22792282 binaryPath: codexConfig.binaryPath,
2283 ...(options?.models ? { models: options.models } : {}),
22802284 launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment),
22812285 ...(options?.environment ? { environment: options.environment } : {}),
22822286 ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+73
......@@ -868,3 +868,76 @@ describe("CodexSessionRuntime collab integration", () => {
868868 );
869869 }
870870});
871
872describe("CodexSessionRuntime compaction", () => {
873 it.effect("restores T3 context after the root thread compacts", () =>
874 Effect.gen(function* () {
875 const compacted = (threadId: string) => ({
876 method: "item/completed",
877 params: {
878 threadId,
879 turnId: `${threadId}-turn`,
880 completedAtMs: 0,
881 item: { type: "contextCompaction", id: `compaction-${threadId}` },
882 },
883 });
884 const script = {
885 rootThreadId: ROOT,
886 recordRequests: true,
887 // A child's compaction must not inject into the root thread.
888 notifications: [compacted(CHILD_A), compacted(ROOT)],
889 };
890 // @effect-diagnostics-next-line preferSchemaOverJson:off
891 NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
892 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
893 yield* Effect.addFinalizer(() =>
894 Effect.sync(() => {
895 NodeFS.rmSync(scriptPath, { force: true });
896 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
897 }),
898 );
899
900 const runtime = yield* makeCodexSessionRuntime({
901 threadId: ThreadId.make("thread-compaction-context"),
902 binaryPath: peerPath,
903 cwd: NodeOS.tmpdir(),
904 runtimeMode: "full-access",
905 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
906 models: Effect.succeed([
907 { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null },
908 ]),
909 });
910 const completedFiber = yield* runtime.events.pipe(
911 Stream.filter((event) => event.method === "turn/completed"),
912 Stream.take(1),
913 Stream.runCollect,
914 Effect.forkScoped,
915 );
916
917 yield* runtime.start();
918 yield* runtime.sendTurn({ input: "keep going", interactionMode: "default" });
919 yield* Fiber.join(completedFiber);
920
921 // The restore is awaited before later notifications, so it has landed.
922 const requests = readRecordedRequests();
923 assert.lengthOf(requests, 1);
924 const [inject] = requests;
925 assert.isDefined(inject);
926 assert.equal(inject.method, "thread/inject_items");
927 assert.equal(inject.params.threadId, ROOT);
928 const texts = (
929 inject.params.items as ReadonlyArray<{ role: string; content: [{ text: string }] }>
930 ).map((item) => {
931 assert.equal(item.role, "developer");
932 return item.content[0].text;
933 });
934 assert.lengthOf(texts, 1);
935 assert.match(
936 texts[0] ?? "",
937 /^<t3_code_runtime><runtime_info>.*as GPT-5\.6 Sol \(model slug: gpt-5\.6-sol\).*<\/t3_code_runtime>$/s,
938 );
939
940 yield* runtime.close;
941 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
942 );
943});
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+90-82
......@@ -9,7 +9,10 @@ import * as CodexErrors from "effect-codex-app-server/errors";
99import * as CodexRpc from "effect-codex-app-server/rpc";
1010import * as EffectCodexSchema from "effect-codex-app-server/schema";
1111
12import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts";
12import {
13 buildCodexAdditionalContext,
14 buildCodexDeveloperInstructions,
15} from "../CodexDeveloperInstructions.ts";
1316import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
1417import {
1518 buildTurnStartParams,
......@@ -232,12 +235,13 @@ describe("buildTurnStartParams", () => {
232235 settings: {
233236 model: "gpt-5.3-codex",
234237 reasoning_effort: "medium",
235 developer_instructions: buildCodexDeveloperInstructions("plan", {
236 model: "gpt-5.3-codex",
237 reasoningEffort: "medium",
238 }),
238 developer_instructions: buildCodexDeveloperInstructions("plan"),
239239 },
240240 },
241 additionalContext: buildCodexAdditionalContext({
242 model: "gpt-5.3-codex",
243 reasoningEffort: "medium",
244 }),
241245 });
242246 });
243247
......@@ -281,12 +285,13 @@ describe("buildTurnStartParams", () => {
281285 settings: {
282286 model: "gpt-5.3-codex",
283287 reasoning_effort: "medium",
284 developer_instructions: buildCodexDeveloperInstructions("default", {
285 model: "gpt-5.3-codex",
286 reasoningEffort: "medium",
287 }),
288 developer_instructions: buildCodexDeveloperInstructions("default"),
288289 },
289290 },
291 additionalContext: buildCodexAdditionalContext({
292 model: "gpt-5.3-codex",
293 reasoningEffort: "medium",
294 }),
290295 });
291296 });
292297
......@@ -303,9 +308,29 @@ describe("buildTurnStartParams", () => {
303308 const settings = params.collaborationMode?.settings;
304309 NodeAssert.equal(settings?.model, DEFAULT_MODEL);
305310 NodeAssert.equal(settings?.reasoning_effort, "medium");
306 NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`));
311 NodeAssert.ok(
312 params.additionalContext?.t3_code_runtime?.value.includes(`as ${DEFAULT_MODEL} with medium`),
313 );
307314 });
308315
316 it.effect("names the model by display name and slug in the runtime context", () =>
317 Effect.gen(function* () {
318 const params = yield* buildTurnStartParams({
319 threadId: "provider-thread-1",
320 runtimeMode: "full-access",
321 model: "gpt-5.3-codex",
322 modelName: "GPT-5.3-Codex",
323 effort: "high",
324 interactionMode: "plan",
325 });
326
327 NodeAssert.match(
328 params.additionalContext?.t3_code_runtime?.value ?? "",
329 /as GPT-5\.3-Codex \(model slug: gpt-5\.3-codex\) with high reasoning effort/,
330 );
331 }),
332 );
333
309334 it.effect("routes approvals to the auto reviewer in auto mode", () =>
310335 Effect.gen(function* () {
311336 const params = yield* buildTurnStartParams({
......@@ -560,99 +585,82 @@ describe("Codex MCP elicitation approvals", () => {
560585});
561586
562587describe("buildCodexDeveloperInstructions", () => {
563 it("appends runtime info after the mode instructions", () => {
564 const instructions = buildCodexDeveloperInstructions("default", {
565 model: "gpt-5.3-codex",
566 reasoningEffort: "high",
567 });
568
569 NodeAssert.match(instructions, /^<collaboration_mode># Collaboration Mode: Default/);
570 NodeAssert.match(instructions, /T3 Code/);
571 NodeAssert.match(instructions, /Codex harness/);
572 NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/);
573 });
574
575 it("describes Markdown media support in the runtime context in both modes", () => {
588 it("keeps T3 context out of the mode prompt, which the model catalog can replace", () => {
576589 for (const mode of ["default", "plan"] as const) {
577 const instructions = buildCodexDeveloperInstructions(mode, {
578 model: "gpt-5.3-codex",
579 reasoningEffort: "high",
580 });
581 NodeAssert.match(
582 instructions,
583 /<runtime_info>.*embed images and videos.*Markdown.*<\/runtime_info>/,
584 );
590 const instructions = buildCodexDeveloperInstructions(mode);
591 NodeAssert.match(instructions, /^<collaboration_mode>[\s\S]*<\/collaboration_mode>$/);
592 NodeAssert.doesNotMatch(instructions, /runtime_info|pull_request_linking|preview_|device_/);
585593 }
586594 });
595});
587596
588 it("includes runtime info alongside plan mode instructions", () => {
589 const instructions = buildCodexDeveloperInstructions("plan", {
590 model: "gpt-5.3-codex",
591 reasoningEffort: "medium",
592 });
597describe("buildCodexAdditionalContext", () => {
598 const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" };
599 const runtimeValue = (context: ReturnType<typeof buildCodexAdditionalContext>) =>
600 context.t3_code_runtime?.value ?? "";
601
602 it("describes the harness, model, effort, and Markdown media support", () => {
603 const context = buildCodexAdditionalContext(runtime);
593604
594 NodeAssert.match(instructions, /^<collaboration_mode># Plan Mode/);
595 NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/);
605 NodeAssert.equal(context.t3_code_runtime?.kind, "application");
606 NodeAssert.match(
607 runtimeValue(context),
608 /<runtime_info>.*Codex harness, as gpt-5\.3-codex with high reasoning effort.*embed images and videos.*Markdown.*<\/runtime_info>/,
609 );
596610 });
597611
598612 it("varies with the model and effort of each turn", () => {
599 const first = buildCodexDeveloperInstructions("default", {
600 model: "gpt-5.3-codex",
601 reasoningEffort: "medium",
602 });
603 const second = buildCodexDeveloperInstructions("default", {
604 model: "gpt-5.4",
605 reasoningEffort: "high",
606 });
607
608 NodeAssert.notEqual(first, second);
613 NodeAssert.notEqual(
614 runtimeValue(
615 buildCodexAdditionalContext({ model: "gpt-5.3-codex", reasoningEffort: "medium" }),
616 ),
617 runtimeValue(buildCodexAdditionalContext({ model: "gpt-5.4", reasoningEffort: "high" })),
618 );
609619 });
610620
611621 it("flattens multiline metadata into single-line runtime info", () => {
612 const instructions = buildCodexDeveloperInstructions("default", {
613 model: "gpt\n5.3\ncodex",
614 reasoningEffort: " high\neffort ",
615 });
622 const value = runtimeValue(
623 buildCodexAdditionalContext({ model: "gpt\n5.3\ncodex", reasoningEffort: " high\neffort " }),
624 );
616625
617 NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/);
618 NodeAssert.doesNotMatch(instructions, /<runtime_info>[^<]*\n/);
626 NodeAssert.match(value, /as gpt 5\.3 codex with high effort reasoning effort/);
627 NodeAssert.doesNotMatch(value, /<runtime_info>[^<]*\n/);
628 });
629
630 it("keeps every entry under Codex's 1,000 token cap per entry", () => {
631 const context = buildCodexAdditionalContext(runtime, { browser: true, device: true });
632 for (const entry of Object.values(context)) {
633 // Codex estimates 4 bytes per token and truncates the middle of longer values.
634 NodeAssert.ok(Buffer.byteLength(entry.value) < 4_000);
635 }
619636 });
620637});
621638
622describe("T3 browser developer instructions", () => {
639describe("T3 tool instructions", () => {
623640 const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" };
624641
625 it("prefers the product-native preview tools in both collaboration modes", () => {
626 for (const mode of ["default", "plan"] as const) {
627 const instructions = buildCodexDeveloperInstructions(mode, runtime, true);
628 NodeAssert.match(instructions, /t3-code/);
629 NodeAssert.match(instructions, /preview_status/);
630 NodeAssert.match(instructions, /preview_open/);
631 NodeAssert.match(instructions, /Do not switch to global browser skills/);
632 }
642 it("prefers the product-native preview tools when they are attached", () => {
643 const tools = buildCodexAdditionalContext(runtime, true).t3_code_tools?.value ?? "";
644 NodeAssert.match(tools, /t3-code/);
645 NodeAssert.match(tools, /preview_status/);
646 NodeAssert.match(tools, /preview_open/);
647 NodeAssert.match(tools, /Do not switch to global browser skills/);
648 NodeAssert.doesNotMatch(tools, /device_open/);
633649 });
634650
635 it("omits the browser block entirely when the preview tools are not attached", () => {
636 for (const mode of ["default", "plan"] as const) {
637 const instructions = buildCodexDeveloperInstructions(mode, runtime, false);
638 NodeAssert.doesNotMatch(instructions, /preview_status/);
639 NodeAssert.doesNotMatch(instructions, /preview_open/);
640 NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/);
641 // Steering away from other browser automation must go with the tools;
642 // keeping it would leave the model talked out of its only option.
643 NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/);
644 // The rest of the collaboration mode is untouched.
645 NodeAssert.match(instructions, /<collaboration_mode>/);
646 NodeAssert.match(instructions, /<\/collaboration_mode>/);
647 }
651 it("describes device tools only when the credential grants them", () => {
652 const tools =
653 buildCodexAdditionalContext(runtime, { browser: false, device: true }).t3_code_tools?.value ??
654 "";
655 NodeAssert.match(tools, /device_open/);
656 NodeAssert.doesNotMatch(tools, /preview_open/);
648657 });
649658
650 it("tracks the turn's MCP configuration rather than defaulting to on", () => {
651 NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/);
652 NodeAssert.doesNotMatch(
653 buildCodexDeveloperInstructions("default", runtime, false),
654 /preview_open/,
655 );
659 it("omits the tool entry entirely when no tools are attached", () => {
660 // Steering away from other browser automation must go with the tools;
661 // keeping it would leave the model talked out of its only option.
662 const context = buildCodexAdditionalContext(runtime, false);
663 NodeAssert.deepStrictEqual(Object.keys(context), ["t3_code_runtime"]);
656664 });
657665});
658666
apps/server/src/provider/Layers/CodexSessionRuntime.ts+72-15
......@@ -14,6 +14,7 @@ import {
1414 type ProviderTurnStartResult,
1515 type ProviderUserInputAnswers,
1616 RuntimeMode,
17 type ServerProviderModel,
1718 ThreadId,
1819 TurnId,
1920} from "@t3tools/contracts";
......@@ -40,6 +41,7 @@ import { buildCodexInitializeParams } from "./CodexProvider.ts";
4041import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
4142import { expandHomePath } from "../../pathExpansion.ts";
4243import {
44 buildCodexAdditionalContext,
4345 buildCodexDeveloperInstructions,
4446 type T3CodeToolAvailability,
4547} from "../CodexDeveloperInstructions.ts";
......@@ -141,10 +143,13 @@ const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata);
141143const isMcpElicitationForm = Schema.is(McpElicitationForm);
142144
143145// TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated
144// `V2TurnStartParams` schema includes `collaborationMode` directly.
146// `V2TurnStartParams` schema includes its experimental fields directly.
145147const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe(
146148 Schema.fieldsAssign({
147149 collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode),
150 additionalContext: Schema.optionalKey(
151 Schema.Record(Schema.String, EffectCodexSchema.V2TurnStartParams__AdditionalContextEntry),
152 ),
148153 }),
149154);
150155const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect(
......@@ -178,6 +183,8 @@ export interface CodexSessionRuntimeOptions {
178183 readonly serviceTier?: CodexServiceTier | undefined;
179184 readonly resumeCursor?: CodexResumeCursor;
180185 readonly appServerArgs?: ReadonlyArray<string>;
186 /** The provider's model list; supplies the display name for runtime info. */
187 readonly models?: Effect.Effect<ReadonlyArray<ServerProviderModel>>;
181188 /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */
182189 readonly mcpCapabilities?: ReadonlySet<string>;
183190}
......@@ -579,28 +586,31 @@ function runtimeModeToTurnSandboxPolicy(
579586 }
580587}
581588
582function buildCodexCollaborationMode(input: {
589function buildCodexTurnInstructions(input: {
583590 readonly interactionMode?: ProviderInteractionMode;
584591 readonly model?: string;
592 readonly modelName?: string;
585593 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
586594 readonly browserToolsAvailable?: boolean | T3CodeToolAvailability;
587}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined {
595}): Pick<CodexTurnStartParamsWithCollaborationMode, "collaborationMode" | "additionalContext"> {
588596 if (input.interactionMode === undefined) {
589 return undefined;
597 return {};
590598 }
591599 const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL;
592600 const reasoningEffort = input.effort ?? "medium";
593601 return {
594 mode: input.interactionMode,
595 settings: {
596 model,
597 reasoning_effort: reasoningEffort,
598 developer_instructions: buildCodexDeveloperInstructions(
599 input.interactionMode,
600 { model, reasoningEffort },
601 input.browserToolsAvailable ?? true,
602 ),
602 collaborationMode: {
603 mode: input.interactionMode,
604 settings: {
605 model,
606 reasoning_effort: reasoningEffort,
607 developer_instructions: buildCodexDeveloperInstructions(input.interactionMode),
608 },
603609 },
610 additionalContext: buildCodexAdditionalContext(
611 { model, modelName: input.modelName, reasoningEffort },
612 input.browserToolsAvailable ?? true,
613 ),
604614 };
605615}
606616
......@@ -617,6 +627,8 @@ export function buildTurnStartParams(input: {
617627 readonly path: string;
618628 }>;
619629 readonly model?: string;
630 /** Display name of `model`, for runtime info. */
631 readonly modelName?: string;
620632 readonly serviceTier?: CodexServiceTier;
621633 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
622634 readonly interactionMode?: ProviderInteractionMode;
......@@ -638,9 +650,10 @@ export function buildTurnStartParams(input: {
638650 }
639651
640652 const config = runtimeModeToThreadConfig(input.runtimeMode);
641 const collaborationMode = buildCodexCollaborationMode({
653 const turnInstructions = buildCodexTurnInstructions({
642654 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
643655 ...(input.model ? { model: input.model } : {}),
656 ...(input.modelName ? { modelName: input.modelName } : {}),
644657 ...(input.effort ? { effort: input.effort } : {}),
645658 browserToolsAvailable: input.browserToolsAvailable ?? true,
646659 });
......@@ -654,7 +667,7 @@ export function buildTurnStartParams(input: {
654667 ...(input.model ? { model: input.model } : {}),
655668 ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
656669 ...(input.effort ? { effort: input.effort } : {}),
657 ...(collaborationMode ? { collaborationMode } : {}),
670 ...turnInstructions,
658671 }).pipe(
659672 Effect.mapError((cause) =>
660673 CodexErrors.CodexAppServerProtocolParseError.fromSchemaError(
......@@ -1305,6 +1318,9 @@ export const makeCodexSessionRuntime = (
13051318 const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>());
13061319 const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter();
13071320 const closedRef = yield* Ref.make(false);
1321 /** The `additionalContext` of the latest `turn/start`, restored after compaction. */
1322 const lastAdditionalContextRef =
1323 yield* Ref.make<CodexTurnStartParamsWithCollaborationMode["additionalContext"]>(undefined);
13081324
13091325 // `~` is not shell-expanded when env vars are set via
13101326 // `child_process.spawn`; `expandHomePath` lets a configured
......@@ -1856,6 +1872,35 @@ export const makeCodexSessionRuntime = (
18561872 }
18571873 });
18581874
1875 /**
1876 * Compaction rebuilds history from user messages and Codex's own context,
1877 * which drops our `additionalContext` messages. Codex only resends an
1878 * entry when its value changes, so without this the T3 context would stay
1879 * lost until the model or effort changed. Awaited so the context is back
1880 * before later notifications from the same turn are handled. Drop this if
1881 * Codex enables its `retain_client_developer_messages` feature by default.
1882 */
1883 const restoreAdditionalContext = (threadId: string) =>
1884 Effect.gen(function* () {
1885 const context = yield* Ref.get(lastAdditionalContextRef);
1886 if (!context) return;
1887 yield* client.request("thread/inject_items", {
1888 threadId,
1889 items: Object.entries(context).map(([key, entry]) => ({
1890 type: "message",
1891 role: "developer",
1892 content: [{ type: "input_text", text: `<${key}>${entry.value}</${key}>` }],
1893 })),
1894 });
1895 }).pipe(
1896 Effect.timeout("10 seconds"),
1897 Effect.catch((cause) =>
1898 Effect.logWarning("Failed to restore Codex additional context after compaction.", {
1899 cause,
1900 }),
1901 ),
1902 );
1903
18591904 const handleRawNotification = (notification: CodexServerNotification) =>
18601905 Effect.gen(function* () {
18611906 const isMemoryConsolidationNotification =
......@@ -1942,6 +1987,14 @@ export const makeCodexSessionRuntime = (
19421987 return;
19431988 }
19441989
1990 if (
1991 notification.method === "item/completed" &&
1992 notification.params.item.type === "contextCompaction" &&
1993 notification.params.threadId === suppressRootId
1994 ) {
1995 yield* restoreAdditionalContext(notification.params.threadId);
1996 }
1997
19451998 let requestId: ApprovalRequestId | undefined;
19461999 let requestKind: ProviderRequestKind | undefined;
19472000 let turnId = childParentTurnId ?? route.turnId;
......@@ -2509,12 +2562,15 @@ export const makeCodexSessionRuntime = (
25092562 const normalizedModel = normalizeCodexModelSlug(
25102563 input.model ?? (yield* Ref.get(sessionRef)).model,
25112564 );
2565 const models = options.models ? yield* options.models : [];
2566 const modelName = models.find((model) => model.slug === normalizedModel)?.name;
25122567 const params = yield* buildTurnStartParams({
25132568 threadId: providerThreadId,
25142569 runtimeMode: options.runtimeMode,
25152570 ...(input.input ? { prompt: input.input } : {}),
25162571 ...(input.attachments ? { attachments: input.attachments } : {}),
25172572 ...(normalizedModel ? { model: normalizedModel } : {}),
2573 ...(modelName ? { modelName } : {}),
25182574 ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
25192575 ...(input.effort ? { effort: input.effort } : {}),
25202576 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
......@@ -2526,6 +2582,7 @@ export const makeCodexSessionRuntime = (
25262582 options.mcpCapabilities,
25272583 ),
25282584 });
2585 yield* Ref.set(lastAdditionalContextRef, params.additionalContext);
25292586 const rawResponse = yield* client.raw.request("turn/start", params);
25302587 const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(
25312588 Effect.mapError((error) =>
apps/server/src/provider/RuntimeInstructions.test.ts+9
......@@ -20,6 +20,15 @@ describe("buildRuntimeInstructions", () => {
2020 ).toContain("through the Codex harness, as custom model with high reasoning effort.");
2121 });
2222
23 it("names the model by display name and slug when they differ", () => {
24 expect(
25 buildRuntimeInstructions({ harness: "Codex", model: "gpt-5.4", modelName: "GPT-5.4" }),
26 ).toContain("through the Codex harness, as GPT-5.4 (model slug: gpt-5.4).");
27 expect(
28 buildRuntimeInstructions({ harness: "Codex", model: "my-model", modelName: "my-model" }),
29 ).toContain("through the Codex harness, as my-model.");
30 });
31
2332 it.each([undefined, "", "auto", "default"])("omits unresolved model %s", (model) => {
2433 const instructions = buildRuntimeInstructions({ harness: "Cursor", model });
2534 expect(instructions).toContain("through the Cursor harness.");
apps/server/src/provider/RuntimeInstructions.ts+9-2
......@@ -2,16 +2,23 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking>
22When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.
33</pull_request_linking>`;
44
5/** Shared runtime context; omit model and effort when the harness manages them dynamically. */
5/**
6 * Shared runtime context; omit model and effort when the harness manages them dynamically.
7 * `modelName` is the display name users see in the model picker; `model` is the slug.
8 */
69export function buildRuntimeInstructions(runtime: {
710 readonly harness: string;
811 readonly model?: string | undefined;
12 readonly modelName?: string | undefined;
913 readonly reasoningEffort?: string | undefined;
1014}): string {
1115 const harness = toSingleLine(runtime.harness);
1216 const model = toSingleLine(runtime.model ?? "");
17 const modelName = toSingleLine(runtime.modelName ?? "");
1318 const effort = toSingleLine(runtime.reasoningEffort ?? "");
14 const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : "";
19 const modelLabel =
20 modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model;
21 const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : "";
1522 const effortInfo = effort ? ` with ${effort} reasoning effort` : "";
1623 return `<runtime_info>In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`;
1724}
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+8
......@@ -106,6 +106,14 @@ rl.on("line", (line) => {
106106 write({ id, result: fixture.responses.threadStart });
107107 return;
108108 }
109 if (method === "thread/inject_items" && script.recordRequests) {
110 NodeFS.appendFileSync(
111 `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`,
112 `${JSON.stringify({ method, params: message.params })}\n`,
113 );
114 write({ id, result: {} });
115 return;
116 }
109117 if (method === "thread/resume") {
110118 if (script.recordRequests) {
111119 NodeFS.appendFileSync(