| author | |
| committer | |
| log | 6391be2729566a1fe1045ff8617f003580b37bfd |
| tree | ad41c538db15f48d02303b4d9d682915c269df74 |
| parent | ab70c8943f48c08e67e3597f62941c169bc58c16 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ... | @@ -1,23 +1,18 @@ |
| 1 | import type { ProviderInteractionMode } from "@t3tools/contracts"; | 1 | import type { ProviderInteractionMode } from "@t3tools/contracts"; |
| 2 | import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; | ||
| 2 | import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; | 3 | import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; |
| 3 | 4 | ||
| 4 | const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` | 5 | const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `## T3 Code collaborative browser |
| 5 | |||
| 6 | ## T3 Code collaborative browser | ||
| 7 | 6 | ||
| 8 | You 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. | 7 | You 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. |
| 9 | 8 | ||
| 10 | For 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. | 9 | For 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. |
| 11 | 10 | ||
| 12 | Do 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. | 11 | Do 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 | |||
| 15 | const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = ` | ||
| 16 | 12 | ||
| 17 | ## T3 Code devices | 13 | const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices |
| 18 | 14 | ||
| 19 | The \`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. | 15 | The \`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 | `; | ||
| 21 | 16 | ||
| 22 | export interface T3CodeToolAvailability { | 17 | export interface T3CodeToolAvailability { |
| 23 | readonly browser: boolean; | 18 | readonly browser: boolean; |
| ... | @@ -36,16 +31,17 @@ const normalizeAvailability = ( | ... | @@ -36,16 +31,17 @@ const normalizeAvailability = ( |
| 36 | * from Playwright, agent-browser, and raw simctl/adb, so leaving them in would | 31 | * from Playwright, agent-browser, and raw simctl/adb, so leaving them in would |
| 37 | * talk it out of the only automation it still has. | 32 | * talk it out of the only automation it still has. |
| 38 | */ | 33 | */ |
| 39 | const browserToolInstructions = (availability: boolean | T3CodeToolAvailability): string => { | 34 | const toolInstructions = (availability: boolean | T3CodeToolAvailability): string => { |
| 40 | const tools = normalizeAvailability(availability); | 35 | const tools = normalizeAvailability(availability); |
| 41 | return `${tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""}${ | 36 | return [ |
| 42 | tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "" | 37 | tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : "", |
| 43 | }`; | 38 | tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "", |
| 39 | ] | ||
| 40 | .filter(Boolean) | ||
| 41 | .join("\n\n"); | ||
| 44 | }; | 42 | }; |
| 45 | 43 | ||
| 46 | const codexPlanModeDeveloperInstructions = ( | 44 | const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational) |
| 47 | browserToolsAvailable: boolean | T3CodeToolAvailability, | ||
| 48 | ): string => `<collaboration_mode># Plan Mode (Conversational) | ||
| 49 | 45 | ||
| 50 | You 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. | 46 | You 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. |
| 51 | 47 | ||
| ... | @@ -173,12 +169,9 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o | ... | @@ -173,12 +169,9 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o |
| 173 | Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec. | 169 | Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec. |
| 174 | 170 | ||
| 175 | If 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. | 171 | If 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)} | ||
| 177 | </collaboration_mode>`; | 172 | </collaboration_mode>`; |
| 178 | 173 | ||
| 179 | const codexDefaultModeDeveloperInstructions = ( | 174 | const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Collaboration Mode: Default |
| 180 | browserToolsAvailable: boolean | T3CodeToolAvailability, | ||
| 181 | ): string => `<collaboration_mode># Collaboration Mode: Default | ||
| 182 | 175 | ||
| 183 | You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. | 176 | You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. |
| 184 | 177 | ||
| ... | @@ -189,29 +182,46 @@ Your active mode changes only when new developer instructions with a different \ | ... | @@ -189,29 +182,46 @@ Your active mode changes only when new developer instructions with a different \ |
| 189 | Use the \`request_user_input\` tool only when it is listed in the available tools for this turn. | 182 | Use the \`request_user_input\` tool only when it is listed in the available tools for this turn. |
| 190 | 183 | ||
| 191 | In 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. | 184 | In 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)} | ||
| 193 | </collaboration_mode>`; | 185 | </collaboration_mode>`; |
| 194 | 186 | ||
| 195 | export interface CodexRuntimeInfo { | 187 | export interface CodexRuntimeInfo { |
| 196 | readonly model: string; | 188 | readonly model: string; |
| 189 | readonly modelName?: string | undefined; | ||
| 197 | readonly reasoningEffort: string; | 190 | readonly reasoningEffort: string; |
| 198 | } | 191 | } |
| 199 | 192 | ||
| 200 | export function buildCodexDeveloperInstructions( | 193 | /** Mode prompt for `turn/start.collaborationMode.settings.developer_instructions`. */ |
| 201 | interactionMode: ProviderInteractionMode, | 194 | export 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 | */ | ||
| 209 | export function buildCodexAdditionalContext( | ||
| 202 | runtime: CodexRuntimeInfo, | 210 | runtime: CodexRuntimeInfo, |
| 203 | /** | 211 | /** |
| 204 | * Whether the `t3-code` MCP server is attached to this turn. Callers derive | 212 | * Whether the `t3-code` MCP server is attached to this turn. Callers derive |
| 205 | * it from the session's actual MCP configuration rather than re-reading the | 213 | * it from the session's actual MCP configuration rather than re-reading the |
| 206 | * setting, so the prompt cannot claim tools the turn doesn't have. | 214 | * setting, so the prompt cannot claim tools the turn doesn't have. |
| 207 | */ | 215 | */ |
| 208 | browserToolsAvailable: boolean | T3CodeToolAvailability = true, | 216 | toolsAvailable: boolean | T3CodeToolAvailability = true, |
| 209 | ): string { | 217 | ): Record<string, V2TurnStartParams__AdditionalContextEntry> { |
| 210 | const base = | 218 | const tools = toolInstructions(toolsAvailable); |
| 211 | interactionMode === "plan" | 219 | // Separate keys keep each value under Codex's per-entry token cap. |
| 212 | ? codexPlanModeDeveloperInstructions(browserToolsAvailable) | 220 | return { |
| 213 | : codexDefaultModeDeveloperInstructions(browserToolsAvailable); | 221 | t3_code_runtime: { |
| 214 | return `${base} | 222 | kind: "application", |
| 215 | 223 | value: buildRuntimeInstructions({ harness: "Codex", ...runtime }), | |
| 216 | ${buildRuntimeInstructions({ harness: "Codex", ...runtime })}`; | 224 | }, |
| 225 | ...(tools ? { t3_code_tools: { kind: "application", value: tools } } : {}), | ||
| 226 | }; | ||
| 217 | } | 227 | } |
apps/server/src/provider/Drivers/CodexDriver.ts+14-17| ... | @@ -172,18 +172,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -172,18 +172,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 172 | ), | 172 | ), |
| 173 | ); | 173 | ); |
| 174 | 174 | ||
| 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 | |||
| 187 | // Build a managed snapshot whose settings never change — mutations come | 175 | // Build a managed snapshot whose settings never change — mutations come |
| 188 | // in as instance rebuilds from the registry rather than in-place | 176 | // in as instance rebuilds from the registry rather than in-place |
| 189 | // updates. Pre-provide `ChildProcessSpawner` so the check fits | 177 | // updates. Pre-provide `ChildProcessSpawner` so the check fits |
| ... | @@ -238,11 +226,20 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -238,11 +226,20 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 238 | }), | 226 | }), |
| 239 | ), | 227 | ), |
| 240 | ); | 228 | ); |
| 241 | const textGeneration = yield* makeCodexTextGeneration( | 229 | const models = snapshot.getSnapshot.pipe(Effect.map((value) => value.models)); |
| 242 | effectiveConfig, | 230 | // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error |
| 243 | processEnv, | 231 | // channels at construction time — their failure modes are all on the |
| 244 | snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), | 232 | // per-operation closures they return. No `mapError` wrapper is needed |
| 245 | ); | 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); | ||
| 246 | const snapshotForCwd = (cwd: string) => | 243 | const snapshotForCwd = (cwd: string) => |
| 247 | !effectiveConfig.enabled | 244 | !effectiveConfig.enabled |
| 248 | ? snapshot.getSnapshot | 245 | ? snapshot.getSnapshot |
apps/server/src/provider/Layers/CodexAdapter.ts+4| ... | @@ -22,6 +22,7 @@ import { | ... | @@ -22,6 +22,7 @@ import { |
| 22 | type ToolActivityNativeAppReference, | 22 | type ToolActivityNativeAppReference, |
| 23 | type ToolActivitySource, | 23 | type ToolActivitySource, |
| 24 | type ProviderUserInputAnswers, | 24 | type ProviderUserInputAnswers, |
| 25 | type ServerProviderModel, | ||
| 25 | RuntimeItemId, | 26 | RuntimeItemId, |
| 26 | RuntimeRequestId, | 27 | RuntimeRequestId, |
| 27 | RuntimeTaskId, | 28 | RuntimeTaskId, |
| ... | @@ -89,6 +90,8 @@ const PROVIDER = ProviderDriverKind.make("codex"); | ... | @@ -89,6 +90,8 @@ const PROVIDER = ProviderDriverKind.make("codex"); |
| 89 | export interface CodexAdapterLiveOptions { | 90 | export interface CodexAdapterLiveOptions { |
| 90 | readonly instanceId?: ProviderInstanceId; | 91 | readonly instanceId?: ProviderInstanceId; |
| 91 | readonly environment?: NodeJS.ProcessEnv; | 92 | readonly environment?: NodeJS.ProcessEnv; |
| 93 | /** The provider's model list; supplies model display names for runtime info. */ | ||
| 94 | readonly models?: Effect.Effect<ReadonlyArray<ServerProviderModel>>; | ||
| 92 | readonly makeRuntime?: ( | 95 | readonly makeRuntime?: ( |
| 93 | options: CodexSessionRuntimeOptions, | 96 | options: CodexSessionRuntimeOptions, |
| 94 | ) => Effect.Effect< | 97 | ) => Effect.Effect< |
| ... | @@ -2277,6 +2280,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -2277,6 +2280,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 2277 | providerInstanceId: boundInstanceId, | 2280 | providerInstanceId: boundInstanceId, |
| 2278 | cwd: input.cwd ?? process.cwd(), | 2281 | cwd: input.cwd ?? process.cwd(), |
| 2279 | binaryPath: codexConfig.binaryPath, | 2282 | binaryPath: codexConfig.binaryPath, |
| 2283 | ...(options?.models ? { models: options.models } : {}), | ||
| 2280 | launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), | 2284 | launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), |
| 2281 | ...(options?.environment ? { environment: options.environment } : {}), | 2285 | ...(options?.environment ? { environment: options.environment } : {}), |
| 2282 | ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), | 2286 | ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), |
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+73| ... | @@ -868,3 +868,76 @@ describe("CodexSessionRuntime collab integration", () => { | ... | @@ -868,3 +868,76 @@ describe("CodexSessionRuntime collab integration", () => { |
| 868 | ); | 868 | ); |
| 869 | } | 869 | } |
| 870 | }); | 870 | }); |
| 871 | |||
| 872 | describe("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"; | ... | @@ -9,7 +9,10 @@ import * as CodexErrors from "effect-codex-app-server/errors"; |
| 9 | import * as CodexRpc from "effect-codex-app-server/rpc"; | 9 | import * as CodexRpc from "effect-codex-app-server/rpc"; |
| 10 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; | 10 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| 11 | 11 | ||
| 12 | import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; | 12 | import { |
| 13 | buildCodexAdditionalContext, | ||
| 14 | buildCodexDeveloperInstructions, | ||
| 15 | } from "../CodexDeveloperInstructions.ts"; | ||
| 13 | import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; | 16 | import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; |
| 14 | import { | 17 | import { |
| 15 | buildTurnStartParams, | 18 | buildTurnStartParams, |
| ... | @@ -232,12 +235,13 @@ describe("buildTurnStartParams", () => { | ... | @@ -232,12 +235,13 @@ describe("buildTurnStartParams", () => { |
| 232 | settings: { | 235 | settings: { |
| 233 | model: "gpt-5.3-codex", | 236 | model: "gpt-5.3-codex", |
| 234 | reasoning_effort: "medium", | 237 | reasoning_effort: "medium", |
| 235 | developer_instructions: buildCodexDeveloperInstructions("plan", { | 238 | developer_instructions: buildCodexDeveloperInstructions("plan"), |
| 236 | model: "gpt-5.3-codex", | ||
| 237 | reasoningEffort: "medium", | ||
| 238 | }), | ||
| 239 | }, | 239 | }, |
| 240 | }, | 240 | }, |
| 241 | additionalContext: buildCodexAdditionalContext({ | ||
| 242 | model: "gpt-5.3-codex", | ||
| 243 | reasoningEffort: "medium", | ||
| 244 | }), | ||
| 241 | }); | 245 | }); |
| 242 | }); | 246 | }); |
| 243 | 247 | ||
| ... | @@ -281,12 +285,13 @@ describe("buildTurnStartParams", () => { | ... | @@ -281,12 +285,13 @@ describe("buildTurnStartParams", () => { |
| 281 | settings: { | 285 | settings: { |
| 282 | model: "gpt-5.3-codex", | 286 | model: "gpt-5.3-codex", |
| 283 | reasoning_effort: "medium", | 287 | reasoning_effort: "medium", |
| 284 | developer_instructions: buildCodexDeveloperInstructions("default", { | 288 | developer_instructions: buildCodexDeveloperInstructions("default"), |
| 285 | model: "gpt-5.3-codex", | ||
| 286 | reasoningEffort: "medium", | ||
| 287 | }), | ||
| 288 | }, | 289 | }, |
| 289 | }, | 290 | }, |
| 291 | additionalContext: buildCodexAdditionalContext({ | ||
| 292 | model: "gpt-5.3-codex", | ||
| 293 | reasoningEffort: "medium", | ||
| 294 | }), | ||
| 290 | }); | 295 | }); |
| 291 | }); | 296 | }); |
| 292 | 297 | ||
| ... | @@ -303,9 +308,29 @@ describe("buildTurnStartParams", () => { | ... | @@ -303,9 +308,29 @@ describe("buildTurnStartParams", () => { |
| 303 | const settings = params.collaborationMode?.settings; | 308 | const settings = params.collaborationMode?.settings; |
| 304 | NodeAssert.equal(settings?.model, DEFAULT_MODEL); | 309 | NodeAssert.equal(settings?.model, DEFAULT_MODEL); |
| 305 | NodeAssert.equal(settings?.reasoning_effort, "medium"); | 310 | 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 | ); | ||
| 307 | }); | 314 | }); |
| 308 | 315 | ||
| 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 | |||
| 309 | it.effect("routes approvals to the auto reviewer in auto mode", () => | 334 | it.effect("routes approvals to the auto reviewer in auto mode", () => |
| 310 | Effect.gen(function* () { | 335 | Effect.gen(function* () { |
| 311 | const params = yield* buildTurnStartParams({ | 336 | const params = yield* buildTurnStartParams({ |
| ... | @@ -560,99 +585,82 @@ describe("Codex MCP elicitation approvals", () => { | ... | @@ -560,99 +585,82 @@ describe("Codex MCP elicitation approvals", () => { |
| 560 | }); | 585 | }); |
| 561 | 586 | ||
| 562 | describe("buildCodexDeveloperInstructions", () => { | 587 | describe("buildCodexDeveloperInstructions", () => { |
| 563 | it("appends runtime info after the mode instructions", () => { | 588 | it("keeps T3 context out of the mode prompt, which the model catalog can replace", () => { |
| 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", () => { | ||
| 576 | for (const mode of ["default", "plan"] as const) { | 589 | for (const mode of ["default", "plan"] as const) { |
| 577 | const instructions = buildCodexDeveloperInstructions(mode, { | 590 | const instructions = buildCodexDeveloperInstructions(mode); |
| 578 | model: "gpt-5.3-codex", | 591 | NodeAssert.match(instructions, /^<collaboration_mode>[\s\S]*<\/collaboration_mode>$/); |
| 579 | reasoningEffort: "high", | 592 | NodeAssert.doesNotMatch(instructions, /runtime_info|pull_request_linking|preview_|device_/); |
| 580 | }); | ||
| 581 | NodeAssert.match( | ||
| 582 | instructions, | ||
| 583 | /<runtime_info>.*embed images and videos.*Markdown.*<\/runtime_info>/, | ||
| 584 | ); | ||
| 585 | } | 593 | } |
| 586 | }); | 594 | }); |
| 595 | }); | ||
| 587 | 596 | ||
| 588 | it("includes runtime info alongside plan mode instructions", () => { | 597 | describe("buildCodexAdditionalContext", () => { |
| 589 | const instructions = buildCodexDeveloperInstructions("plan", { | 598 | const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; |
| 590 | model: "gpt-5.3-codex", | 599 | const runtimeValue = (context: ReturnType<typeof buildCodexAdditionalContext>) => |
| 591 | reasoningEffort: "medium", | 600 | context.t3_code_runtime?.value ?? ""; |
| 592 | }); | 601 | |
| 602 | it("describes the harness, model, effort, and Markdown media support", () => { | ||
| 603 | const context = buildCodexAdditionalContext(runtime); | ||
| 593 | 604 | ||
| 594 | NodeAssert.match(instructions, /^<collaboration_mode># Plan Mode/); | 605 | NodeAssert.equal(context.t3_code_runtime?.kind, "application"); |
| 595 | NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); | 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 | ); | ||
| 596 | }); | 610 | }); |
| 597 | 611 | ||
| 598 | it("varies with the model and effort of each turn", () => { | 612 | it("varies with the model and effort of each turn", () => { |
| 599 | const first = buildCodexDeveloperInstructions("default", { | 613 | NodeAssert.notEqual( |
| 600 | model: "gpt-5.3-codex", | 614 | runtimeValue( |
| 601 | reasoningEffort: "medium", | 615 | buildCodexAdditionalContext({ model: "gpt-5.3-codex", reasoningEffort: "medium" }), |
| 602 | }); | 616 | ), |
| 603 | const second = buildCodexDeveloperInstructions("default", { | 617 | runtimeValue(buildCodexAdditionalContext({ model: "gpt-5.4", reasoningEffort: "high" })), |
| 604 | model: "gpt-5.4", | 618 | ); |
| 605 | reasoningEffort: "high", | ||
| 606 | }); | ||
| 607 | |||
| 608 | NodeAssert.notEqual(first, second); | ||
| 609 | }); | 619 | }); |
| 610 | 620 | ||
| 611 | it("flattens multiline metadata into single-line runtime info", () => { | 621 | it("flattens multiline metadata into single-line runtime info", () => { |
| 612 | const instructions = buildCodexDeveloperInstructions("default", { | 622 | const value = runtimeValue( |
| 613 | model: "gpt\n5.3\ncodex", | 623 | buildCodexAdditionalContext({ model: "gpt\n5.3\ncodex", reasoningEffort: " high\neffort " }), |
| 614 | reasoningEffort: " high\neffort ", | 624 | ); |
| 615 | }); | ||
| 616 | 625 | ||
| 617 | NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); | 626 | NodeAssert.match(value, /as gpt 5\.3 codex with high effort reasoning effort/); |
| 618 | NodeAssert.doesNotMatch(instructions, /<runtime_info>[^<]*\n/); | 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 | } | ||
| 619 | }); | 636 | }); |
| 620 | }); | 637 | }); |
| 621 | 638 | ||
| 622 | describe("T3 browser developer instructions", () => { | 639 | describe("T3 tool instructions", () => { |
| 623 | const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; | 640 | const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; |
| 624 | 641 | ||
| 625 | it("prefers the product-native preview tools in both collaboration modes", () => { | 642 | it("prefers the product-native preview tools when they are attached", () => { |
| 626 | for (const mode of ["default", "plan"] as const) { | 643 | const tools = buildCodexAdditionalContext(runtime, true).t3_code_tools?.value ?? ""; |
| 627 | const instructions = buildCodexDeveloperInstructions(mode, runtime, true); | 644 | NodeAssert.match(tools, /t3-code/); |
| 628 | NodeAssert.match(instructions, /t3-code/); | 645 | NodeAssert.match(tools, /preview_status/); |
| 629 | NodeAssert.match(instructions, /preview_status/); | 646 | NodeAssert.match(tools, /preview_open/); |
| 630 | NodeAssert.match(instructions, /preview_open/); | 647 | NodeAssert.match(tools, /Do not switch to global browser skills/); |
| 631 | NodeAssert.match(instructions, /Do not switch to global browser skills/); | 648 | NodeAssert.doesNotMatch(tools, /device_open/); |
| 632 | } | ||
| 633 | }); | 649 | }); |
| 634 | 650 | ||
| 635 | it("omits the browser block entirely when the preview tools are not attached", () => { | 651 | it("describes device tools only when the credential grants them", () => { |
| 636 | for (const mode of ["default", "plan"] as const) { | 652 | const tools = |
| 637 | const instructions = buildCodexDeveloperInstructions(mode, runtime, false); | 653 | buildCodexAdditionalContext(runtime, { browser: false, device: true }).t3_code_tools?.value ?? |
| 638 | NodeAssert.doesNotMatch(instructions, /preview_status/); | 654 | ""; |
| 639 | NodeAssert.doesNotMatch(instructions, /preview_open/); | 655 | NodeAssert.match(tools, /device_open/); |
| 640 | NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); | 656 | NodeAssert.doesNotMatch(tools, /preview_open/); |
| 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 | } | ||
| 648 | }); | 657 | }); |
| 649 | 658 | ||
| 650 | it("tracks the turn's MCP configuration rather than defaulting to on", () => { | 659 | it("omits the tool entry entirely when no tools are attached", () => { |
| 651 | NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); | 660 | // Steering away from other browser automation must go with the tools; |
| 652 | NodeAssert.doesNotMatch( | 661 | // keeping it would leave the model talked out of its only option. |
| 653 | buildCodexDeveloperInstructions("default", runtime, false), | 662 | const context = buildCodexAdditionalContext(runtime, false); |
| 654 | /preview_open/, | 663 | NodeAssert.deepStrictEqual(Object.keys(context), ["t3_code_runtime"]); |
| 655 | ); | ||
| 656 | }); | 664 | }); |
| 657 | }); | 665 | }); |
| 658 | 666 |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+72-15| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | type ProviderTurnStartResult, | 14 | type ProviderTurnStartResult, |
| 15 | type ProviderUserInputAnswers, | 15 | type ProviderUserInputAnswers, |
| 16 | RuntimeMode, | 16 | RuntimeMode, |
| 17 | type ServerProviderModel, | ||
| 17 | ThreadId, | 18 | ThreadId, |
| 18 | TurnId, | 19 | TurnId, |
| 19 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| ... | @@ -40,6 +41,7 @@ import { buildCodexInitializeParams } from "./CodexProvider.ts"; | ... | @@ -40,6 +41,7 @@ import { buildCodexInitializeParams } from "./CodexProvider.ts"; |
| 40 | import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; | 41 | import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; |
| 41 | import { expandHomePath } from "../../pathExpansion.ts"; | 42 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 42 | import { | 43 | import { |
| 44 | buildCodexAdditionalContext, | ||
| 43 | buildCodexDeveloperInstructions, | 45 | buildCodexDeveloperInstructions, |
| 44 | type T3CodeToolAvailability, | 46 | type T3CodeToolAvailability, |
| 45 | } from "../CodexDeveloperInstructions.ts"; | 47 | } from "../CodexDeveloperInstructions.ts"; |
| ... | @@ -141,10 +143,13 @@ const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); | ... | @@ -141,10 +143,13 @@ const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); |
| 141 | const isMcpElicitationForm = Schema.is(McpElicitationForm); | 143 | const isMcpElicitationForm = Schema.is(McpElicitationForm); |
| 142 | 144 | ||
| 143 | // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated | 145 | // 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. |
| 145 | const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe( | 147 | const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe( |
| 146 | Schema.fieldsAssign({ | 148 | Schema.fieldsAssign({ |
| 147 | collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode), | 149 | collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode), |
| 150 | additionalContext: Schema.optionalKey( | ||
| 151 | Schema.Record(Schema.String, EffectCodexSchema.V2TurnStartParams__AdditionalContextEntry), | ||
| 152 | ), | ||
| 148 | }), | 153 | }), |
| 149 | ); | 154 | ); |
| 150 | const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( | 155 | const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( |
| ... | @@ -178,6 +183,8 @@ export interface CodexSessionRuntimeOptions { | ... | @@ -178,6 +183,8 @@ export interface CodexSessionRuntimeOptions { |
| 178 | readonly serviceTier?: CodexServiceTier | undefined; | 183 | readonly serviceTier?: CodexServiceTier | undefined; |
| 179 | readonly resumeCursor?: CodexResumeCursor; | 184 | readonly resumeCursor?: CodexResumeCursor; |
| 180 | readonly appServerArgs?: ReadonlyArray<string>; | 185 | readonly appServerArgs?: ReadonlyArray<string>; |
| 186 | /** The provider's model list; supplies the display name for runtime info. */ | ||
| 187 | readonly models?: Effect.Effect<ReadonlyArray<ServerProviderModel>>; | ||
| 181 | /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ | 188 | /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ |
| 182 | readonly mcpCapabilities?: ReadonlySet<string>; | 189 | readonly mcpCapabilities?: ReadonlySet<string>; |
| 183 | } | 190 | } |
| ... | @@ -579,28 +586,31 @@ function runtimeModeToTurnSandboxPolicy( | ... | @@ -579,28 +586,31 @@ function runtimeModeToTurnSandboxPolicy( |
| 579 | } | 586 | } |
| 580 | } | 587 | } |
| 581 | 588 | ||
| 582 | function buildCodexCollaborationMode(input: { | 589 | function buildCodexTurnInstructions(input: { |
| 583 | readonly interactionMode?: ProviderInteractionMode; | 590 | readonly interactionMode?: ProviderInteractionMode; |
| 584 | readonly model?: string; | 591 | readonly model?: string; |
| 592 | readonly modelName?: string; | ||
| 585 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; | 593 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; |
| 586 | readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; | 594 | readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; |
| 587 | }): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { | 595 | }): Pick<CodexTurnStartParamsWithCollaborationMode, "collaborationMode" | "additionalContext"> { |
| 588 | if (input.interactionMode === undefined) { | 596 | if (input.interactionMode === undefined) { |
| 589 | return undefined; | 597 | return {}; |
| 590 | } | 598 | } |
| 591 | const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; | 599 | const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; |
| 592 | const reasoningEffort = input.effort ?? "medium"; | 600 | const reasoningEffort = input.effort ?? "medium"; |
| 593 | return { | 601 | return { |
| 594 | mode: input.interactionMode, | 602 | collaborationMode: { |
| 595 | settings: { | 603 | mode: input.interactionMode, |
| 596 | model, | 604 | settings: { |
| 597 | reasoning_effort: reasoningEffort, | 605 | model, |
| 598 | developer_instructions: buildCodexDeveloperInstructions( | 606 | reasoning_effort: reasoningEffort, |
| 599 | input.interactionMode, | 607 | developer_instructions: buildCodexDeveloperInstructions(input.interactionMode), |
| 600 | { model, reasoningEffort }, | 608 | }, |
| 601 | input.browserToolsAvailable ?? true, | ||
| 602 | ), | ||
| 603 | }, | 609 | }, |
| 610 | additionalContext: buildCodexAdditionalContext( | ||
| 611 | { model, modelName: input.modelName, reasoningEffort }, | ||
| 612 | input.browserToolsAvailable ?? true, | ||
| 613 | ), | ||
| 604 | }; | 614 | }; |
| 605 | } | 615 | } |
| 606 | 616 | ||
| ... | @@ -617,6 +627,8 @@ export function buildTurnStartParams(input: { | ... | @@ -617,6 +627,8 @@ export function buildTurnStartParams(input: { |
| 617 | readonly path: string; | 627 | readonly path: string; |
| 618 | }>; | 628 | }>; |
| 619 | readonly model?: string; | 629 | readonly model?: string; |
| 630 | /** Display name of `model`, for runtime info. */ | ||
| 631 | readonly modelName?: string; | ||
| 620 | readonly serviceTier?: CodexServiceTier; | 632 | readonly serviceTier?: CodexServiceTier; |
| 621 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; | 633 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; |
| 622 | readonly interactionMode?: ProviderInteractionMode; | 634 | readonly interactionMode?: ProviderInteractionMode; |
| ... | @@ -638,9 +650,10 @@ export function buildTurnStartParams(input: { | ... | @@ -638,9 +650,10 @@ export function buildTurnStartParams(input: { |
| 638 | } | 650 | } |
| 639 | 651 | ||
| 640 | const config = runtimeModeToThreadConfig(input.runtimeMode); | 652 | const config = runtimeModeToThreadConfig(input.runtimeMode); |
| 641 | const collaborationMode = buildCodexCollaborationMode({ | 653 | const turnInstructions = buildCodexTurnInstructions({ |
| 642 | ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), | 654 | ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), |
| 643 | ...(input.model ? { model: input.model } : {}), | 655 | ...(input.model ? { model: input.model } : {}), |
| 656 | ...(input.modelName ? { modelName: input.modelName } : {}), | ||
| 644 | ...(input.effort ? { effort: input.effort } : {}), | 657 | ...(input.effort ? { effort: input.effort } : {}), |
| 645 | browserToolsAvailable: input.browserToolsAvailable ?? true, | 658 | browserToolsAvailable: input.browserToolsAvailable ?? true, |
| 646 | }); | 659 | }); |
| ... | @@ -654,7 +667,7 @@ export function buildTurnStartParams(input: { | ... | @@ -654,7 +667,7 @@ export function buildTurnStartParams(input: { |
| 654 | ...(input.model ? { model: input.model } : {}), | 667 | ...(input.model ? { model: input.model } : {}), |
| 655 | ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), | 668 | ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), |
| 656 | ...(input.effort ? { effort: input.effort } : {}), | 669 | ...(input.effort ? { effort: input.effort } : {}), |
| 657 | ...(collaborationMode ? { collaborationMode } : {}), | 670 | ...turnInstructions, |
| 658 | }).pipe( | 671 | }).pipe( |
| 659 | Effect.mapError((cause) => | 672 | Effect.mapError((cause) => |
| 660 | CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( | 673 | CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( |
| ... | @@ -1305,6 +1318,9 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1305,6 +1318,9 @@ export const makeCodexSessionRuntime = ( |
| 1305 | const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>()); | 1318 | const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>()); |
| 1306 | const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); | 1319 | const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); |
| 1307 | const closedRef = yield* Ref.make(false); | 1320 | 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); | ||
| 1308 | 1324 | ||
| 1309 | // `~` is not shell-expanded when env vars are set via | 1325 | // `~` is not shell-expanded when env vars are set via |
| 1310 | // `child_process.spawn`; `expandHomePath` lets a configured | 1326 | // `child_process.spawn`; `expandHomePath` lets a configured |
| ... | @@ -1856,6 +1872,35 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1856,6 +1872,35 @@ export const makeCodexSessionRuntime = ( |
| 1856 | } | 1872 | } |
| 1857 | }); | 1873 | }); |
| 1858 | 1874 | ||
| 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 | |||
| 1859 | const handleRawNotification = (notification: CodexServerNotification) => | 1904 | const handleRawNotification = (notification: CodexServerNotification) => |
| 1860 | Effect.gen(function* () { | 1905 | Effect.gen(function* () { |
| 1861 | const isMemoryConsolidationNotification = | 1906 | const isMemoryConsolidationNotification = |
| ... | @@ -1942,6 +1987,14 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1942,6 +1987,14 @@ export const makeCodexSessionRuntime = ( |
| 1942 | return; | 1987 | return; |
| 1943 | } | 1988 | } |
| 1944 | 1989 | ||
| 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 | |||
| 1945 | let requestId: ApprovalRequestId | undefined; | 1998 | let requestId: ApprovalRequestId | undefined; |
| 1946 | let requestKind: ProviderRequestKind | undefined; | 1999 | let requestKind: ProviderRequestKind | undefined; |
| 1947 | let turnId = childParentTurnId ?? route.turnId; | 2000 | let turnId = childParentTurnId ?? route.turnId; |
| ... | @@ -2509,12 +2562,15 @@ export const makeCodexSessionRuntime = ( | ... | @@ -2509,12 +2562,15 @@ export const makeCodexSessionRuntime = ( |
| 2509 | const normalizedModel = normalizeCodexModelSlug( | 2562 | const normalizedModel = normalizeCodexModelSlug( |
| 2510 | input.model ?? (yield* Ref.get(sessionRef)).model, | 2563 | input.model ?? (yield* Ref.get(sessionRef)).model, |
| 2511 | ); | 2564 | ); |
| 2565 | const models = options.models ? yield* options.models : []; | ||
| 2566 | const modelName = models.find((model) => model.slug === normalizedModel)?.name; | ||
| 2512 | const params = yield* buildTurnStartParams({ | 2567 | const params = yield* buildTurnStartParams({ |
| 2513 | threadId: providerThreadId, | 2568 | threadId: providerThreadId, |
| 2514 | runtimeMode: options.runtimeMode, | 2569 | runtimeMode: options.runtimeMode, |
| 2515 | ...(input.input ? { prompt: input.input } : {}), | 2570 | ...(input.input ? { prompt: input.input } : {}), |
| 2516 | ...(input.attachments ? { attachments: input.attachments } : {}), | 2571 | ...(input.attachments ? { attachments: input.attachments } : {}), |
| 2517 | ...(normalizedModel ? { model: normalizedModel } : {}), | 2572 | ...(normalizedModel ? { model: normalizedModel } : {}), |
| 2573 | ...(modelName ? { modelName } : {}), | ||
| 2518 | ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), | 2574 | ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), |
| 2519 | ...(input.effort ? { effort: input.effort } : {}), | 2575 | ...(input.effort ? { effort: input.effort } : {}), |
| 2520 | ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), | 2576 | ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), |
| ... | @@ -2526,6 +2582,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -2526,6 +2582,7 @@ export const makeCodexSessionRuntime = ( |
| 2526 | options.mcpCapabilities, | 2582 | options.mcpCapabilities, |
| 2527 | ), | 2583 | ), |
| 2528 | }); | 2584 | }); |
| 2585 | yield* Ref.set(lastAdditionalContextRef, params.additionalContext); | ||
| 2529 | const rawResponse = yield* client.raw.request("turn/start", params); | 2586 | const rawResponse = yield* client.raw.request("turn/start", params); |
| 2530 | const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( | 2587 | const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( |
| 2531 | Effect.mapError((error) => | 2588 | Effect.mapError((error) => |
apps/server/src/provider/RuntimeInstructions.test.ts+9| ... | @@ -20,6 +20,15 @@ describe("buildRuntimeInstructions", () => { | ... | @@ -20,6 +20,15 @@ describe("buildRuntimeInstructions", () => { |
| 20 | ).toContain("through the Codex harness, as custom model with high reasoning effort."); | 20 | ).toContain("through the Codex harness, as custom model with high reasoning effort."); |
| 21 | }); | 21 | }); |
| 22 | 22 | ||
| 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 | |||
| 23 | it.each([undefined, "", "auto", "default"])("omits unresolved model %s", (model) => { | 32 | it.each([undefined, "", "auto", "default"])("omits unresolved model %s", (model) => { |
| 24 | const instructions = buildRuntimeInstructions({ harness: "Cursor", model }); | 33 | const instructions = buildRuntimeInstructions({ harness: "Cursor", model }); |
| 25 | expect(instructions).toContain("through the Cursor harness."); | 34 | 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> | ... | @@ -2,16 +2,23 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking> |
| 2 | When 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. | 2 | When 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. |
| 3 | </pull_request_linking>`; | 3 | </pull_request_linking>`; |
| 4 | 4 | ||
| 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 | */ | ||
| 6 | export function buildRuntimeInstructions(runtime: { | 9 | export function buildRuntimeInstructions(runtime: { |
| 7 | readonly harness: string; | 10 | readonly harness: string; |
| 8 | readonly model?: string | undefined; | 11 | readonly model?: string | undefined; |
| 12 | readonly modelName?: string | undefined; | ||
| 9 | readonly reasoningEffort?: string | undefined; | 13 | readonly reasoningEffort?: string | undefined; |
| 10 | }): string { | 14 | }): string { |
| 11 | const harness = toSingleLine(runtime.harness); | 15 | const harness = toSingleLine(runtime.harness); |
| 12 | const model = toSingleLine(runtime.model ?? ""); | 16 | const model = toSingleLine(runtime.model ?? ""); |
| 17 | const modelName = toSingleLine(runtime.modelName ?? ""); | ||
| 13 | const effort = toSingleLine(runtime.reasoningEffort ?? ""); | 18 | 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}` : ""; | ||
| 15 | const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; | 22 | const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; |
| 16 | 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}`; | 23 | 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}`; |
| 17 | } | 24 | } |
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+8| ... | @@ -106,6 +106,14 @@ rl.on("line", (line) => { | ... | @@ -106,6 +106,14 @@ rl.on("line", (line) => { |
| 106 | write({ id, result: fixture.responses.threadStart }); | 106 | write({ id, result: fixture.responses.threadStart }); |
| 107 | return; | 107 | return; |
| 108 | } | 108 | } |
| 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 | } | ||
| 109 | if (method === "thread/resume") { | 117 | if (method === "thread/resume") { |
| 110 | if (script.recordRequests) { | 118 | if (script.recordRequests) { |
| 111 | NodeFS.appendFileSync( | 119 | NodeFS.appendFileSync( |