diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index 4dcac342..ddb8d7a9 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -270,6 +270,7 @@ export interface ChatRunEnv { /** Reads recorded cursor telemetry for an asset. Built in `electron/ipc/ * handlers.ts`, where the path allow-list lives. */ cursor?: CursorTelemetryReader; + codex?: import("./codex-app-server-client").CodexAppServerClient; } // ponytail: zero-config noop for sink callbacks that the caller did not provide. @@ -395,23 +396,48 @@ export async function runChat( error: (message: string) => emit.error(message), }; - const { invokeOpenScreenAgent } = await import("./deep-agent/service"); - - const result = await invokeOpenScreenAgent({ - document: workingDocument ?? emptyDocumentForTextOnly(projectId), - model: { - provider: config.provider, - model: config.model, - apiKey: apiKey ?? undefined, - baseUrl: config.baseUrl, - reasoningEffort: config.reasoningEffort, - }, - history, - userMessage: message, - sink: agentSink, - editsAllowed, - cursor: env.cursor, - }); + const result = + config.provider === "codex" + ? await (async () => { + if (!env.codex) { + return { + text: "", + document: workingDocument ?? emptyDocumentForTextOnly(projectId), + mutated: false, + reason: "Codex app-server is unavailable in this runtime.", + }; + } + const { invokeCodexOpenScreenAgent } = await import("./codex-chat-service"); + return invokeCodexOpenScreenAgent({ + document: workingDocument ?? emptyDocumentForTextOnly(projectId), + client: env.codex, + model: config.model, + reasoningEffort: config.reasoningEffort, + history, + userMessage: message, + sink: agentSink, + editsAllowed, + cursor: env.cursor, + }); + })() + : await (async () => { + const { invokeOpenScreenAgent } = await import("./deep-agent/service"); + return invokeOpenScreenAgent({ + document: workingDocument ?? emptyDocumentForTextOnly(projectId), + model: { + provider: config.provider, + model: config.model, + apiKey: apiKey ?? undefined, + baseUrl: config.baseUrl, + reasoningEffort: config.reasoningEffort, + }, + history, + userMessage: message, + sink: agentSink, + editsAllowed, + cursor: env.cursor, + }); + })(); if (!result.text) { // ponytail: surface the deep-agent's diagnostic so the user can see diff --git a/electron/ai-edition/codex-app-server-client.test.ts b/electron/ai-edition/codex-app-server-client.test.ts new file mode 100644 index 00000000..dd4a9fa9 --- /dev/null +++ b/electron/ai-edition/codex-app-server-client.test.ts @@ -0,0 +1,272 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { + CodexAppServerClient, + type CodexAppServerProcess, + resolveCodexExecutable, +} from "./codex-app-server-client"; + +class FakeCodexProcess extends EventEmitter implements CodexAppServerProcess { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly requests: Array> = []; + killed = false; + + constructor( + private readonly respond: (request: Record) => Record | null, + ) { + super(); + let buffered = ""; + this.stdin.on("data", (chunk: Buffer) => { + buffered += chunk.toString("utf8"); + for (;;) { + const newline = buffered.indexOf("\n"); + if (newline === -1) break; + const line = buffered.slice(0, newline).trim(); + buffered = buffered.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line) as Record; + this.requests.push(request); + const response = this.respond(request); + if (response) this.stdout.write(`${JSON.stringify(response)}\n`); + } + }); + } + + kill(): boolean { + this.killed = true; + this.emit("exit", 0, null); + return true; + } +} + +function resultFor( + request: Record, + result: unknown, +): Record | null { + if (!("id" in request)) return null; + return { jsonrpc: "2.0", id: request.id, result }; +} + +describe("CodexAppServerClient", () => { + it("honors an explicit Codex executable path for packaged app environments", () => { + const previous = process.env.OPENSCREEN_CODEX_PATH; + process.env.OPENSCREEN_CODEX_PATH = "/Applications/Codex/bin/codex"; + try { + expect(resolveCodexExecutable()).toBe("/Applications/Codex/bin/codex"); + } finally { + if (previous === undefined) delete process.env.OPENSCREEN_CODEX_PATH; + else process.env.OPENSCREEN_CODEX_PATH = previous; + } + }); + + it("initializes honestly as openscreen and reads a ChatGPT account", async () => { + const process = new FakeCodexProcess((request) => { + if (request.method === "initialize") { + return resultFor(request, { + userAgent: "codex_cli_rs/0.149.1", + codexHome: "/tmp/codex", + platformFamily: "unix", + platformOs: "macos", + }); + } + if (request.method === "account/read") { + return resultFor(request, { + account: { type: "chatgpt", email: "user@example.com", planType: "plus" }, + requiresOpenaiAuth: true, + }); + } + return null; + }); + const client = new CodexAppServerClient({ + spawnProcess: async () => process, + requestTimeoutMs: 1_000, + }); + + await expect(client.readAccount()).resolves.toEqual({ + available: true, + connected: true, + account: { email: "user@example.com", planType: "plus" }, + }); + const initialize = process.requests.find((request) => request.method === "initialize"); + expect(initialize).toMatchObject({ + params: { + clientInfo: { name: "openscreen", title: "OpenScreen", version: "1.10.0" }, + capabilities: { experimentalApi: true, requestAttestation: false }, + }, + }); + expect(process.requests).toContainEqual({ jsonrpc: "2.0", method: "initialized" }); + }); + + it("starts browser login and resolves only after the matching completion notification", async () => { + const process = new FakeCodexProcess((request) => { + if (request.method === "initialize") return resultFor(request, {}); + if (request.method === "account/login/start") { + return resultFor(request, { + type: "chatgpt", + loginId: "login-1", + authUrl: "https://auth.openai.com/oauth/authorize?client=codex", + }); + } + return null; + }); + const client = new CodexAppServerClient({ + spawnProcess: async () => process, + requestTimeoutMs: 1_000, + }); + + const login = await client.startLogin(); + expect(login).toEqual({ + loginId: "login-1", + authUrl: "https://auth.openai.com/oauth/authorize?client=codex", + }); + const completion = client.waitForLogin("login-1", 1_000); + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + method: "account/login/completed", + params: { loginId: "login-1", success: true, error: null }, + })}\n`, + ); + await expect(completion).resolves.toEqual({ success: true, error: null }); + }); + + it("rejects a non-HTTPS authentication URL", async () => { + const process = new FakeCodexProcess((request) => { + if (request.method === "initialize") return resultFor(request, {}); + if (request.method === "account/login/start") { + return resultFor(request, { + type: "chatgpt", + loginId: "login-1", + authUrl: "file:///tmp/fake-login.html", + }); + } + return null; + }); + const client = new CodexAppServerClient({ + spawnProcess: async () => process, + requestTimeoutMs: 1_000, + }); + + await expect(client.startLogin()).rejects.toThrow("HTTPS"); + }); + + it("lists visible Codex models and terminates its child process", async () => { + const process = new FakeCodexProcess((request) => { + if (request.method === "initialize") return resultFor(request, {}); + if (request.method === "model/list") { + return resultFor(request, { + data: [ + { id: "gpt-5.6-sol", model: "gpt-5.6-sol", hidden: false }, + { id: "hidden", model: "hidden", hidden: true }, + ], + nextCursor: null, + }); + } + return null; + }); + const client = new CodexAppServerClient({ + spawnProcess: async () => process, + requestTimeoutMs: 1_000, + }); + + await expect(client.listModels()).resolves.toEqual(["gpt-5.6-sol"]); + client.close(); + expect(process.killed).toBe(true); + }); + + it("round-trips a dynamic tool call during a Codex turn", async () => { + let process: FakeCodexProcess; + process = new FakeCodexProcess((request) => { + if (request.method === "initialize") return resultFor(request, {}); + if (request.method === "thread/start") { + return resultFor(request, { thread: { id: "thread-1" } }); + } + if (request.method === "turn/start") { + queueMicrotask(() => { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: "tool-call-1", + method: "item/tool/call", + params: { + threadId: "thread-1", + turnId: "turn-1", + callId: "call-1", + namespace: null, + tool: "addTrim", + arguments: { startSec: 1, endSec: 2 }, + }, + })}\n`, + ); + }); + return resultFor(request, { turn: { id: "turn-1" } }); + } + if (request.id === "tool-call-1" && isToolResponse(request)) { + queueMicrotask(() => { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "message-1", + delta: "Trimmed.", + }, + })}\n`, + ); + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { id: "turn-1", status: "completed", items: [] }, + }, + })}\n`, + ); + }); + } + return null; + }); + const client = new CodexAppServerClient({ + spawnProcess: async () => process, + requestTimeoutMs: 1_000, + }); + + const calls: Array<{ name: string; args: unknown }> = []; + const result = await client.runTurn({ + model: "gpt-5.6-sol", + systemPrompt: "Edit the video.", + message: "Remove the pause.", + tools: [ + { + name: "addTrim", + description: "Add a trim.", + inputSchema: { type: "object" }, + }, + ], + onToolCall: async (name, args) => { + calls.push({ name, args }); + return { success: true, resultText: '{"ok":true}' }; + }, + }); + + expect(calls).toEqual([{ name: "addTrim", args: { startSec: 1, endSec: 2 } }]); + expect(result.text).toBe("Trimmed."); + const toolResponse = process.requests.find((request) => request.id === "tool-call-1"); + expect(toolResponse).toMatchObject({ + result: { + success: true, + contentItems: [{ type: "inputText", text: '{"ok":true}' }], + }, + }); + }); +}); + +function isToolResponse(request: Record): boolean { + return "result" in request && !("method" in request); +} diff --git a/electron/ai-edition/codex-app-server-client.ts b/electron/ai-edition/codex-app-server-client.ts new file mode 100644 index 00000000..a1c94472 --- /dev/null +++ b/electron/ai-edition/codex-app-server-client.ts @@ -0,0 +1,633 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import type { Readable, Writable } from "node:stream"; + +export interface CodexAppServerProcess { + stdin: Writable; + stdout: Readable; + stderr: Readable; + on(event: "error", listener: (error: Error) => void): this; + on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + on(event: "spawn", listener: () => void): this; + once(event: "error", listener: (error: Error) => void): this; + once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + once(event: "spawn", listener: () => void): this; + kill(signal?: NodeJS.Signals): boolean; +} + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: unknown; +} + +interface JsonRpcResponse { + jsonrpc?: string; + id: number | string; + result?: unknown; + error?: { code?: number; message?: string; data?: unknown }; +} + +interface JsonRpcNotification { + jsonrpc?: string; + method: string; + params?: unknown; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +export interface CodexAccountStatus { + available: boolean; + connected: boolean; + account?: { email: string | null; planType: string }; + error?: string; +} + +export interface CodexLoginStart { + loginId: string; + authUrl: string; +} + +export interface CodexLoginCompletion { + success: boolean; + error: string | null; +} + +export interface CodexAppServerClientOptions { + spawnProcess?: () => Promise; + requestTimeoutMs?: number; + clientVersion?: string; +} + +export interface CodexDynamicTool { + name: string; + description: string; + inputSchema: Record; +} + +export interface CodexDynamicToolResult { + success: boolean; + resultText: string; +} + +export interface CodexRunTurnOptions { + model: string; + effort?: string; + systemPrompt: string; + message: string; + tools: CodexDynamicTool[]; + onToolCall: (name: string, args: unknown) => Promise; + onText?: (delta: string) => void; + onThinking?: (delta: string) => void; + turnTimeoutMs?: number; +} + +export interface CodexRunTurnResult { + text: string; + threadId: string; + turnId: string; +} + +interface ActiveTurn { + turnId: string | null; + text: string; + onToolCall: CodexRunTurnOptions["onToolCall"]; + onText: (delta: string) => void; + onThinking: (delta: string) => void; + resolve: (value: CodexRunTurnResult) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +export function resolveCodexExecutable(): string { + const override = process.env.OPENSCREEN_CODEX_PATH?.trim(); + if (override) return override; + const executable = process.platform === "win32" ? "codex.exe" : "codex"; + const pathCandidates = (process.env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean) + .map((directory) => path.join(directory, executable)); + const home = os.homedir(); + const candidates = [ + ...pathCandidates, + path.join(home, ".local", "bin", executable), + path.join(home, ".codex", "bin", executable), + ...(process.platform === "darwin" ? ["/opt/homebrew/bin/codex", "/usr/local/bin/codex"] : []), + ...(process.platform === "win32" && process.env.APPDATA + ? [path.join(process.env.APPDATA, "npm", "codex.cmd")] + : []), + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? "codex"; +} + +function spawnDefaultCodexProcess(): Promise { + return new Promise((resolve, reject) => { + const command = resolveCodexExecutable(); + const child = spawn(command, ["app-server", "--stdio"], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const fail = (error: Error) => reject(error); + child.once("error", fail); + child.once("spawn", () => { + child.removeListener("error", fail); + resolve(child); + }); + }); +} + +function messageFromUnknown(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertHttpsUrl(value: unknown): string { + if (typeof value !== "string") throw new Error("Codex did not return an authentication URL."); + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error("Codex returned an invalid authentication URL."); + } + if (parsed.protocol !== "https:") { + throw new Error("Codex authentication URL must use HTTPS."); + } + return parsed.toString(); +} + +/** + * Thin JSON-RPC client for the official `codex app-server --stdio` process. + * + * OpenScreen never reads Codex's auth files or tokens. Authentication state, + * refresh, and browser login stay owned by the Codex process. + */ +export class CodexAppServerClient { + private readonly spawnProcess: () => Promise; + private readonly requestTimeoutMs: number; + private readonly clientVersion: string; + private process: CodexAppServerProcess | null = null; + private starting: Promise | null = null; + private nextRequestId = 1; + private readonly pending = new Map(); + private readonly loginCompletions = new Map(); + private readonly loginWaiters = new Map< + string, + Array<(completion: CodexLoginCompletion) => void> + >(); + private readonly activeTurns = new Map(); + private stderrTail = ""; + + constructor(options: CodexAppServerClientOptions = {}) { + this.spawnProcess = options.spawnProcess ?? spawnDefaultCodexProcess; + this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000; + this.clientVersion = options.clientVersion ?? "1.10.0"; + } + + async readAccount(): Promise { + try { + const response = await this.request("account/read", { refreshToken: false }); + if (!isRecord(response)) throw new Error("Codex returned an invalid account response."); + const account = response.account; + if (!isRecord(account) || account.type !== "chatgpt") { + return { available: true, connected: false }; + } + return { + available: true, + connected: true, + account: { + email: typeof account.email === "string" ? account.email : null, + planType: typeof account.planType === "string" ? account.planType : "unknown", + }, + }; + } catch (error) { + return { + available: false, + connected: false, + error: messageFromUnknown(error), + }; + } + } + + async startLogin(): Promise { + const response = await this.request("account/login/start", { + type: "chatgpt", + codexStreamlinedLogin: true, + useHostedLoginSuccessPage: true, + appBrand: "codex", + }); + if (!isRecord(response) || response.type !== "chatgpt") { + throw new Error("Codex did not start ChatGPT login."); + } + if (typeof response.loginId !== "string" || !response.loginId) { + throw new Error("Codex did not return a login id."); + } + return { + loginId: response.loginId, + authUrl: assertHttpsUrl(response.authUrl), + }; + } + + waitForLogin(loginId: string, timeoutMs = 5 * 60_000): Promise { + const completed = this.loginCompletions.get(loginId); + if (completed) return Promise.resolve(completed); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const waiters = this.loginWaiters.get(loginId) ?? []; + this.loginWaiters.set( + loginId, + waiters.filter((waiter) => waiter !== finish), + ); + reject(new Error("Timed out waiting for Codex login.")); + }, timeoutMs); + const finish = (completion: CodexLoginCompletion) => { + clearTimeout(timer); + resolve(completion); + }; + const waiters = this.loginWaiters.get(loginId) ?? []; + waiters.push(finish); + this.loginWaiters.set(loginId, waiters); + }); + } + + async listModels(): Promise { + const models: string[] = []; + let cursor: string | null = null; + do { + const response = await this.request("model/list", { + cursor, + limit: 100, + includeHidden: false, + }); + if (!isRecord(response) || !Array.isArray(response.data)) { + throw new Error("Codex returned an invalid model list."); + } + for (const item of response.data) { + if (!isRecord(item) || item.hidden === true) continue; + const model = + typeof item.model === "string" + ? item.model + : typeof item.id === "string" + ? item.id + : null; + if (model) models.push(model); + } + cursor = typeof response.nextCursor === "string" ? response.nextCursor : null; + } while (cursor); + return [...new Set(models)]; + } + + async runTurn(options: CodexRunTurnOptions): Promise { + await this.ensureStarted(); + const threadResponse = await this.requestWithoutStart("thread/start", { + model: options.model || null, + cwd: process.cwd(), + approvalPolicy: "never", + sandbox: "read-only", + baseInstructions: options.systemPrompt, + developerInstructions: + "You are embedded in OpenScreen. Use only the dynamic video-editing tools provided by the host. Do not use shell, filesystem, network, MCP, apps, or collaboration tools.", + ephemeral: true, + config: { tools: { web_search: false } }, + dynamicTools: options.tools.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }); + if (!isRecord(threadResponse) || !isRecord(threadResponse.thread)) { + throw new Error("Codex did not create a thread."); + } + const threadId = threadResponse.thread.id; + if (typeof threadId !== "string" || !threadId) { + throw new Error("Codex returned an invalid thread id."); + } + + const completion = new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + const turnId = this.activeTurns.get(threadId)?.turnId ?? undefined; + this.activeTurns.delete(threadId); + void this.requestWithoutStart("turn/interrupt", { + threadId, + turnId, + }).catch(() => undefined); + reject(new Error("Timed out waiting for Codex turn.")); + }, + options.turnTimeoutMs ?? 10 * 60_000, + ); + this.activeTurns.set(threadId, { + turnId: null, + text: "", + onToolCall: options.onToolCall, + onText: options.onText ?? (() => undefined), + onThinking: options.onThinking ?? (() => undefined), + resolve, + reject, + timer, + }); + }); + + try { + const turnResponse = await this.requestWithoutStart("turn/start", { + threadId, + input: [{ type: "text", text: options.message, text_elements: [] }], + effort: options.effort || null, + }); + if (isRecord(turnResponse) && isRecord(turnResponse.turn)) { + const active = this.activeTurns.get(threadId); + if (active && typeof turnResponse.turn.id === "string") { + active.turnId = turnResponse.turn.id; + } + } + return await completion; + } catch (error) { + const active = this.activeTurns.get(threadId); + if (active) clearTimeout(active.timer); + this.activeTurns.delete(threadId); + throw error; + } + } + + close(): void { + const child = this.process; + this.process = null; + this.starting = null; + this.rejectPending(new Error("Codex app-server was closed.")); + this.rejectActiveTurns(new Error("Codex app-server was closed.")); + if (child) child.kill(); + } + + private async ensureStarted(): Promise { + if (this.process) return; + if (this.starting) return this.starting; + this.starting = this.start().finally(() => { + this.starting = null; + }); + return this.starting; + } + + private async start(): Promise { + const child = await this.spawnProcess(); + this.process = child; + const lines = createInterface({ input: child.stdout }); + lines.on("line", (line) => this.handleLine(line)); + child.stderr.on("data", (chunk: Buffer | string) => { + this.stderrTail = `${this.stderrTail}${chunk.toString()}`.slice(-4_000); + }); + child.on("error", (error) => this.handleProcessEnd(error)); + child.on("exit", (code, signal) => { + this.handleProcessEnd( + new Error( + `Codex app-server exited (${code ?? "null"}${signal ? `, ${signal}` : ""}).${ + this.stderrTail ? ` ${this.stderrTail.trim()}` : "" + }`, + ), + ); + }); + + await this.requestWithoutStart("initialize", { + clientInfo: { name: "openscreen", title: "OpenScreen", version: this.clientVersion }, + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, + }); + this.notify("initialized"); + } + + private async request(method: string, params?: unknown): Promise { + await this.ensureStarted(); + return this.requestWithoutStart(method, params); + } + + private requestWithoutStart(method: string, params?: unknown): Promise { + const child = this.process; + if (!child) return Promise.reject(new Error("Codex app-server is not running.")); + const id = this.nextRequestId++; + const request: JsonRpcRequest = { jsonrpc: "2.0", id, method }; + if (params !== undefined) request.params = params; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Codex request timed out: ${method}`)); + }, this.requestTimeoutMs); + this.pending.set(id, { resolve, reject, timer }); + child.stdin.write(`${JSON.stringify(request)}\n`); + }); + } + + private notify(method: string, params?: unknown): void { + const child = this.process; + if (!child) return; + const notification: JsonRpcNotification = { jsonrpc: "2.0", method }; + if (params !== undefined) notification.params = params; + child.stdin.write(`${JSON.stringify(notification)}\n`); + } + + private handleLine(line: string): void { + let message: unknown; + try { + message = JSON.parse(line); + } catch { + return; + } + if (!isRecord(message)) return; + if ((typeof message.id === "number" || typeof message.id === "string") && !message.method) { + this.handleResponse(message as unknown as JsonRpcResponse); + return; + } + if ( + (typeof message.id === "number" || typeof message.id === "string") && + typeof message.method === "string" + ) { + void this.handleServerRequest( + message as unknown as JsonRpcNotification & { id: number | string }, + ); + return; + } + if (typeof message.method === "string") { + this.handleNotification(message as unknown as JsonRpcNotification); + } + } + + private handleResponse(response: JsonRpcResponse): void { + const pending = this.pending.get(response.id); + if (!pending) return; + this.pending.delete(response.id); + clearTimeout(pending.timer); + if (response.error) { + pending.reject( + new Error( + response.error.message || + `Codex request failed with code ${response.error.code ?? "unknown"}.`, + ), + ); + return; + } + pending.resolve(response.result); + } + + private handleNotification(notification: JsonRpcNotification): void { + if (notification.method === "item/agentMessage/delta" && isRecord(notification.params)) { + const threadId = notification.params.threadId; + const delta = notification.params.delta; + if (typeof threadId === "string" && typeof delta === "string") { + const active = this.activeTurns.get(threadId); + if (active) { + active.text += delta; + active.onText(delta); + } + } + return; + } + if ( + (notification.method === "item/reasoning/textDelta" || + notification.method === "item/reasoning/summaryTextDelta") && + isRecord(notification.params) + ) { + const threadId = notification.params.threadId; + const delta = notification.params.delta; + if (typeof threadId === "string" && typeof delta === "string") { + this.activeTurns.get(threadId)?.onThinking(delta); + } + return; + } + if (notification.method === "turn/completed" && isRecord(notification.params)) { + this.completeTurn(notification.params); + return; + } + if (notification.method !== "account/login/completed" || !isRecord(notification.params)) { + return; + } + const loginId = notification.params.loginId; + if (typeof loginId !== "string") return; + const completion: CodexLoginCompletion = { + success: notification.params.success === true, + error: typeof notification.params.error === "string" ? notification.params.error : null, + }; + this.loginCompletions.set(loginId, completion); + const waiters = this.loginWaiters.get(loginId) ?? []; + this.loginWaiters.delete(loginId); + for (const waiter of waiters) waiter(completion); + } + + private async handleServerRequest( + request: JsonRpcNotification & { id: number | string }, + ): Promise { + if (request.method !== "item/tool/call" || !isRecord(request.params)) { + this.respondToServerRequest(request.id, undefined, { + code: -32601, + message: `Unsupported Codex server request: ${request.method}`, + }); + return; + } + const threadId = request.params.threadId; + const tool = request.params.tool; + if (typeof threadId !== "string" || typeof tool !== "string") { + this.respondToServerRequest(request.id, undefined, { + code: -32602, + message: "Invalid dynamic tool call.", + }); + return; + } + const active = this.activeTurns.get(threadId); + if (!active) { + this.respondToServerRequest(request.id, undefined, { + code: -32000, + message: "No active OpenScreen turn for this tool call.", + }); + return; + } + try { + const result = await active.onToolCall(tool, request.params.arguments); + this.respondToServerRequest(request.id, { + contentItems: [{ type: "inputText", text: result.resultText }], + success: result.success, + }); + } catch (error) { + this.respondToServerRequest(request.id, { + contentItems: [ + { type: "inputText", text: JSON.stringify({ error: messageFromUnknown(error) }) }, + ], + success: false, + }); + } + } + + private respondToServerRequest( + id: number | string, + result?: unknown, + error?: { code: number; message: string }, + ): void { + const child = this.process; + if (!child) return; + const response = error ? { jsonrpc: "2.0", id, error } : { jsonrpc: "2.0", id, result }; + child.stdin.write(`${JSON.stringify(response)}\n`); + } + + private completeTurn(params: Record): void { + const threadId = params.threadId; + if (typeof threadId !== "string") return; + const active = this.activeTurns.get(threadId); + if (!active) return; + this.activeTurns.delete(threadId); + clearTimeout(active.timer); + const turn = isRecord(params.turn) ? params.turn : null; + const turnId = turn && typeof turn.id === "string" ? turn.id : active.turnId; + if (turn && turn.status === "failed") { + const error = + isRecord(turn.error) && typeof turn.error.message === "string" + ? turn.error.message + : "Codex turn failed."; + active.reject(new Error(error)); + return; + } + let text = active.text; + if (!text && turn && Array.isArray(turn.items)) { + const messages = turn.items.filter( + (item): item is Record => isRecord(item) && item.type === "agentMessage", + ); + const final = messages.at(-1); + if (final && typeof final.text === "string") text = final.text; + } + if (!turnId) { + active.reject(new Error("Codex completed without a turn id.")); + return; + } + active.resolve({ text: text.trim(), threadId, turnId }); + } + + private handleProcessEnd(error: Error): void { + this.process = null; + this.rejectPending(error); + this.rejectActiveTurns(error); + } + + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } + + private rejectActiveTurns(error: Error): void { + for (const active of this.activeTurns.values()) { + clearTimeout(active.timer); + active.reject(error); + } + this.activeTurns.clear(); + } +} diff --git a/electron/ai-edition/codex-chat-service.test.ts b/electron/ai-edition/codex-chat-service.test.ts new file mode 100644 index 00000000..2645f9b8 --- /dev/null +++ b/electron/ai-edition/codex-chat-service.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { createEmptyDocument, documentSchema } from "../../src/lib/ai-edition/schema"; +import type { CodexAppServerClient } from "./codex-app-server-client"; +import { invokeCodexOpenScreenAgent } from "./codex-chat-service"; + +function fixtureDocument() { + const base = createEmptyDocument({ + title: "Test", + projectId: "proj_1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + return documentSchema.parse({ + ...base, + project: { ...base.project, primaryAssetId: "asset_1" }, + assets: [ + { + id: "asset_1", + kind: "video", + label: "Recording", + originalPath: "/tmp/recording.mp4", + durationSec: 30, + }, + ], + timeline: { + ...base.timeline, + clips: [ + { + id: "clip_1", + assetId: "asset_1", + sourceStartSec: 0, + sourceEndSec: 30, + timelineStartSec: 0, + timelineEndSec: 30, + wordRefs: [], + origin: "user", + reason: "", + }, + ], + }, + }); +} + +describe("invokeCodexOpenScreenAgent", () => { + it("exposes the existing tools and applies a Codex dynamic tool call", async () => { + const toolEnd = vi.fn(); + const runner: Pick = { + runTurn: async (options) => { + expect(options.tools.map((tool) => tool.name)).toContain("addTrim"); + const applied = await options.onToolCall("addTrim", { + startSec: 5, + endSec: 7, + clipId: "clip_1", + reason: "pause", + }); + expect(applied.success).toBe(true); + expect(JSON.parse(applied.resultText)).not.toHaveProperty("error"); + return { text: "Removed the pause.", threadId: "thread-1", turnId: "turn-1" }; + }, + }; + + const result = await invokeCodexOpenScreenAgent({ + document: fixtureDocument(), + client: runner, + model: "gpt-5.6-sol", + history: [], + userMessage: "Remove the pause.", + sink: { + text: vi.fn(), + thinking: vi.fn(), + toolStart: vi.fn(), + toolEnd, + error: vi.fn(), + }, + }); + + expect(result.text).toBe("Removed the pause."); + expect(result.mutated).toBe(true); + expect(result.document.timeline.trimRanges).toHaveLength(1); + expect(toolEnd).toHaveBeenCalledWith("addTrim", true, expect.any(String)); + }); +}); diff --git a/electron/ai-edition/codex-chat-service.ts b/electron/ai-edition/codex-chat-service.ts new file mode 100644 index 00000000..75d6e5bd --- /dev/null +++ b/electron/ai-edition/codex-chat-service.ts @@ -0,0 +1,117 @@ +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { AxcutDocument } from "../../src/lib/ai-edition/schema"; +import type { CodexAppServerClient } from "./codex-app-server-client"; +import { + buildSystemPrompt, + buildTools, + type CursorTelemetryReader, + type InvokeResult, + type OpenScreenAgentSink, + probeCursorTelemetry, +} from "./deep-agent/service"; + +export interface InvokeCodexAgentArgs { + document: AxcutDocument; + client: Pick; + model: string; + reasoningEffort?: string; + history: Array<{ role: "user" | "assistant" | "system"; content: string }>; + userMessage: string; + sink: OpenScreenAgentSink; + editsAllowed?: boolean; + cursor?: CursorTelemetryReader; +} + +function formatConversation(history: InvokeCodexAgentArgs["history"], userMessage: string): string { + const prior = history + .slice(0, -1) + .map((message) => `${message.role.toUpperCase()}: ${message.content}`) + .join("\n\n"); + if (!prior) return userMessage; + return `Conversation so far:\n\n${prior}\n\nCURRENT USER REQUEST:\n${userMessage}`; +} + +function codexEffort(value: string | undefined): string | undefined { + if (!value || value === "none") return undefined; + return value; +} + +function toolInputSchema(schema: unknown): Record { + const candidate = schema as { toJSONSchema?: () => unknown }; + if (typeof candidate.toJSONSchema === "function") { + return candidate.toJSONSchema() as Record; + } + // LangChain still exposes one transformed Zod 3 schema alongside its Zod 4 + // tools. Keep the compatibility converter scoped to that legacy shape. + return zodToJsonSchema(schema as never) as Record; +} + +/** + * Runs the existing OpenScreen editing tools through Codex app-server. + * Tool definitions, validation, consent checks, cursor IO, and document + * mutation all remain owned by the same `buildTools` path as API providers. + */ +export async function invokeCodexOpenScreenAgent( + args: InvokeCodexAgentArgs, +): Promise { + const editsAllowed = args.editsAllowed !== false; + const holder = { current: args.document }; + const initialDocumentJson = JSON.stringify(args.document); + const availableByAssetId = await probeCursorTelemetry(args.document, args.cursor); + const tools = buildTools(holder, args.sink, editsAllowed, { + cursor: args.cursor, + availableByAssetId, + }); + const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); + + try { + const result = await args.client.runTurn({ + model: args.model, + effort: codexEffort(args.reasoningEffort), + systemPrompt: buildSystemPrompt({ editsAllowed }), + message: formatConversation(args.history, args.userMessage), + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: toolInputSchema(tool.schema), + })), + onToolCall: async (name, toolArgs) => { + const selected = toolsByName.get(name); + if (!selected) { + return { + success: false, + resultText: JSON.stringify({ error: `Unknown OpenScreen tool: ${name}` }), + }; + } + const output = await (selected as { invoke: (args: unknown) => Promise }).invoke( + toolArgs, + ); + return { + success: true, + resultText: typeof output === "string" ? output : JSON.stringify(output), + }; + }, + onText: args.sink.text, + onThinking: args.sink.thinking, + }); + const mutated = JSON.stringify(holder.current) !== initialDocumentJson; + if (!result.text) { + return { + text: "", + document: holder.current, + mutated, + reason: "Codex completed without an assistant message.", + }; + } + return { text: result.text, document: holder.current, mutated }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + args.sink.error(reason); + return { + text: "", + document: holder.current, + mutated: JSON.stringify(holder.current) !== initialDocumentJson, + reason, + }; + } +} diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts index d804a3b1..ed523adb 100644 --- a/electron/ai-edition/deep-agent/service.ts +++ b/electron/ai-edition/deep-agent/service.ts @@ -205,7 +205,7 @@ export interface CursorTelemetryReader { probe?(input: { assetId: string; originalPath: string | null }): Promise; } -interface ToolRuntime { +export interface ToolRuntime { cursor?: CursorTelemetryReader; availableByAssetId?: Record; } @@ -384,7 +384,7 @@ export interface InvokeArgs { /** One cheap probe per asset, run before the tools are built so the very first * `getCurrentDocument` can already say whether telemetry exists. */ -async function probeCursorTelemetry( +export async function probeCursorTelemetry( document: AxcutDocument, cursor: CursorTelemetryReader | undefined, ): Promise | undefined> { diff --git a/electron/ai-edition/provider-registry.test.ts b/electron/ai-edition/provider-registry.test.ts index d4918bfc..266b264d 100644 --- a/electron/ai-edition/provider-registry.test.ts +++ b/electron/ai-edition/provider-registry.test.ts @@ -17,9 +17,9 @@ const FIRST_PARTY_ONLY_HOSTS = [ ]; describe("PROVIDER_DEFINITIONS", () => { - it("ships only API-key providers", () => { + it("ships only API-key providers plus the sanctioned Codex app-server", () => { const others = PROVIDER_DEFINITIONS.filter((def) => def.authKind !== "api-key"); - expect(others.map((d) => d.id)).toEqual([]); + expect(others.map((d) => [d.id, d.authKind])).toEqual([["codex", "codex-app-server"]]); }); it("points at no endpoint reserved for a vendor's own clients", () => { diff --git a/electron/ai-edition/provider-registry.ts b/electron/ai-edition/provider-registry.ts index 458334c2..91ffc395 100644 --- a/electron/ai-edition/provider-registry.ts +++ b/electron/ai-edition/provider-registry.ts @@ -10,10 +10,7 @@ export interface ProviderDefinition { id: string; label: string; defaultModel: string; - /** Only API-key providers ship today — see the removal note in - * PROVIDER_DEFINITIONS. Widen this again when the Copilot SDK / Codex - * app-server providers land. */ - authKind: "api-key"; + authKind: "api-key" | "codex-app-server"; supportsReasoningEffort: boolean; /** True when this provider always requires the user to enter a base URL * (e.g. openai-compatible). False when the default is implicit. */ @@ -30,6 +27,16 @@ export interface ProviderDefinition { } export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ + { + id: "codex", + label: "Codex (ChatGPT)", + defaultModel: "gpt-5.6-sol", + authKind: "codex-app-server", + supportsReasoningEffort: true, + envKeys: [], + setupHint: + "Sign in with your ChatGPT account through the official Codex app-server. OpenScreen never receives your Codex token.", + }, { id: "anthropic", label: "Claude API", @@ -96,7 +103,8 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ // backend-api). Both vendors now offer a sanctioned surface — GitHub's // Copilot SDK (register our own OAuth App) and `codex app-server` (drives // the user's own `codex login`, no client ID shipped at all) — so these come - // back on those, not on borrowed credentials. Tracked in the follow-up PR. + // back on those, not on borrowed credentials. Codex now uses the sanctioned + // app-server integration above; GitHub Copilot remains out of scope. { id: "minimax", label: "MiniMax API", diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 958ef6d9..69fb54ba 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -49,6 +49,7 @@ import { runChat, selectSession, } from "../ai-edition/chat-service"; +import { CodexAppServerClient } from "../ai-edition/codex-app-server-client"; import type { CursorTelemetryReader } from "../ai-edition/deep-agent/service"; import { DocumentService } from "../ai-edition/document-service"; import { LlmConfigStore } from "../ai-edition/llm-config-store"; @@ -4182,6 +4183,14 @@ export function registerIpcHandlers( } return aiEditionLlmConfigInstance; }; + let codexAppServerClientInstance: CodexAppServerClient | null = null; + const getCodexAppServerClient = (): CodexAppServerClient => { + if (!codexAppServerClientInstance) { + codexAppServerClientInstance = new CodexAppServerClient(); + } + return codexAppServerClientInstance; + }; + app.once("before-quit", () => codexAppServerClientInstance?.close()); registerNativeBridgeHandlers({ getPlatform: () => process.platform, @@ -4218,9 +4227,14 @@ export function registerIpcHandlers( }, getAiEditionDocuments: () => aiEditionDocuments, getAiEditionLlmConfig, + getCodexAppServerClient, + openExternal: async (url) => { + await shell.openExternal(url); + }, runAiEditionChat: (projectId, sessionId, message, document, sink) => runChat(projectId, sessionId, message, getAiEditionLlmConfig(), document, sink, { cursor: agentCursorTelemetryReader, + codex: getCodexAppServerClient(), }), undoAiEditionToolBatch: (_projectId, _sessionId) => ({ success: false, diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 47d66e27..92ace9b2 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -54,6 +54,8 @@ export interface NativeBridgeContext { getNativeWindowHandle?: (sender: import("electron").WebContents) => Buffer | null; getAiEditionDocuments: () => DocumentService; getAiEditionLlmConfig: () => import("../ai-edition/llm-config-store").LlmConfigStore; + getCodexAppServerClient: () => import("../ai-edition/codex-app-server-client").CodexAppServerClient; + openExternal: (url: string) => Promise; runAiEditionChat: ( projectId: string, sessionId: string, @@ -226,6 +228,8 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { // Passed uncalled on purpose — invoking it here would build the store (and // hit the macOS Keychain) while wiring the bridge at startup. llmConfig: context.getAiEditionLlmConfig, + codexClient: context.getCodexAppServerClient, + openExternal: context.openExternal, runChat: context.runAiEditionChat, undoLastToolBatch: context.undoAiEditionToolBatch, rewindToMessage: context.rewindToMessage, @@ -500,6 +504,8 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { requestId, await aiEditionService.llmSetConfig(request.payload.config), ); + case "llm.connectCodex": + return createSuccessResponse(requestId, await aiEditionService.llmConnectCodex()); case "llm.setApiKey": return createSuccessResponse( requestId, diff --git a/electron/native-bridge/services/aiEditionService.codex.test.ts b/electron/native-bridge/services/aiEditionService.codex.test.ts new file mode 100644 index 00000000..d6d698f6 --- /dev/null +++ b/electron/native-bridge/services/aiEditionService.codex.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CodexAppServerClient } from "../../ai-edition/codex-app-server-client"; +import type { LlmConfig, LlmConfigStore } from "../../ai-edition/llm-config-store"; +import { AiEditionService, type AiEditionServiceOptions } from "./aiEditionService"; + +function harness( + statuses: Array<{ + available: boolean; + connected: boolean; + account?: { email: string; planType: string }; + }>, +) { + let config: LlmConfig | null = null; + const store = { + getConfig: () => config, + setConfig: async (next: LlmConfig) => { + config = next; + }, + getCredential: () => null, + removeCredential: vi.fn(), + } as unknown as LlmConfigStore; + let readIndex = 0; + const client = { + readAccount: async () => statuses[Math.min(readIndex++, statuses.length - 1)], + startLogin: async () => ({ loginId: "login-1", authUrl: "https://auth.openai.com/codex" }), + waitForLogin: async () => ({ success: true, error: null }), + listModels: async () => ["gpt-5.6-sol"], + } as unknown as CodexAppServerClient; + const openExternal = vi.fn(async () => undefined); + const service = new AiEditionService({ + documents: { listProjects: async () => [] }, + llmConfig: () => store, + codexClient: () => client, + openExternal, + } as unknown as AiEditionServiceOptions); + return { service, openExternal, getConfig: () => config }; +} + +describe("AiEditionService Codex connection", () => { + it("reports Codex account state without exposing a token", async () => { + const { service } = harness([ + { + available: true, + connected: true, + account: { email: "user@example.com", planType: "plus" }, + }, + ]); + const snapshot = await service.llmGetSnapshot(); + expect(snapshot.connectedProviders).toContain("codex"); + expect(snapshot.codex).toMatchObject({ + available: true, + connected: true, + email: "user@example.com", + planType: "plus", + }); + expect(snapshot.credentialSummary.find((row) => row.providerId === "codex")).toMatchObject({ + credentialKind: "codex", + }); + }); + + it("opens the HTTPS Codex login URL and selects Codex after completion", async () => { + const { service, openExternal, getConfig } = harness([ + { available: true, connected: false }, + { available: true, connected: true, account: { email: "user@example.com", planType: "pro" } }, + ]); + const result = await service.llmConnectCodex(); + expect(result.success).toBe(true); + expect(openExternal).toHaveBeenCalledWith("https://auth.openai.com/codex"); + expect(getConfig()).toMatchObject({ provider: "codex", model: "gpt-5.6-sol" }); + }); +}); diff --git a/electron/native-bridge/services/aiEditionService.lazyLlmConfig.test.ts b/electron/native-bridge/services/aiEditionService.lazyLlmConfig.test.ts index 0935f0db..793fd188 100644 --- a/electron/native-bridge/services/aiEditionService.lazyLlmConfig.test.ts +++ b/electron/native-bridge/services/aiEditionService.lazyLlmConfig.test.ts @@ -35,6 +35,9 @@ function serviceWithCountingFactory(): { service: AiEditionService; builds: () = builds += 1; return store; }, + codexClient: () => ({ + readAccount: async () => ({ available: true, connected: false }), + }), } as unknown as AiEditionServiceOptions; return { service: new AiEditionService(options), builds: () => builds }; } diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index 0fbbccc9..1bed5a20 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -9,6 +9,7 @@ import type { AiEditionChatRewindResult, AiEditionChatSession, AiEditionChatSessionSummary, + AiEditionCodexConnectResult, AiEditionDocumentResult, AiEditionLlmConfig, AiEditionLlmDisconnectResult, @@ -20,6 +21,7 @@ import { translateCaptionSegments, } from "../../ai-edition/caption-translate"; import type { ChatEventSink } from "../../ai-edition/chat-service"; +import type { CodexAppServerClient } from "../../ai-edition/codex-app-server-client"; import type { DocumentService } from "../../ai-edition/document-service"; import type { LlmConfigStore, LlmCredential } from "../../ai-edition/llm-config-store"; import { @@ -44,6 +46,8 @@ export interface AiEditionServiceOptions { * method the renderer has to invoke first. */ llmConfig: () => LlmConfigStore; + codexClient: () => CodexAppServerClient; + openExternal: (url: string) => Promise; runChat: ( projectId: string, sessionId: string, @@ -166,7 +170,18 @@ export class AiEditionService { const config = this.llmConfig.getConfig(); const credentialSummary: AiEditionLlmSnapshot["credentialSummary"] = []; const connectedProviders: string[] = []; + const codex = await this.options.codexClient().readAccount(); for (const def of PROVIDER_DEFINITIONS) { + if (def.authKind === "codex-app-server") { + if (codex.connected) connectedProviders.push(def.id); + credentialSummary.push({ + providerId: def.id, + connected: codex.connected, + authKind: def.authKind, + credentialKind: codex.connected ? "codex" : null, + }); + continue; + } const resolved = this.llmConfig.getCredential(def.id, def.envKeys); const connected = Boolean(resolved); if (connected) connectedProviders.push(def.id); @@ -186,9 +201,48 @@ export class AiEditionService { authKind: d.authKind, })), credentialSummary, + codex: { + available: codex.available, + connected: codex.connected, + email: codex.account?.email, + planType: codex.account?.planType, + error: codex.error, + }, }; } + async llmConnectCodex(): Promise { + try { + const client = this.options.codexClient(); + let account = await client.readAccount(); + if (!account.connected) { + if (!account.available && account.error) throw new Error(account.error); + const login = await client.startLogin(); + await this.options.openExternal(login.authUrl); + const completion = await client.waitForLogin(login.loginId); + if (!completion.success) { + throw new Error(completion.error || "Codex login was not completed."); + } + account = await client.readAccount(); + } + if (!account.connected) throw new Error(account.error || "Codex is not signed in."); + const current = this.llmConfig.getConfig(); + await this.llmConfig.setConfig({ + provider: "codex", + model: current?.provider === "codex" && current.model ? current.model : "gpt-5.6-sol", + reasoningEffort: current?.provider === "codex" ? current.reasoningEffort : "medium", + allowAgentEdits: current?.allowAgentEdits, + }); + return { success: true, snapshot: await this.llmGetSnapshot() }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + snapshot: await this.llmGetSnapshot(), + }; + } + } + async llmSetConfig(config: AiEditionLlmConfig): Promise { try { await this.llmConfig.setConfig(config); @@ -218,7 +272,7 @@ export class AiEditionService { } async llmDisconnect(providerId: string): Promise { - await this.llmConfig.removeCredential(providerId); + if (providerId !== "codex") await this.llmConfig.removeCredential(providerId); const active = this.llmConfig.getConfig(); if (active?.provider === providerId) { await this.llmConfig.setConfig({ @@ -233,6 +287,11 @@ export class AiEditionService { try { const def = PROVIDER_DEFINITIONS.find((d) => d.id === providerId); if (!def) return { models: [], error: `Unknown provider ${providerId}` }; + if (providerId === "codex") { + const account = await this.options.codexClient().readAccount(); + if (!account.connected) return { models: [], error: account.error || "Not connected" }; + return { models: await this.options.codexClient().listModels() }; + } const cred = this.llmConfig.getCredential(providerId, def.envKeys); if (!cred) return { models: [], error: "Not connected" }; const config = this.llmConfig.getConfig(); diff --git a/src/components/ai-edition/LeftPanel.providerRefresh.test.tsx b/src/components/ai-edition/LeftPanel.providerRefresh.test.tsx index 6163ed8d..c9a84907 100644 --- a/src/components/ai-edition/LeftPanel.providerRefresh.test.tsx +++ b/src/components/ai-edition/LeftPanel.providerRefresh.test.tsx @@ -10,10 +10,11 @@ // here: refreshed once on mount, not again when the dialog opens, once more when it closes. import "@testing-library/jest-dom"; -import { act, cleanup, render } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AiEditionLlmSnapshot } from "@/native/contracts"; -const llmGetSnapshot = vi.fn(() => +const llmGetSnapshot = vi.fn<() => Promise>(() => Promise.resolve({ config: null, connectedProviders: [], @@ -21,12 +22,35 @@ const llmGetSnapshot = vi.fn(() => credentialSummary: [], }), ); +const unattachedSession = { + id: "session-unattached", + projectId: "__openscreen_unattached_chat__", + title: "Conversation 1", + messageCount: 0, + createdAt: "2026-08-26T00:00:00.000Z", +}; +const chatCreateSession = vi.fn(() => Promise.resolve(unattachedSession)); +const chatRun = vi.fn(() => + Promise.resolve({ + success: true, + assistantMessage: { + id: "assistant-1", + role: "assistant" as const, + content: "Hello from Codex", + createdAt: "2026-08-26T00:00:01.000Z", + }, + }), +); vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: { llmGetSnapshot: () => llmGetSnapshot(), - chatListSessions: () => Promise.resolve([]), + chatListSessions: () => + Promise.resolve(chatCreateSession.mock.calls.length ? [unattachedSession] : []), + chatCreateSession: (...args: Parameters) => + chatCreateSession(...args), + chatRun: (...args: Parameters) => chatRun(...args), chatBudget: () => Promise.resolve(null), llmListProviderModels: () => Promise.resolve({ models: [] }), }, @@ -57,7 +81,15 @@ function CaptureDialogActions() { } beforeEach(() => { - llmGetSnapshot.mockClear(); + llmGetSnapshot.mockReset(); + llmGetSnapshot.mockResolvedValue({ + config: null, + connectedProviders: [], + availableProviders: [], + credentialSummary: [], + }); + chatCreateSession.mockClear(); + chatRun.mockClear(); dialogActions = null; // The panel subscribes to streamed chat events on mount; there is no preload in jsdom. (window as unknown as { electronAPI?: unknown }).electronAPI = { @@ -105,4 +137,33 @@ describe("ChatStripPanel, against the lifted provider dialog", () => { }); expect(llmGetSnapshot).toHaveBeenCalledTimes(2); }); + + it("sends a text-only Codex message before a video project is opened", async () => { + llmGetSnapshot.mockResolvedValue({ + config: { provider: "codex", model: "gpt-5.6-sol" }, + connectedProviders: ["codex"], + availableProviders: [], + credentialSummary: [], + }); + render( + + + , + ); + + const composer = await screen.findByPlaceholderText("chat.composerPlaceholder"); + fireEvent.change(composer, { target: { value: "Hello" } }); + fireEvent.click(screen.getByRole("button", { name: "chat.send" })); + + await waitFor(() => { + expect(chatCreateSession).toHaveBeenCalledWith("__openscreen_unattached_chat__"); + }); + expect(chatRun).toHaveBeenCalledWith( + "__openscreen_unattached_chat__", + "session-unattached", + "Hello", + undefined, + ); + await screen.findByText("Hello from Codex"); + }); }); diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 2cafaebf..68b6decc 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -40,6 +40,7 @@ import { useChatBudget } from "./useChatBudget"; export type LeftTab = "chat" | "media"; const THUMB_PALETTE = ["thumbRed", "thumbGreen", "thumbAmber", "thumbCyan"] as const; +const UNATTACHED_CHAT_PROJECT_ID = "__openscreen_unattached_chat__"; // `h:mm:ss.t`, hours always shown — a third shape, so it formats itself rather // than calling into format.ts. It shares `splitRoundedTime` because the carry is @@ -733,6 +734,7 @@ function ChatStripPanel() { // see the prompt-bus effect below. const tTimeline = useScopedT("timeline"); const projectId = useProjectStore((s) => s.projectId); + const chatProjectId = projectId ?? UNATTACHED_CHAT_PROJECT_ID; const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); @@ -838,24 +840,18 @@ function ChatStripPanel() { }, []); useEffect(() => { - if (!projectId) { - setSessions([]); - setActiveSessionId(null); - setMessages([]); - return; - } - void refreshSessions(projectId, true); - }, [projectId, refreshSessions]); + void refreshSessions(chatProjectId, true); + }, [chatProjectId, refreshSessions]); useEffect(() => { - if (!projectId || !activeSessionId) { + if (!activeSessionId) { setMessages([]); return; } void (async () => { try { const session = await nativeBridgeClient.aiEdition.chatSelectSession( - projectId, + chatProjectId, activeSessionId, ); if (session) { @@ -876,7 +872,7 @@ function ChatStripPanel() { // ponytail: silent — shim mode } })(); - }, [projectId, activeSessionId]); + }, [chatProjectId, activeSessionId]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); @@ -884,7 +880,7 @@ function ChatStripPanel() { const send = async (overrideText?: string) => { const text = (overrideText ?? input).trim(); - if (!projectId || !text || busy) return; + if (!text || busy) return; // ponytail: nothing to talk to. Bounce to the settings modal instead of // firing a doomed request. The composer is disabled in this state too, // but Auto-enhance calls send() directly and Enter can slip through. @@ -921,7 +917,7 @@ function ChatStripPanel() { // silently starts one instead of no-op'ing. let sessionId = activeSessionId; if (!sessionId) { - const created = await nativeBridgeClient.aiEdition.chatCreateSession(projectId); + const created = await nativeBridgeClient.aiEdition.chatCreateSession(chatProjectId); sessionId = created.id; setSessions((prev) => [...prev, created]); setActiveSessionId(sessionId); @@ -935,7 +931,7 @@ function ChatStripPanel() { // before the turn starts, so a manual edit landing while the agent works is // detectable when its answer comes back. const { result, applyDocument } = await runAgentTurn((documentSnapshot) => - nativeBridgeClient.aiEdition.chatRun(projectId, sessionId, text, documentSnapshot), + nativeBridgeClient.aiEdition.chatRun(chatProjectId, sessionId, text, documentSnapshot), ); const assistant = result.assistantMessage; if (result.success && assistant) { @@ -991,7 +987,7 @@ function ChatStripPanel() { thinking: thinkingText || undefined, }, ]); - void refreshSessions(projectId); + void refreshSessions(chatProjectId); } else { toast.error(result.error ?? t("chat.chatFailed")); } diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx index 3c1debd7..2b640146 100644 --- a/src/components/ai-edition/ProviderSettings.test.tsx +++ b/src/components/ai-edition/ProviderSettings.test.tsx @@ -34,7 +34,7 @@ vi.mock("@/native/client", () => ({ import { ProviderSettingsDialog } from "./ProviderSettings"; -const noop = () => {}; +const noop = () => undefined; /** The top bar as NewEditorShell builds it: the menu row's action is the context's opener, and * nothing else in `actions` matters here. */ @@ -149,4 +149,15 @@ describe("ProviderSettings, reached from the app menu", () => { expect(screen.getByRole("heading", { name: /paramètres ia/i })).toBeInTheDocument(); }); + + it("offers the official Codex sign-in flow without an API-key field", () => { + renderEditorChrome("en"); + openAiSettingsFromAppMenu(); + + fireEvent.click(screen.getByRole("button", { name: /Codex \(ChatGPT\).*Sign in/i })); + + expect(screen.getByRole("button", { name: /Sign in with Codex/i })).toBeInTheDocument(); + expect(screen.getByText(/official Codex app-server/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/^API key$/i)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/ai-edition/ProviderSettings.tsx b/src/components/ai-edition/ProviderSettings.tsx index 8d66681c..46a50aba 100644 --- a/src/components/ai-edition/ProviderSettings.tsx +++ b/src/components/ai-edition/ProviderSettings.tsx @@ -3,15 +3,14 @@ // UI: 3 screens stacked in the modal, navigated by URL-less state // (mirroring axcut apps/web/src/App.tsx _p modal): // 1. **list** — grid of provider cards, each showing label, default -// model, and a CONNECTED / API KEY pill. +// model, and a CONNECTED / API KEY / SIGN IN pill. // 2. **connect-form** — single form per provider: model + optional baseUrl + // optional reasoning effort + api-key field + // Save/Disconnect buttons. // -// Every provider is API-key based since 1.8.0 dropped the ChatGPT and Copilot -// OAuth providers (see provider-registry.ts); the device-challenge screen went -// with them. Credentials live in the safeStorage blob (LlmConfigStore) — the -// renderer never sees raw keys, only `kind`. +// API credentials live in safeStorage. Codex is different: the official +// app-server owns ChatGPT authentication, while the renderer sees only account +// status and never receives a token. // // `ProviderSettingsDialog` at the bottom is the only mount, and the only caller of the // `open` / `onClose` component above it. Internal state is local-only. @@ -148,6 +147,22 @@ function ProviderSettings({ open, onClose }: ProviderSettingsProps) { } }; + const connectCodex = async () => { + setBusy(true); + setError(null); + try { + const result = await nativeBridgeClient.aiEdition.llmConnectCodex(); + setSnapshot(result.snapshot); + if (!result.success) throw new Error(result.error || te("providerSettings.codexLoginFailed")); + setConfig(result.snapshot.config); + toast.success(te("providerSettings.saved", { provider: active?.label ?? "Codex" })); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + const disconnect = async () => { if (!active) return; setBusy(true); @@ -198,6 +213,7 @@ function ProviderSettings({ open, onClose }: ProviderSettingsProps) { error={error} onBack={goBackToList} onSave={saveApiKey} + onConnectCodex={connectCodex} onDisconnect={disconnect} listProviderModels={nativeBridgeClient.aiEdition.llmListProviderModels} /> @@ -252,8 +268,10 @@ function ProviderList({ ) : ( - - {te("providerSettings.pillApiKey")} + {def.authKind === "codex-app-server" ? null : } + {def.authKind === "codex-app-server" + ? te("providerSettings.pillSignIn") + : te("providerSettings.pillApiKey")} )} @@ -294,6 +312,7 @@ function ProviderForm({ error, onBack, onSave, + onConnectCodex, onDisconnect, listProviderModels, }: { @@ -308,6 +327,7 @@ function ProviderForm({ error: string | null; onBack: () => void; onSave: () => void; + onConnectCodex: () => void; onDisconnect: () => void; listProviderModels: (providerId: string) => Promise<{ models: string[]; error?: string }>; }) { @@ -487,18 +507,24 @@ function ProviderForm({ ) : null} - - setApiKey(e.target.value)} - disabled={busy} - /> - + {def.authKind === "api-key" ? ( + + setApiKey(e.target.value)} + disabled={busy} + /> + + ) : ( +

+ {te("providerSettings.codexSignInHint")} +

+ )} : } {te("providerSettings.save")} + ) : def.authKind === "codex-app-server" ? ( + ) : (