| author | |
| committer | |
| log | 8e744269a2edb2e51d3fa81e576fc29e64d5b186 |
| tree | 356bc6fbc2a5b4c8f5cc415172999cc6ca601263 |
| parent | c90e245038970029f777200201048513da8f84e8 |
Co-authored-by: codex <codex@users.noreply.github.com>6 files changed, 145 insertions(+), 48 deletions(-)
apps/server/src/codexAppServerManager.test.ts+34| ... | ... | @@ -143,6 +143,40 @@ describe("isRecoverableThreadResumeError", () => { |
| 143 | 143 | }); |
| 144 | 144 | }); |
| 145 | 145 | |
| 146 | describe("startSession", () => { | |
| 147 | it("emits session/startFailed when resolving cwd throws before process launch", async () => { | |
| 148 | const manager = new CodexAppServerManager(); | |
| 149 | const events: Array<{ method: string; kind: string; message?: string }> = []; | |
| 150 | manager.on("event", (event) => { | |
| 151 | events.push({ | |
| 152 | method: event.method, | |
| 153 | kind: event.kind, | |
| 154 | ...(event.message ? { message: event.message } : {}), | |
| 155 | }); | |
| 156 | }); | |
| 157 | ||
| 158 | const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { | |
| 159 | throw new Error("cwd missing"); | |
| 160 | }); | |
| 161 | try { | |
| 162 | await expect( | |
| 163 | manager.startSession({ | |
| 164 | provider: "codex", | |
| 165 | }), | |
| 166 | ).rejects.toThrow("cwd missing"); | |
| 167 | expect(events).toHaveLength(1); | |
| 168 | expect(events[0]).toEqual({ | |
| 169 | method: "session/startFailed", | |
| 170 | kind: "error", | |
| 171 | message: "cwd missing", | |
| 172 | }); | |
| 173 | } finally { | |
| 174 | processCwd.mockRestore(); | |
| 175 | manager.stopAll(); | |
| 176 | } | |
| 177 | }); | |
| 178 | }); | |
| 179 | ||
| 146 | 180 | describe("sendTurn", () => { |
| 147 | 181 | it("sends text and image user input items to turn/start", async () => { |
| 148 | 182 | const { manager, context, requireSession, sendRequest, updateSession } = |
apps/server/src/codexAppServerManager.ts+50-36| ... | ... | @@ -148,41 +148,43 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 148 | 148 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { |
| 149 | 149 | const sessionId = randomUUID(); |
| 150 | 150 | const now = new Date().toISOString(); |
| 151 | const resolvedCwd = input.cwd ?? process.cwd(); | |
| 151 | let context: CodexSessionContext | undefined; | |
| 152 | 152 | |
| 153 | const session: ProviderSession = { | |
| 154 | sessionId, | |
| 155 | provider: "codex", | |
| 156 | status: "connecting", | |
| 157 | model: normalizeCodexModelSlug(input.model), | |
| 158 | cwd: resolvedCwd, | |
| 159 | createdAt: now, | |
| 160 | updatedAt: now, | |
| 161 | }; | |
| 153 | try { | |
| 154 | const resolvedCwd = input.cwd ?? process.cwd(); | |
| 162 | 155 | |
| 163 | const child = spawn("codex", ["app-server"], { | |
| 164 | cwd: resolvedCwd, | |
| 165 | env: process.env, | |
| 166 | stdio: ["pipe", "pipe", "pipe"], | |
| 167 | }); | |
| 168 | const output = readline.createInterface({ input: child.stdout }); | |
| 169 | ||
| 170 | const context: CodexSessionContext = { | |
| 171 | session, | |
| 172 | child, | |
| 173 | output, | |
| 174 | pending: new Map(), | |
| 175 | pendingApprovals: new Map(), | |
| 176 | nextRequestId: 1, | |
| 177 | stopping: false, | |
| 178 | }; | |
| 156 | const session: ProviderSession = { | |
| 157 | sessionId, | |
| 158 | provider: "codex", | |
| 159 | status: "connecting", | |
| 160 | model: normalizeCodexModelSlug(input.model), | |
| 161 | cwd: resolvedCwd, | |
| 162 | createdAt: now, | |
| 163 | updatedAt: now, | |
| 164 | }; | |
| 179 | 165 | |
| 180 | this.sessions.set(sessionId, context); | |
| 181 | this.attachProcessListeners(context); | |
| 166 | const child = spawn("codex", ["app-server"], { | |
| 167 | cwd: resolvedCwd, | |
| 168 | env: process.env, | |
| 169 | stdio: ["pipe", "pipe", "pipe"], | |
| 170 | }); | |
| 171 | const output = readline.createInterface({ input: child.stdout }); | |
| 172 | ||
| 173 | context = { | |
| 174 | session, | |
| 175 | child, | |
| 176 | output, | |
| 177 | pending: new Map(), | |
| 178 | pendingApprovals: new Map(), | |
| 179 | nextRequestId: 1, | |
| 180 | stopping: false, | |
| 181 | }; | |
| 182 | 182 | |
| 183 | this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); | |
| 183 | this.sessions.set(sessionId, context); | |
| 184 | this.attachProcessListeners(context); | |
| 185 | ||
| 186 | this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); | |
| 184 | 187 | |
| 185 | try { | |
| 186 | 188 | await this.sendRequest(context, "initialize", { |
| 187 | 189 | clientInfo: { |
| 188 | 190 | name: "t3code_desktop", |
| ... | ... | @@ -252,12 +254,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 252 | 254 | return { ...context.session }; |
| 253 | 255 | } catch (error) { |
| 254 | 256 | const message = error instanceof Error ? error.message : "Failed to start Codex session."; |
| 255 | this.updateSession(context, { | |
| 256 | status: "error", | |
| 257 | lastError: message, | |
| 258 | }); | |
| 259 | this.emitErrorEvent(context, "session/startFailed", message); | |
| 260 | this.stopSession(sessionId); | |
| 257 | if (context) { | |
| 258 | this.updateSession(context, { | |
| 259 | status: "error", | |
| 260 | lastError: message, | |
| 261 | }); | |
| 262 | this.emitErrorEvent(context, "session/startFailed", message); | |
| 263 | this.stopSession(sessionId); | |
| 264 | } else { | |
| 265 | this.emitEvent({ | |
| 266 | id: randomUUID(), | |
| 267 | kind: "error", | |
| 268 | provider: "codex", | |
| 269 | sessionId, | |
| 270 | createdAt: new Date().toISOString(), | |
| 271 | method: "session/startFailed", | |
| 272 | message, | |
| 273 | }); | |
| 274 | } | |
| 261 | 275 | throw new Error(message, { cause: error }); |
| 262 | 276 | } |
| 263 | 277 | } |
apps/server/src/filesystemCheckpointStore.test.ts+7| ... | ... | @@ -204,4 +204,11 @@ describe("FilesystemCheckpointStore", () => { |
| 204 | 204 | await rm(cwd, { recursive: true, force: true }); |
| 205 | 205 | } |
| 206 | 206 | }); |
| 207 | ||
| 208 | it("returns false when repository path does not exist", async () => { | |
| 209 | const store = new FilesystemCheckpointStore(); | |
| 210 | const cwd = path.join(os.tmpdir(), `t3-fs-checkpoints-missing-${Date.now()}`); | |
| 211 | ||
| 212 | expect(await store.isGitRepository(cwd)).toBe(false); | |
| 213 | }); | |
| 207 | 214 | }); |
apps/server/src/filesystemCheckpointStore.ts+8-4| ... | ... | @@ -32,10 +32,14 @@ interface GitOptions { |
| 32 | 32 | |
| 33 | 33 | export class FilesystemCheckpointStore { |
| 34 | 34 | async isGitRepository(cwd: string): Promise<boolean> { |
| 35 | const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], { | |
| 36 | allowNonZeroExit: true, | |
| 37 | }); | |
| 38 | return result.code === 0 && result.stdout.trim() === "true"; | |
| 35 | try { | |
| 36 | const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], { | |
| 37 | allowNonZeroExit: true, | |
| 38 | }); | |
| 39 | return result.code === 0 && result.stdout.trim() === "true"; | |
| 40 | } catch { | |
| 41 | return false; | |
| 42 | } | |
| 39 | 43 | } |
| 40 | 44 | |
| 41 | 45 | async captureCheckpoint(input: { |
apps/web/src/store.test.ts+16-8| ... | ... | @@ -618,10 +618,10 @@ describe("store reducer thread continuity", () => { |
| 618 | 618 | ], |
| 619 | 619 | activeThreadId: "thread-b", |
| 620 | 620 | runtimeMode: "full-access", |
| 621 | diffOpen: false, | |
| 622 | diffThreadId: null, | |
| 623 | diffTurnId: null, | |
| 624 | diffFilePath: null, | |
| 621 | diffOpen: true, | |
| 622 | diffThreadId: "thread-b", | |
| 623 | diffTurnId: "turn-b", | |
| 624 | diffFilePath: "src/removed.ts", | |
| 625 | 625 | }; |
| 626 | 626 | |
| 627 | 627 | const next = reducer(state, { |
| ... | ... | @@ -646,6 +646,10 @@ describe("store reducer thread continuity", () => { |
| 646 | 646 | expect(next.threads[0]?.id).toBe("thread-a"); |
| 647 | 647 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 648 | 648 | expect(next.activeThreadId).toBe("thread-a"); |
| 649 | expect(next.diffOpen).toBe(false); | |
| 650 | expect(next.diffThreadId).toBeNull(); | |
| 651 | expect(next.diffTurnId).toBeNull(); | |
| 652 | expect(next.diffFilePath).toBeNull(); | |
| 649 | 653 | }); |
| 650 | 654 | |
| 651 | 655 | it("treats empty scripts from sync as authoritative", () => { |
| ... | ... | @@ -753,10 +757,10 @@ describe("store reducer thread continuity", () => { |
| 753 | 757 | ], |
| 754 | 758 | activeThreadId: "thread-a", |
| 755 | 759 | runtimeMode: "full-access", |
| 756 | diffOpen: false, | |
| 757 | diffThreadId: null, | |
| 758 | diffTurnId: null, | |
| 759 | diffFilePath: null, | |
| 760 | diffOpen: true, | |
| 761 | diffThreadId: "thread-a", | |
| 762 | diffTurnId: "turn-a", | |
| 763 | diffFilePath: "src/a.ts", | |
| 760 | 764 | }; |
| 761 | 765 | |
| 762 | 766 | const next = reducer(state, { |
| ... | ... | @@ -769,6 +773,10 @@ describe("store reducer thread continuity", () => { |
| 769 | 773 | expect(next.threads).toHaveLength(1); |
| 770 | 774 | expect(next.threads[0]?.id).toBe("thread-b"); |
| 771 | 775 | expect(next.activeThreadId).toBe("thread-b"); |
| 776 | expect(next.diffOpen).toBe(false); | |
| 777 | expect(next.diffThreadId).toBeNull(); | |
| 778 | expect(next.diffTurnId).toBeNull(); | |
| 779 | expect(next.diffFilePath).toBeNull(); | |
| 772 | 780 | }); |
| 773 | 781 | |
| 774 | 782 | it("marks the active thread as visited when selected", () => { |
apps/web/src/store.ts+30| ... | ... | @@ -198,6 +198,32 @@ function updateThread( |
| 198 | 198 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 199 | 199 | } |
| 200 | 200 | |
| 201 | function resetDiffTargetIfMissing(state: AppState, threads: Thread[]) { | |
| 202 | if (!state.diffThreadId) { | |
| 203 | return { | |
| 204 | diffOpen: state.diffOpen, | |
| 205 | diffThreadId: state.diffThreadId, | |
| 206 | diffTurnId: state.diffTurnId, | |
| 207 | diffFilePath: state.diffFilePath, | |
| 208 | }; | |
| 209 | } | |
| 210 | const hasThread = threads.some((thread) => thread.id === state.diffThreadId); | |
| 211 | if (hasThread) { | |
| 212 | return { | |
| 213 | diffOpen: state.diffOpen, | |
| 214 | diffThreadId: state.diffThreadId, | |
| 215 | diffTurnId: state.diffTurnId, | |
| 216 | diffFilePath: state.diffFilePath, | |
| 217 | }; | |
| 218 | } | |
| 219 | return { | |
| 220 | diffOpen: false, | |
| 221 | diffThreadId: null, | |
| 222 | diffTurnId: null, | |
| 223 | diffFilePath: null, | |
| 224 | }; | |
| 225 | } | |
| 226 | ||
| 201 | 227 | function mergeTurnDiffSummaries( |
| 202 | 228 | existing: Thread["turnDiffSummaries"], |
| 203 | 229 | next: Thread["turnDiffSummaries"], |
| ... | ... | @@ -602,12 +628,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 602 | 628 | const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) |
| 603 | 629 | ? state.activeThreadId |
| 604 | 630 | : (nextThreads[0]?.id ?? null); |
| 631 | const diffState = resetDiffTargetIfMissing(state, nextThreads); | |
| 605 | 632 | |
| 606 | 633 | return { |
| 607 | 634 | ...state, |
| 608 | 635 | projects: nextProjects, |
| 609 | 636 | threads: nextThreads, |
| 610 | 637 | activeThreadId, |
| 638 | ...diffState, | |
| 611 | 639 | }; |
| 612 | 640 | } |
| 613 | 641 | |
| ... | ... | @@ -629,12 +657,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 629 | 657 | const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId) |
| 630 | 658 | ? state.activeThreadId |
| 631 | 659 | : (threads[0]?.id ?? null); |
| 660 | const diffState = resetDiffTargetIfMissing(state, threads); | |
| 632 | 661 | |
| 633 | 662 | return { |
| 634 | 663 | ...state, |
| 635 | 664 | projects, |
| 636 | 665 | threads, |
| 637 | 666 | activeThreadId, |
| 667 | ...diffState, | |
| 638 | 668 | }; |
| 639 | 669 | } |
| 640 | 670 |