| author | |
| committer | |
| log | 153a130a09e9d2d71b9b1ff0c477c07b7bd81c9a |
| tree | 749da4a81efd2b0f44ea5b798c71fb85496500a4 |
| parent | 2fc933652828b51f35b48579c86b71858224b10c |
| signature | Commit is signed but in an unrecognized format. |
10 files changed, 419 insertions(+), 67 deletions(-)
TODO.md+8-10| ... | ... | @@ -1,11 +1,9 @@ |
| 1 | - [x] Folder picker for new projects | |
| 2 | - [x] Projects should be persisted so they're still visible after closing | |
| 3 | - [x] Remove model selection from project creation | |
| 4 | - [x] First message vanishes when thread is created?? Awful ux | |
| 5 | - [x] Show more clearly when thinking is happening | |
| 6 | - [x] Enable react compiler | |
| 1 | ## TODO | |
| 7 | 2 | |
| 8 | ## Bigger overhauls | |
| 9 | ||
| 10 | - [x] Markdown rendering | |
| 11 | - [x] Code block rendering | |
| 3 | - [ ] Fix sort order for threads (newest first) | |
| 4 | - [ ] Indicate in thread list which threads are currently working | |
| 5 | - [ ] Logs should be per-thread, not a single events.txt | |
| 6 | - [ ] Audit the logs to identify which events are (and aren't) worth showing to the user, i.e. drop "preamble" and "work" events | |
| 7 | - [ ] Make the "end message" stand out from the work done prior (maybe just a bar between the two that says "worked for 10 minutes 31 seconds" or however long it was) | |
| 8 | - [ ] Better logging of "time to complete" (I want this more than the timestamps tbh) | |
| 9 | - [ ] Remove the left bars on messages to make UI feel "flatter" |
apps/desktop/src/codexAppServerManager.ts+5-13| ... | ... | @@ -10,6 +10,7 @@ import type { |
| 10 | 10 | ProviderSessionStartInput, |
| 11 | 11 | ProviderTurnStartResult, |
| 12 | 12 | } from "@acme/contracts"; |
| 13 | import { normalizeModelSlug } from "@acme/contracts"; | |
| 13 | 14 | |
| 14 | 15 | type PendingRequestKey = string; |
| 15 | 16 | |
| ... | ... | @@ -59,29 +60,20 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ |
| 59 | 60 | "state db missing rollout path for thread", |
| 60 | 61 | "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", |
| 61 | 62 | ]; |
| 62 | const MODEL_SLUG_ALIASES: Record<string, string> = { | |
| 63 | "5.3": "gpt-5.3-codex", | |
| 64 | "gpt-5.3": "gpt-5.3-codex", | |
| 65 | }; | |
| 66 | ||
| 67 | 63 | export function normalizeCodexModelSlug( |
| 68 | 64 | model: string | undefined | null, |
| 69 | 65 | preferredId?: string, |
| 70 | 66 | ): string | undefined { |
| 71 | if (typeof model !== "string") { | |
| 72 | return undefined; | |
| 73 | } | |
| 74 | ||
| 75 | const trimmed = model.trim(); | |
| 76 | if (!trimmed) { | |
| 67 | const normalized = normalizeModelSlug(model); | |
| 68 | if (!normalized) { | |
| 77 | 69 | return undefined; |
| 78 | 70 | } |
| 79 | 71 | |
| 80 | if (preferredId?.endsWith("-codex") && preferredId !== trimmed) { | |
| 72 | if (preferredId?.endsWith("-codex") && preferredId !== normalized) { | |
| 81 | 73 | return preferredId; |
| 82 | 74 | } |
| 83 | 75 | |
| 84 | return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; | |
| 76 | return normalized; | |
| 85 | 77 | } |
| 86 | 78 | |
| 87 | 79 | export function classifyCodexStderrLine( |
apps/desktop/src/main.ts+1| ... | ... | @@ -314,6 +314,7 @@ async function bootstrap(): Promise<void> { |
| 314 | 314 | app.on("before-quit", () => { |
| 315 | 315 | processManager.killAll(); |
| 316 | 316 | providerManager.stopAll(); |
| 317 | providerManager.dispose(); | |
| 317 | 318 | }); |
| 318 | 319 | |
| 319 | 320 | app.whenReady().then(() => { |
apps/desktop/src/providerManager.test.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { ProviderManager } from "./providerManager"; | |
| 4 | ||
| 5 | describe("ProviderManager", () => { | |
| 6 | it("detaches provider event listener and ends log stream on dispose", () => { | |
| 7 | const manager = new ProviderManager(); | |
| 8 | const internals = manager as unknown as { | |
| 9 | codex: { listenerCount: (event: string) => number }; | |
| 10 | logStream: { writableEnded: boolean; destroyed: boolean }; | |
| 11 | }; | |
| 12 | ||
| 13 | expect(internals.codex.listenerCount("event")).toBe(1); | |
| 14 | manager.dispose(); | |
| 15 | ||
| 16 | expect(internals.codex.listenerCount("event")).toBe(0); | |
| 17 | expect( | |
| 18 | internals.logStream.writableEnded || internals.logStream.destroyed, | |
| 19 | ).toBe(true); | |
| 20 | }); | |
| 21 | ||
| 22 | it("allows multiple dispose calls", () => { | |
| 23 | const manager = new ProviderManager(); | |
| 24 | ||
| 25 | manager.dispose(); | |
| 26 | expect(() => manager.dispose()).not.toThrow(); | |
| 27 | }); | |
| 28 | }); |
apps/desktop/src/providerManager.ts+20-4| ... | ... | @@ -24,6 +24,15 @@ export interface ProviderManagerEvents { |
| 24 | 24 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 25 | 25 | private readonly codex = new CodexAppServerManager(); |
| 26 | 26 | private readonly logStream: fs.WriteStream; |
| 27 | private disposed = false; | |
| 28 | private readonly onCodexEvent = (event: ProviderEvent) => { | |
| 29 | if (this.disposed) { | |
| 30 | return; | |
| 31 | } | |
| 32 | ||
| 33 | this.logStream.write(`${JSON.stringify(event)}\n`); | |
| 34 | this.emit("event", event); | |
| 35 | }; | |
| 27 | 36 | |
| 28 | 37 | constructor() { |
| 29 | 38 | super(); |
| ... | ... | @@ -34,10 +43,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 34 | 43 | flags: "a", |
| 35 | 44 | }); |
| 36 | 45 | |
| 37 | this.codex.on("event", (event) => { | |
| 38 | this.logStream.write(`${JSON.stringify(event)}\n`); | |
| 39 | this.emit("event", event); | |
| 40 | }); | |
| 46 | this.codex.on("event", this.onCodexEvent); | |
| 41 | 47 | } |
| 42 | 48 | |
| 43 | 49 | async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> { |
| ... | ... | @@ -79,4 +85,14 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 79 | 85 | stopAll(): void { |
| 80 | 86 | this.codex.stopAll(); |
| 81 | 87 | } |
| 88 | ||
| 89 | dispose(): void { | |
| 90 | if (this.disposed) { | |
| 91 | return; | |
| 92 | } | |
| 93 | ||
| 94 | this.disposed = true; | |
| 95 | this.codex.off("event", this.onCodexEvent); | |
| 96 | this.logStream.end(); | |
| 97 | } | |
| 82 | 98 | } |
apps/renderer/src/model-logic.ts+6-38| ... | ... | @@ -1,41 +1,9 @@ |
| 1 | export const MODEL_OPTIONS = [ | |
| 2 | "gpt-5.3-codex", | |
| 3 | "gpt-5.2-codex", | |
| 4 | "gpt-5.2", | |
| 5 | ] as const; | |
| 6 | ||
| 7 | export const DEFAULT_MODEL = "gpt-5.3-codex"; | |
| 1 | export { | |
| 2 | DEFAULT_MODEL, | |
| 3 | MODEL_OPTIONS, | |
| 4 | normalizeModelSlug, | |
| 5 | resolveModelSlug, | |
| 6 | } from "@acme/contracts"; | |
| 8 | 7 | |
| 9 | 8 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; |
| 10 | 9 | export const DEFAULT_REASONING = "high"; |
| 11 | ||
| 12 | const MODEL_SLUG_ALIASES: Record<string, string> = { | |
| 13 | "5.3": "gpt-5.3-codex", | |
| 14 | "gpt-5.3": "gpt-5.3-codex", | |
| 15 | }; | |
| 16 | ||
| 17 | export function normalizeModelSlug( | |
| 18 | model: string | null | undefined, | |
| 19 | ): string | null { | |
| 20 | if (typeof model !== "string") { | |
| 21 | return null; | |
| 22 | } | |
| 23 | ||
| 24 | const trimmed = model.trim(); | |
| 25 | if (!trimmed) { | |
| 26 | return null; | |
| 27 | } | |
| 28 | ||
| 29 | return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; | |
| 30 | } | |
| 31 | ||
| 32 | export function resolveModelSlug(model: string | null | undefined): string { | |
| 33 | const normalized = normalizeModelSlug(model); | |
| 34 | if (!normalized) { | |
| 35 | return DEFAULT_MODEL; | |
| 36 | } | |
| 37 | ||
| 38 | return MODEL_OPTIONS.includes(normalized as (typeof MODEL_OPTIONS)[number]) | |
| 39 | ? normalized | |
| 40 | : DEFAULT_MODEL; | |
| 41 | } |
apps/renderer/src/session-logic.test.ts+272-2| ... | ... | @@ -1,8 +1,39 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { WorkLogEntry } from "./session-logic"; | |
| 3 | import { deriveTimelineEntries } from "./session-logic"; | |
| 2 | ||
| 3 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; | |
| 4 | import { | |
| 5 | type WorkLogEntry, | |
| 6 | applyEventToMessages, | |
| 7 | deriveTimelineEntries, | |
| 8 | evolveSession, | |
| 9 | } from "./session-logic"; | |
| 4 | 10 | import type { ChatMessage } from "./types"; |
| 5 | 11 | |
| 12 | function makeEvent(overrides: Partial<ProviderEvent>): ProviderEvent { | |
| 13 | return { | |
| 14 | id: "evt-1", | |
| 15 | kind: "notification", | |
| 16 | provider: "codex", | |
| 17 | sessionId: "sess-1", | |
| 18 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 19 | method: "noop", | |
| 20 | ...overrides, | |
| 21 | }; | |
| 22 | } | |
| 23 | ||
| 24 | function makeSession( | |
| 25 | overrides: Partial<ProviderSession> = {}, | |
| 26 | ): ProviderSession { | |
| 27 | return { | |
| 28 | sessionId: "sess-1", | |
| 29 | provider: "codex", | |
| 30 | status: "ready", | |
| 31 | createdAt: "2026-02-08T09:59:00.000Z", | |
| 32 | updatedAt: "2026-02-08T09:59:00.000Z", | |
| 33 | ...overrides, | |
| 34 | }; | |
| 35 | } | |
| 36 | ||
| 6 | 37 | describe("deriveTimelineEntries", () => { |
| 7 | 38 | it("interleaves messages and work entries by timestamp", () => { |
| 8 | 39 | const messages: ChatMessage[] = [ |
| ... | ... | @@ -73,3 +104,242 @@ describe("deriveTimelineEntries", () => { |
| 73 | 104 | ]); |
| 74 | 105 | }); |
| 75 | 106 | }); |
| 107 | ||
| 108 | describe("evolveSession", () => { | |
| 109 | it("updates thread id when thread starts", () => { | |
| 110 | const previous = makeSession(); | |
| 111 | const next = evolveSession( | |
| 112 | previous, | |
| 113 | makeEvent({ | |
| 114 | method: "thread/started", | |
| 115 | createdAt: "2026-02-08T10:01:00.000Z", | |
| 116 | payload: { thread: { id: "thread-1" } }, | |
| 117 | }), | |
| 118 | ); | |
| 119 | ||
| 120 | expect(next.threadId).toBe("thread-1"); | |
| 121 | expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z"); | |
| 122 | }); | |
| 123 | ||
| 124 | it("moves to running and records active turn on turn start", () => { | |
| 125 | const previous = makeSession(); | |
| 126 | const next = evolveSession( | |
| 127 | previous, | |
| 128 | makeEvent({ | |
| 129 | method: "turn/started", | |
| 130 | turnId: "turn-1", | |
| 131 | createdAt: "2026-02-08T10:02:00.000Z", | |
| 132 | }), | |
| 133 | ); | |
| 134 | ||
| 135 | expect(next.status).toBe("running"); | |
| 136 | expect(next.activeTurnId).toBe("turn-1"); | |
| 137 | expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z"); | |
| 138 | }); | |
| 139 | ||
| 140 | it("returns to ready and clears active turn on successful completion", () => { | |
| 141 | const previous = makeSession({ | |
| 142 | status: "running", | |
| 143 | activeTurnId: "turn-1", | |
| 144 | lastError: "older error", | |
| 145 | }); | |
| 146 | const next = evolveSession( | |
| 147 | previous, | |
| 148 | makeEvent({ | |
| 149 | method: "turn/completed", | |
| 150 | createdAt: "2026-02-08T10:03:00.000Z", | |
| 151 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 152 | }), | |
| 153 | ); | |
| 154 | ||
| 155 | expect(next.status).toBe("ready"); | |
| 156 | expect(next.activeTurnId).toBeUndefined(); | |
| 157 | expect(next.lastError).toBe("older error"); | |
| 158 | }); | |
| 159 | ||
| 160 | it("marks session as error when turn fails", () => { | |
| 161 | const previous = makeSession({ | |
| 162 | status: "running", | |
| 163 | activeTurnId: "turn-1", | |
| 164 | }); | |
| 165 | const next = evolveSession( | |
| 166 | previous, | |
| 167 | makeEvent({ | |
| 168 | method: "turn/completed", | |
| 169 | createdAt: "2026-02-08T10:03:00.000Z", | |
| 170 | payload: { | |
| 171 | turn: { id: "turn-1", status: "failed", error: { message: "boom" } }, | |
| 172 | }, | |
| 173 | }), | |
| 174 | ); | |
| 175 | ||
| 176 | expect(next.status).toBe("error"); | |
| 177 | expect(next.activeTurnId).toBeUndefined(); | |
| 178 | expect(next.lastError).toBe("boom"); | |
| 179 | }); | |
| 180 | ||
| 181 | it("moves to error on runtime error events", () => { | |
| 182 | const previous = makeSession(); | |
| 183 | const next = evolveSession( | |
| 184 | previous, | |
| 185 | makeEvent({ | |
| 186 | kind: "error", | |
| 187 | method: "runtime/error", | |
| 188 | createdAt: "2026-02-08T10:04:00.000Z", | |
| 189 | message: "runtime failure", | |
| 190 | }), | |
| 191 | ); | |
| 192 | ||
| 193 | expect(next.status).toBe("error"); | |
| 194 | expect(next.lastError).toBe("runtime failure"); | |
| 195 | }); | |
| 196 | ||
| 197 | it("closes session on close lifecycle events", () => { | |
| 198 | const previous = makeSession({ | |
| 199 | status: "running", | |
| 200 | activeTurnId: "turn-1", | |
| 201 | }); | |
| 202 | const next = evolveSession( | |
| 203 | previous, | |
| 204 | makeEvent({ | |
| 205 | method: "session/closed", | |
| 206 | createdAt: "2026-02-08T10:05:00.000Z", | |
| 207 | message: "closed", | |
| 208 | }), | |
| 209 | ); | |
| 210 | ||
| 211 | expect(next.status).toBe("closed"); | |
| 212 | expect(next.activeTurnId).toBeUndefined(); | |
| 213 | expect(next.lastError).toBe("closed"); | |
| 214 | }); | |
| 215 | }); | |
| 216 | ||
| 217 | describe("applyEventToMessages", () => { | |
| 218 | it("handles start/delta/completed flow for assistant messages", () => { | |
| 219 | const activeAssistantItemRef = { current: null as string | null }; | |
| 220 | const started = applyEventToMessages( | |
| 221 | [], | |
| 222 | makeEvent({ | |
| 223 | method: "item/started", | |
| 224 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 225 | payload: { | |
| 226 | item: { | |
| 227 | id: "item-1", | |
| 228 | type: "agentMessage", | |
| 229 | text: "Hello", | |
| 230 | }, | |
| 231 | }, | |
| 232 | }), | |
| 233 | activeAssistantItemRef, | |
| 234 | ); | |
| 235 | ||
| 236 | expect(started).toEqual([ | |
| 237 | { | |
| 238 | id: "item-1", | |
| 239 | role: "assistant", | |
| 240 | text: "Hello", | |
| 241 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 242 | streaming: true, | |
| 243 | }, | |
| 244 | ]); | |
| 245 | expect(activeAssistantItemRef.current).toBe("item-1"); | |
| 246 | ||
| 247 | const withDelta = applyEventToMessages( | |
| 248 | started, | |
| 249 | makeEvent({ | |
| 250 | method: "item/agentMessage/delta", | |
| 251 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 252 | itemId: "item-1", | |
| 253 | textDelta: " world", | |
| 254 | }), | |
| 255 | activeAssistantItemRef, | |
| 256 | ); | |
| 257 | expect(withDelta[0]?.text).toBe("Hello world"); | |
| 258 | expect(withDelta[0]?.streaming).toBe(true); | |
| 259 | ||
| 260 | const completed = applyEventToMessages( | |
| 261 | withDelta, | |
| 262 | makeEvent({ | |
| 263 | method: "item/completed", | |
| 264 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 265 | payload: { | |
| 266 | item: { | |
| 267 | id: "item-1", | |
| 268 | type: "agentMessage", | |
| 269 | text: "Hello world!", | |
| 270 | }, | |
| 271 | }, | |
| 272 | }), | |
| 273 | activeAssistantItemRef, | |
| 274 | ); | |
| 275 | expect(completed[0]?.text).toBe("Hello world!"); | |
| 276 | expect(completed[0]?.streaming).toBe(false); | |
| 277 | expect(activeAssistantItemRef.current).toBeNull(); | |
| 278 | }); | |
| 279 | ||
| 280 | it("supports out-of-order delta before started/completed", () => { | |
| 281 | const activeAssistantItemRef = { current: null as string | null }; | |
| 282 | const withDelta = applyEventToMessages( | |
| 283 | [], | |
| 284 | makeEvent({ | |
| 285 | method: "item/agentMessage/delta", | |
| 286 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 287 | itemId: "item-2", | |
| 288 | textDelta: "Partial", | |
| 289 | }), | |
| 290 | activeAssistantItemRef, | |
| 291 | ); | |
| 292 | expect(withDelta[0]?.id).toBe("item-2"); | |
| 293 | expect(withDelta[0]?.text).toBe("Partial"); | |
| 294 | expect(withDelta[0]?.streaming).toBe(true); | |
| 295 | expect(activeAssistantItemRef.current).toBe("item-2"); | |
| 296 | ||
| 297 | const completed = applyEventToMessages( | |
| 298 | withDelta, | |
| 299 | makeEvent({ | |
| 300 | method: "item/completed", | |
| 301 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 302 | payload: { | |
| 303 | item: { | |
| 304 | id: "item-2", | |
| 305 | type: "agentMessage", | |
| 306 | text: "Partial + final", | |
| 307 | }, | |
| 308 | }, | |
| 309 | }), | |
| 310 | activeAssistantItemRef, | |
| 311 | ); | |
| 312 | ||
| 313 | expect(completed[0]?.text).toBe("Partial + final"); | |
| 314 | expect(completed[0]?.streaming).toBe(false); | |
| 315 | }); | |
| 316 | ||
| 317 | it("clears all streaming flags when turn completes", () => { | |
| 318 | const previous: ChatMessage[] = [ | |
| 319 | { | |
| 320 | id: "m-user", | |
| 321 | role: "user", | |
| 322 | text: "Hi", | |
| 323 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 324 | streaming: false, | |
| 325 | }, | |
| 326 | { | |
| 327 | id: "m-assistant", | |
| 328 | role: "assistant", | |
| 329 | text: "Typing", | |
| 330 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 331 | streaming: true, | |
| 332 | }, | |
| 333 | ]; | |
| 334 | ||
| 335 | const next = applyEventToMessages( | |
| 336 | previous, | |
| 337 | makeEvent({ | |
| 338 | method: "turn/completed", | |
| 339 | }), | |
| 340 | { current: null }, | |
| 341 | ); | |
| 342 | ||
| 343 | expect(next.every((entry) => entry.streaming === false)).toBe(true); | |
| 344 | }); | |
| 345 | }); |
packages/contracts/src/index.ts+1| ... | ... | @@ -3,3 +3,4 @@ export * from "./ipc"; |
| 3 | 3 | export * from "./agent"; |
| 4 | 4 | export * from "./terminal"; |
| 5 | 5 | export * from "./provider"; |
| 6 | export * from "./model"; |
packages/contracts/src/model.test.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | DEFAULT_MODEL, | |
| 5 | MODEL_OPTIONS, | |
| 6 | normalizeModelSlug, | |
| 7 | resolveModelSlug, | |
| 8 | } from "./model"; | |
| 9 | ||
| 10 | describe("normalizeModelSlug", () => { | |
| 11 | it("maps known aliases to canonical slugs", () => { | |
| 12 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | |
| 13 | expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | |
| 14 | }); | |
| 15 | ||
| 16 | it("returns null for empty or missing values", () => { | |
| 17 | expect(normalizeModelSlug("")).toBeNull(); | |
| 18 | expect(normalizeModelSlug(" ")).toBeNull(); | |
| 19 | expect(normalizeModelSlug(null)).toBeNull(); | |
| 20 | expect(normalizeModelSlug(undefined)).toBeNull(); | |
| 21 | }); | |
| 22 | ||
| 23 | it("preserves non-aliased model slugs", () => { | |
| 24 | expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2"); | |
| 25 | expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | |
| 26 | }); | |
| 27 | }); | |
| 28 | ||
| 29 | describe("resolveModelSlug", () => { | |
| 30 | it("returns default for missing and unknown models", () => { | |
| 31 | expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); | |
| 32 | expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); | |
| 33 | }); | |
| 34 | ||
| 35 | it("resolves only supported model options", () => { | |
| 36 | for (const model of MODEL_OPTIONS) { | |
| 37 | expect(resolveModelSlug(model)).toBe(model); | |
| 38 | } | |
| 39 | }); | |
| 40 | }); |
packages/contracts/src/model.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | export const MODEL_OPTIONS = [ | |
| 2 | "gpt-5.3-codex", | |
| 3 | "gpt-5.2-codex", | |
| 4 | "gpt-5.2", | |
| 5 | ] as const; | |
| 6 | ||
| 7 | export const DEFAULT_MODEL = "gpt-5.3-codex"; | |
| 8 | ||
| 9 | export const MODEL_SLUG_ALIASES: Record<string, string> = { | |
| 10 | "5.3": "gpt-5.3-codex", | |
| 11 | "gpt-5.3": "gpt-5.3-codex", | |
| 12 | }; | |
| 13 | ||
| 14 | export function normalizeModelSlug( | |
| 15 | model: string | null | undefined, | |
| 16 | ): string | null { | |
| 17 | if (typeof model !== "string") { | |
| 18 | return null; | |
| 19 | } | |
| 20 | ||
| 21 | const trimmed = model.trim(); | |
| 22 | if (!trimmed) { | |
| 23 | return null; | |
| 24 | } | |
| 25 | ||
| 26 | return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; | |
| 27 | } | |
| 28 | ||
| 29 | export function resolveModelSlug(model: string | null | undefined): string { | |
| 30 | const normalized = normalizeModelSlug(model); | |
| 31 | if (!normalized) { | |
| 32 | return DEFAULT_MODEL; | |
| 33 | } | |
| 34 | ||
| 35 | return MODEL_OPTIONS.includes(normalized as (typeof MODEL_OPTIONS)[number]) | |
| 36 | ? normalized | |
| 37 | : DEFAULT_MODEL; | |
| 38 | } |