| 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,11 +1,9 @@ |
| 1 | - [x] Folder picker for new projects | 1 | ## TODO |
| 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 | ||
| 7 | 2 | ||
| 8 | ## Bigger overhauls | 3 | - [ ] Fix sort order for threads (newest first) |
| 9 | 4 | - [ ] Indicate in thread list which threads are currently working | |
| 10 | - [x] Markdown rendering | 5 | - [ ] Logs should be per-thread, not a single events.txt |
| 11 | - [x] Code block rendering | 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,6 +10,7 @@ import type { |
| 10 | ProviderSessionStartInput, | 10 | ProviderSessionStartInput, |
| 11 | ProviderTurnStartResult, | 11 | ProviderTurnStartResult, |
| 12 | } from "@acme/contracts"; | 12 | } from "@acme/contracts"; |
| 13 | import { normalizeModelSlug } from "@acme/contracts"; | ||
| 13 | 14 | ||
| 14 | type PendingRequestKey = string; | 15 | type PendingRequestKey = string; |
| 15 | 16 | ||
| ... | @@ -59,29 +60,20 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ | ... | @@ -59,29 +60,20 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ |
| 59 | "state db missing rollout path for thread", | 60 | "state db missing rollout path for thread", |
| 60 | "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", | 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 | export function normalizeCodexModelSlug( | 63 | export function normalizeCodexModelSlug( |
| 68 | model: string | undefined | null, | 64 | model: string | undefined | null, |
| 69 | preferredId?: string, | 65 | preferredId?: string, |
| 70 | ): string | undefined { | 66 | ): string | undefined { |
| 71 | if (typeof model !== "string") { | 67 | const normalized = normalizeModelSlug(model); |
| 72 | return undefined; | 68 | if (!normalized) { |
| 73 | } | ||
| 74 | |||
| 75 | const trimmed = model.trim(); | ||
| 76 | if (!trimmed) { | ||
| 77 | return undefined; | 69 | return undefined; |
| 78 | } | 70 | } |
| 79 | 71 | ||
| 80 | if (preferredId?.endsWith("-codex") && preferredId !== trimmed) { | 72 | if (preferredId?.endsWith("-codex") && preferredId !== normalized) { |
| 81 | return preferredId; | 73 | return preferredId; |
| 82 | } | 74 | } |
| 83 | 75 | ||
| 84 | return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; | 76 | return normalized; |
| 85 | } | 77 | } |
| 86 | 78 | ||
| 87 | export function classifyCodexStderrLine( | 79 | export function classifyCodexStderrLine( |
apps/desktop/src/main.ts+1| ... | @@ -314,6 +314,7 @@ async function bootstrap(): Promise<void> { | ... | @@ -314,6 +314,7 @@ async function bootstrap(): Promise<void> { |
| 314 | app.on("before-quit", () => { | 314 | app.on("before-quit", () => { |
| 315 | processManager.killAll(); | 315 | processManager.killAll(); |
| 316 | providerManager.stopAll(); | 316 | providerManager.stopAll(); |
| 317 | providerManager.dispose(); | ||
| 317 | }); | 318 | }); |
| 318 | 319 | ||
| 319 | app.whenReady().then(() => { | 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,6 +24,15 @@ export interface ProviderManagerEvents { |
| 24 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | 24 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 25 | private readonly codex = new CodexAppServerManager(); | 25 | private readonly codex = new CodexAppServerManager(); |
| 26 | private readonly logStream: fs.WriteStream; | 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 | constructor() { | 37 | constructor() { |
| 29 | super(); | 38 | super(); |
| ... | @@ -34,10 +43,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -34,10 +43,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 34 | flags: "a", | 43 | flags: "a", |
| 35 | }); | 44 | }); |
| 36 | 45 | ||
| 37 | this.codex.on("event", (event) => { | 46 | this.codex.on("event", this.onCodexEvent); |
| 38 | this.logStream.write(`${JSON.stringify(event)}\n`); | ||
| 39 | this.emit("event", event); | ||
| 40 | }); | ||
| 41 | } | 47 | } |
| 42 | 48 | ||
| 43 | async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> { | 49 | async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> { |
| ... | @@ -79,4 +85,14 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -79,4 +85,14 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 79 | stopAll(): void { | 85 | stopAll(): void { |
| 80 | this.codex.stopAll(); | 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,41 +1,9 @@ |
| 1 | export const MODEL_OPTIONS = [ | 1 | export { |
| 2 | "gpt-5.3-codex", | 2 | DEFAULT_MODEL, |
| 3 | "gpt-5.2-codex", | 3 | MODEL_OPTIONS, |
| 4 | "gpt-5.2", | 4 | normalizeModelSlug, |
| 5 | ] as const; | 5 | resolveModelSlug, |
| 6 | 6 | } from "@acme/contracts"; | |
| 7 | export const DEFAULT_MODEL = "gpt-5.3-codex"; | ||
| 8 | 7 | ||
| 9 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | 8 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; |
| 10 | export const DEFAULT_REASONING = "high"; | 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,8 +1,39 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { WorkLogEntry } from "./session-logic"; | 2 | |
| 3 | import { deriveTimelineEntries } from "./session-logic"; | 3 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 4 | import { | ||
| 5 | type WorkLogEntry, | ||
| 6 | applyEventToMessages, | ||
| 7 | deriveTimelineEntries, | ||
| 8 | evolveSession, | ||
| 9 | } from "./session-logic"; | ||
| 4 | import type { ChatMessage } from "./types"; | 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 | describe("deriveTimelineEntries", () => { | 37 | describe("deriveTimelineEntries", () => { |
| 7 | it("interleaves messages and work entries by timestamp", () => { | 38 | it("interleaves messages and work entries by timestamp", () => { |
| 8 | const messages: ChatMessage[] = [ | 39 | const messages: ChatMessage[] = [ |
| ... | @@ -73,3 +104,242 @@ describe("deriveTimelineEntries", () => { | ... | @@ -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 +3,4 @@ export * from "./ipc"; |
| 3 | export * from "./agent"; | 3 | export * from "./agent"; |
| 4 | export * from "./terminal"; | 4 | export * from "./terminal"; |
| 5 | export * from "./provider"; | 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 | } | ||