diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b140bd14..c7d15bad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,30 @@ jobs: TZAFON_API_KEY: ${{ secrets.TZAFON_API_KEY }} YUTORI_API_KEY: ${{ secrets.YUTORI_API_KEY }} run: npm test --workspace @onkernel/cua-agent -- test/e2e.live.test.ts + + cli-e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + # Only run on the main repo (not forks) so secrets are available. + # Skip draft PRs; still run on every commit to non-draft PRs and on main pushes. + if: github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft) + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Install Zig 0.15.2 + run: | + mkdir -p .dev/tools + curl -fsSL https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz -o /tmp/zig-x86_64-linux-0.15.2.tar.xz + tar -xf /tmp/zig-x86_64-linux-0.15.2.tar.xz -C .dev/tools + - run: npm run build:native --workspace @onkernel/ptywright + - run: npm run build --workspace @onkernel/cua-cli + - name: CUA CLI live TUI smoke test + env: + CUA_CLI_E2E_LIVE: "1" + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + KERNEL_API_KEY: ${{ secrets.KERNEL_API_KEY }} + run: npm run test:e2e-live --workspace @onkernel/cua-cli diff --git a/package-lock.json b/package-lock.json index 9cf2e56d..48a25cf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4667,16 +4667,11 @@ "name": "@onkernel/cua-cli", "version": "0.1.0", "dependencies": { - "@mariozechner/pi-agent-core": "0.67.6", - "@mariozechner/pi-ai": "0.67.6", "@mariozechner/pi-coding-agent": "0.67.6", "@mariozechner/pi-tui": "0.67.6", - "@onkernel/cua-anthropic": "0.1.0", - "@onkernel/cua-gemini": "0.1.0", - "@onkernel/cua-openai": "0.1.0", + "@onkernel/cua-agent": "0.2.0", + "@onkernel/cua-ai": "0.1.0", "@onkernel/cua-translator": "0.1.0", - "@onkernel/cua-tzafon": "0.1.0", - "@onkernel/cua-yutori": "0.1.0", "@onkernel/sdk": "0.49.0", "smol-toml": "1.5.1" }, diff --git a/packages/agent/test/e2e.live.test.ts b/packages/agent/test/e2e.live.test.ts index 06f97e0f..d26db15e 100644 --- a/packages/agent/test/e2e.live.test.ts +++ b/packages/agent/test/e2e.live.test.ts @@ -236,6 +236,11 @@ describe("Cua live e2e", () => { return apiKey ? { apiKey } : undefined; }, }); + if (c.name === "yutori") { + // Yutori can occasionally keep requesting additional tool rounds. + // Terminate after the first completed batch to keep CI deterministic. + harness.on("tool_result", () => ({ terminate: true })); + } harness.subscribe((event) => { recordRunEvent(stats, event); diff --git a/packages/cua-cli/package.json b/packages/cua-cli/package.json index ea5f90cc..f05c57a1 100644 --- a/packages/cua-cli/package.json +++ b/packages/cua-cli/package.json @@ -15,19 +15,15 @@ "scripts": { "build": "tsc -b && chmod +x dist/cli.js", "clean": "tsc -b --clean", - "test": "node --test dist/tui/testing/*.test.js" + "test": "node --test dist/tui/testing/*.test.js", + "test:e2e-live": "node --test dist/tui/testing/live.e2e.test.js" }, "dependencies": { - "@mariozechner/pi-agent-core": "0.67.6", - "@mariozechner/pi-ai": "0.67.6", "@mariozechner/pi-coding-agent": "0.67.6", "@mariozechner/pi-tui": "0.67.6", - "@onkernel/cua-anthropic": "0.1.0", - "@onkernel/cua-gemini": "0.1.0", - "@onkernel/cua-openai": "0.1.0", + "@onkernel/cua-agent": "0.2.0", + "@onkernel/cua-ai": "0.1.0", "@onkernel/cua-translator": "0.1.0", - "@onkernel/cua-tzafon": "0.1.0", - "@onkernel/cua-yutori": "0.1.0", "@onkernel/sdk": "0.49.0", "smol-toml": "1.5.1" }, diff --git a/packages/cua-cli/src/action/runner.ts b/packages/cua-cli/src/action/runner.ts index ce9e93ec..d802650d 100644 --- a/packages/cua-cli/src/action/runner.ts +++ b/packages/cua-cli/src/action/runner.ts @@ -1,12 +1,11 @@ -import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core"; -import type { SessionManager } from "@mariozechner/pi-coding-agent"; +import type { Agent, AgentEvent } from "@onkernel/cua-agent"; import { type BrowserSession, ComputerTranslator } from "@onkernel/cua-translator"; import { writeFile } from "node:fs/promises"; import { stderr, stdout } from "node:process"; import { promptWithScreenshot } from "../agent-prompt"; import { type CuaAgentHandle, createCuaAgent } from "../agent"; import type { Config } from "../config"; -import { persistAgentEvents, seedAgentFromSession } from "../sessions"; +import type { CuaSessionState } from "../sessions"; import { type ActionRequest, buildPrompt, DEFAULT_MAX_TURNS } from "./prompts"; import { type ActionEventInfo, type ActionResult, exitCodeFor, formatCompact, parseResult } from "./result"; @@ -18,11 +17,10 @@ export interface RunOptions { verbose?: boolean; maxTurns?: number; /** - * Optional SessionManager. When supplied, prior turns are seeded into - * the agent transcript and new messages are persisted as they emit. - * Used when chaining action subcommands via `-s `. + * Optional harness-native session state, used when chaining action + * subcommands via `-s `. */ - sessionManager?: SessionManager; + sessionState?: CuaSessionState; } export interface ScreenshotOutput { @@ -54,22 +52,16 @@ export async function runAction( } const prompt = buildPrompt(req); - const handle = createCuaAgent({ + const handle = await createCuaAgent({ cwd: opts.cwd, browser: opts.browser, config: opts.config, + session: opts.sessionState, modelId: opts.modelId, - sessionId: opts.browser.sessionId, }); - let unsubscribePersist: (() => void) | undefined; - let resumed = false; - if (opts.sessionManager) { - seedAgentFromSession(handle.agent, opts.sessionManager); - unsubscribePersist = persistAgentEvents(handle.agent, opts.sessionManager); - resumed = handle.agent.state.messages.some((m) => m.role === "user" || m.role === "assistant"); - } - const initialMessageCount = handle.agent.state.messages.length; + const resumed = opts.sessionState?.resumed === true; + const initialMessageCount = opts.sessionState?.priorMessageCount ?? handle.agent.state.messages.length; const events: ActionEventInfo[] = []; const maxTurns = req.maxTurns ?? opts.maxTurns ?? DEFAULT_MAX_TURNS; @@ -104,7 +96,6 @@ export async function runAction( runError = err instanceof Error ? err : new Error(String(err)); } finally { unsubscribe(); - unsubscribePersist?.(); } const elapsed = Date.now() - startedAt; @@ -173,7 +164,7 @@ function extractLatestToolError(messages: readonly unknown[]): string | undefine } function collectEvent( - event: { type: "tool_execution_start"; toolName: string; args: any }, + event: { type: "tool_execution_start"; toolName: string; args: unknown }, events: ActionEventInfo[], ): void { switch (event.toolName) { diff --git a/packages/cua-cli/src/agent-prompt.ts b/packages/cua-cli/src/agent-prompt.ts index d09ffb6e..0c30aa19 100644 --- a/packages/cua-cli/src/agent-prompt.ts +++ b/packages/cua-cli/src/agent-prompt.ts @@ -1,4 +1,4 @@ -import type { Agent, AgentMessage } from "@mariozechner/pi-agent-core"; +import type { Agent, AgentMessage } from "@onkernel/cua-agent"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import type { ComputerTranslator } from "@onkernel/cua-translator"; diff --git a/packages/cua-cli/src/agent.ts b/packages/cua-cli/src/agent.ts index 86c30d07..4767a167 100644 --- a/packages/cua-cli/src/agent.ts +++ b/packages/cua-cli/src/agent.ts @@ -1,23 +1,14 @@ +import type { Api, CuaModelRef, Model } from "@onkernel/cua-ai"; +import { resolveCuaRuntimeSpec } from "@onkernel/cua-ai"; import { Agent, + CuaAgentHarness, type AgentEvent, type AgentMessage, type AgentTool, - type StreamFn, type ThinkingLevel, -} from "@mariozechner/pi-agent-core"; -import { - type Api, - type Model, - registerApiProvider, - streamOpenAICompletions, - streamGoogle, - streamOpenAIResponses, - streamSimpleOpenAICompletions, - streamSimple, - streamSimpleGoogle, - streamSimpleOpenAIResponses, -} from "@mariozechner/pi-ai"; + InMemorySessionRepo, +} from "@onkernel/cua-agent"; import { createBashTool, createEditTool, @@ -27,44 +18,9 @@ import { createReadTool, createWriteTool, } from "@mariozechner/pi-coding-agent"; -import { - buildAnthropicSystemPrompt, -} from "@onkernel/cua-anthropic"; -import { - anthropicComputerOnPayload, - composeOnPayload, - createAnthropicContextManagementOnPayload, - createAnthropicComputerTools, - registerAnthropicProvider, - wrapAnthropicStream, -} from "@onkernel/cua-anthropic/pi"; -import { - buildGeminiSystemPrompt, -} from "@onkernel/cua-gemini"; -import { createGeminiComputerTools } from "@onkernel/cua-gemini/pi"; -import { - OPENAI_BATCH_INSTRUCTIONS, -} from "@onkernel/cua-openai"; -import { createOpenAIComputerTools } from "@onkernel/cua-openai/pi"; -import { - type BrowserSession, - ComputerTranslator, -} from "@onkernel/cua-translator"; -import { - buildTzafonSystemPrompt, -} from "@onkernel/cua-tzafon"; -import { - createTzafonComputerTools, - registerTzafonProvider, -} from "@onkernel/cua-tzafon/pi"; -import { - buildYutoriSystemPrompt, -} from "@onkernel/cua-yutori"; -import { - createYutoriComputerTools, - registerYutoriProvider, - yutoriBuiltinToolsOnPayload, -} from "@onkernel/cua-yutori/pi"; +import { createCuaComputerTools, NodeExecutionEnv } from "@onkernel/cua-agent"; +import type { BrowserSession } from "@onkernel/cua-translator"; +import { ComputerTranslator } from "@onkernel/cua-translator"; import { type AnthropicModelConfig, type Config, @@ -84,35 +40,10 @@ import { loadModel as loadSupportedModel, } from "./models"; import { appendSkillsToSystemPrompt, type Skill } from "./skills"; +import type { CuaSessionState } from "./sessions"; -let providersRegistered = false; - -/** - * Eagerly register the providers we use. pi-ai's lazy dynamic-import - * registration breaks under bundlers, so we wire them up at module load. - */ -export function registerProviders(): void { - if (providersRegistered) return; - registerApiProvider({ - api: "openai-responses", - stream: streamOpenAIResponses, - streamSimple: streamSimpleOpenAIResponses, - }); - registerApiProvider({ - api: "openai-completions", - stream: streamOpenAICompletions, - streamSimple: streamSimpleOpenAICompletions, - }); - registerAnthropicProvider(); - registerApiProvider({ - api: "google-generative-ai", - stream: streamGoogle, - streamSimple: streamSimpleGoogle, - }); - registerYutoriProvider(); - registerTzafonProvider(); - providersRegistered = true; -} +const TOOL_INSTRUCTIONS = `Use bash for shell work. Use read, write, edit, grep, find, and ls for workspace files.`; +const TOOL_PREAMBLE_LINE = `Before every tool call, first output a single short sentence describing what you are about to do.`; function mapReasoningEffort(effort: string | undefined): ThinkingLevel { const v = (effort ?? "low").trim().toLowerCase(); @@ -139,14 +70,15 @@ export interface CuaAgentOptions { cwd: string; browser: BrowserSession; config: Config; + session?: CuaSessionState; modelId?: string; // default DEFAULT_MODEL_ID additionalSystemPrompt?: string; skills?: Skill[]; - sessionId?: string; skipCodingTools?: boolean; } export interface CuaAgentHandle { + harness: CuaAgentHarness; agent: Agent; translator: ComputerTranslator; model: Model; @@ -157,16 +89,13 @@ export interface CuaAgentHandle { } /** - * Build a fully wired pi-agent-core Agent for cua: provider registered, + * Build a fully wired CUA AgentHarness for cua-cli: * tools loaded, system prompt set, reasoning effort + auto-compaction * applied via the model config. */ -export function createCuaAgent(opts: CuaAgentOptions): CuaAgentHandle { - registerProviders(); - +export async function createCuaAgent(opts: CuaAgentOptions): Promise { const modelId = opts.modelId ?? DEFAULT_MODEL_ID; const { provider, model: loadedModel } = loadSupportedModel(modelId); - const modelConfig = resolveModelConfigForProvider(provider, opts.config, modelId); const model = applyProviderBaseUrl(provider, loadedModel, opts.config); const thinkingLevel = mapReasoningEffort(modelConfig.reasoningEffort); @@ -178,51 +107,49 @@ export function createCuaAgent(opts: CuaAgentOptions): CuaAgentHandle { sessionId: opts.browser.sessionId, }); + const runtimeInput = toRuntimeInput(provider, model.id); + const runtimeSpec = resolveCuaRuntimeSpec(runtimeInput); + const kernelBrowser = toKernelBrowser(opts.browser); const tools = buildAgentTools({ cwd: opts.cwd, - translator, - provider, + browser: kernelBrowser, + client: opts.browser.client, + toolDefinitions: runtimeSpec.toolDefinitions, skipCodingTools: opts.skipCodingTools, }); - const baseSystemPrompt = buildSystemPromptForProvider(provider, { + const baseSystemPrompt = buildSystemPrompt(runtimeSpec.defaultSystemPrompt, { toolPreamble, additionalSystemPrompt: opts.additionalSystemPrompt, }); const systemPrompt = appendSkillsToSystemPrompt(baseSystemPrompt, opts.skills ?? []); + const sessionState = opts.session ?? (await createEphemeralSessionState(opts.cwd)); - const onPayload = composeOnPayload( - // OpenAI auto-compaction (no-op for Anthropic/Gemini per the model.api guard). - typeof compactThreshold === "number" && compactThreshold > 0 - ? (payload, m) => - m.api === "openai-responses" ? injectContextManagement(payload, compactThreshold) : undefined - : undefined, - provider === "anthropic" - ? createAnthropicContextManagementOnPayload({ - compactThreshold: (modelConfig as AnthropicModelConfig).compactThreshold, - }) - : undefined, - // Anthropic computer-tool spec injection. - anthropicComputerOnPayload, - provider === "yutori" ? yutoriBuiltinToolsOnPayload : undefined, - ); - - const agent = new Agent({ - initialState: { - systemPrompt, - model, - tools, - thinkingLevel, + const harness = new CuaAgentHarness({ + browser: kernelBrowser, + client: opts.browser.client, + env: sessionState.env, + session: sessionState.session, + model, + tools, + thinkingLevel, + systemPrompt, + getApiKeyAndHeaders: async (requestModel) => { + const apiKey = resolveApiKey(providerIdForModelProvider(requestModel.provider), opts.config); + return apiKey ? { apiKey } : undefined; }, - sessionId: opts.sessionId, - getApiKey: () => resolveApiKey(provider, opts.config), - streamFn: wrapAnthropicStream(streamSimple as unknown as StreamFn, { - compactThreshold: provider === "anthropic" ? (modelConfig as AnthropicModelConfig).compactThreshold : undefined, - }), - onPayload, + onPayload: + typeof compactThreshold === "number" && compactThreshold > 0 + ? async (payload, runtimeModel) => + runtimeModel.api === "openai-responses" + ? injectContextManagement(payload, compactThreshold) + : undefined + : undefined, }); + const agent = harness.agent; return { + harness, agent, translator, model, @@ -235,92 +162,49 @@ export function createCuaAgent(opts: CuaAgentOptions): CuaAgentHandle { }; } -// ─── Tool factory ───────────────────────────────────────────────────────── - interface ToolFactoryOptions { cwd: string; - translator: ComputerTranslator; - provider: ProviderId; + browser: Parameters[0]["browser"]; + client: Parameters[0]["client"]; + toolDefinitions: ReturnType["toolDefinitions"]; skipCodingTools?: boolean; } -function buildAgentTools(opts: ToolFactoryOptions): AgentTool[] { - const tools: AgentTool[] = []; - - switch (opts.provider) { - case "anthropic": - tools.push(...createAnthropicComputerTools(opts.translator)); - break; - case "gemini": - tools.push(...createGeminiComputerTools(opts.translator)); - break; - case "tzafon": - tools.push(...createTzafonComputerTools(opts.translator)); - break; - case "yutori": - tools.push(...createYutoriComputerTools(opts.translator)); - break; - case "openai": - default: - tools.push(...createOpenAIComputerTools(opts.translator)); - break; - } +function buildAgentTools(opts: ToolFactoryOptions): AgentTool[] { + const tools: AgentTool[] = createCuaComputerTools({ + browser: opts.browser, + client: opts.client, + toolDefinitions: opts.toolDefinitions, + }); if (!opts.skipCodingTools) { tools.push( - createBashTool(opts.cwd), - createReadTool(opts.cwd), - createEditTool(opts.cwd), - createWriteTool(opts.cwd), - createGrepTool(opts.cwd), - createFindTool(opts.cwd), - createLsTool(opts.cwd), + createBashTool(opts.cwd) as AgentTool, + createReadTool(opts.cwd) as AgentTool, + createEditTool(opts.cwd) as AgentTool, + createWriteTool(opts.cwd) as AgentTool, + createGrepTool(opts.cwd) as AgentTool, + createFindTool(opts.cwd) as AgentTool, + createLsTool(opts.cwd) as AgentTool, ); } return tools; } -// ─── System prompt selection ───────────────────────────────────────────── - -const TOOL_INSTRUCTIONS = `Use bash for shell work. Use read, write, edit, grep, find, and ls for workspace files.`; -const TOOL_PREAMBLE_LINE = `Before every tool call, first output a single short sentence describing what you are about to do.`; - interface SystemPromptOptions { toolPreamble?: boolean; additionalSystemPrompt?: string; } -function buildSystemPromptForProvider(provider: ProviderId, opts: SystemPromptOptions): string { - let preamble: string; - switch (provider) { - case "anthropic": - preamble = buildAnthropicSystemPrompt(); - break; - case "gemini": - preamble = buildGeminiSystemPrompt(); - break; - case "tzafon": - preamble = buildTzafonSystemPrompt(); - break; - case "yutori": - preamble = buildYutoriSystemPrompt({ toolPreamble: false }); - break; - case "openai": - default: - preamble = OPENAI_BATCH_INSTRUCTIONS; - break; - } - - const sections: string[] = [preamble, TOOL_INSTRUCTIONS]; +function buildSystemPrompt(defaultSystemPrompt: string, opts: SystemPromptOptions): string { + const sections: string[] = [defaultSystemPrompt, TOOL_INSTRUCTIONS]; if (opts.toolPreamble !== false) sections.push(TOOL_PREAMBLE_LINE); const extra = (opts.additionalSystemPrompt ?? "").trim(); if (extra) sections.push(extra); return sections.join("\n\n"); } -// ─── Per-provider config resolution + API key routing ──────────────────── - function resolveModelConfigForProvider( provider: ProviderId, cfg: Config, @@ -363,6 +247,37 @@ function applyProviderBaseUrl(provider: ProviderId, model: Model, cfg: Conf return model; } +function providerIdForModelProvider(provider: string): ProviderId { + if (provider === "google") return "gemini"; + if (provider === "openai" || provider === "anthropic" || provider === "tzafon" || provider === "yutori") { + return provider; + } + return "openai"; +} + +function toRuntimeInput(provider: ProviderId, modelId: string): CuaModelRef { + const runtimeProvider = provider === "gemini" ? "google" : provider; + return `${runtimeProvider}:${modelId}` as CuaModelRef; +} + +async function createEphemeralSessionState(cwd: string): Promise { + const repo = new InMemorySessionRepo(); + return { + env: new NodeExecutionEnv({ cwd }), + session: await repo.create(), + resumed: false, + priorMessageCount: 0, + getSessionFile: () => undefined, + }; +} + +function toKernelBrowser(browser: BrowserSession): Parameters[0]["browser"] { + return { + session_id: browser.sessionId, + browser_live_view_url: browser.liveUrl ?? null, + } as Parameters[0]["browser"]; +} + /** * onPayload hook that injects `context_management: [{type:"compaction", compact_threshold:N}]` * into the OpenAI Responses request payload. diff --git a/packages/cua-cli/src/cli.ts b/packages/cua-cli/src/cli.ts index 4491cc83..03b0982d 100644 --- a/packages/cua-cli/src/cli.ts +++ b/packages/cua-cli/src/cli.ts @@ -31,9 +31,7 @@ import { findLatestSession, listSessions, openSession, - persistAgentEvents, resolveSessionPath, - seedAgentFromSession, type SessionInfo, } from "./sessions"; import { discoverCuaSkills, discoverStartupResources, expandSkillInvocation } from "./skills"; @@ -212,7 +210,7 @@ function parseCliArgs(argv: string[]): CliFlags { /** * Load the cua config and verify the keys we need for the requested * provider. The provider comes from the supported model table, matching - * what {@link createCuaAgent} will use at run time. + * what the CUA runtime will use at run time. */ async function loadConfigOrFail(flags: CliFlags): Promise { const cfg = await configMod.load(flags.configProfile); @@ -612,7 +610,7 @@ async function runPrint(prompt: string, flags: CliFlags): Promise { const provision = await provisionBrowser(cfg, flags); const browser = provision.browser; const sessionPolicy = await resolveSessionFlags(flags, cwd, provision.named); - const sm = openSession({ + const sm = await openSession({ cwd, sessionDir: flags.sessionDir, sessionPath: sessionPolicy.sessionPath, @@ -621,24 +619,22 @@ async function runPrint(prompt: string, flags: CliFlags): Promise { const { skills } = discoverCuaSkills({ cwd, extraPaths: flags.skillPaths, disabled: flags.noSkills }); const { expanded, skill: invokedSkill } = expandSkillInvocation(prompt, skills); if (invokedSkill && flags.verbose) stderr.write(`[cua] expanded /skill:${invokedSkill.name}\n`); - const handle = createCuaAgent({ + const handle = await createCuaAgent({ cwd, browser, config: cfg, + session: sm, modelId: flags.model, - sessionId: browser.sessionId, skills, }); - if (sessionPolicy.resumed) seedAgentFromSession(handle.agent, sm); - appendBrowserMetadata(sm, browser); - const unsubscribePersist = persistAgentEvents(handle.agent, sm); + await appendBrowserMetadata(sm, browser); const transcriptPath = sm.getSessionFile(); if (provision.named && transcriptPath) { await recordTranscriptPath(provision.named.name, transcriptPath); } if (flags.verbose) { if (transcriptPath) stderr.write(`[cua] session=${transcriptPath}\n`); - if (sessionPolicy.resumed) stderr.write("[cua] resumed prior session into fresh browser\n"); + if (sm.resumed) stderr.write("[cua] resumed prior session into fresh browser\n"); } const jsonlMode = (flags.output ?? "text").toLowerCase() === "jsonl"; @@ -673,7 +669,7 @@ async function runPrint(prompt: string, flags: CliFlags): Promise { agent: handle.agent, translator: handle.translator, prompt: expanded, - options: { skipInitialScreenshot: sessionPolicy.resumed }, + options: { skipInitialScreenshot: sm.resumed }, }); const agentError = (handle.agent.state as { errorMessage?: string }).errorMessage; if (agentError) { @@ -697,7 +693,6 @@ async function runPrint(prompt: string, flags: CliFlags): Promise { } finally { unsubscribe(); unsubscribeJsonl?.(); - unsubscribePersist(); try { await handle.dispose(); } catch (err) { @@ -724,17 +719,17 @@ async function runActionSub(action: ActionType, rest: string[], flags: CliFlags) // For named sessions the transcript should persist across action calls so // external analysis can correlate them. For one-shot subcommand calls - // without a named session we skip the SessionManager entirely. - let sm: ReturnType | undefined; + // without a named session we use an ephemeral in-memory harness session. + let sm: Awaited> | undefined; if (provision.named) { const sessionPolicy = await resolveSessionFlags(flags, cwd, provision.named); - sm = openSession({ + sm = await openSession({ cwd, sessionDir: flags.sessionDir, sessionPath: sessionPolicy.sessionPath, ephemeral: sessionPolicy.ephemeral, }); - appendBrowserMetadata(sm, browser); + await appendBrowserMetadata(sm, browser); const transcriptPath = sm.getSessionFile(); if (transcriptPath) await recordTranscriptPath(provision.named.name, transcriptPath); if (flags.verbose && transcriptPath) stderr.write(`[cua] session=${transcriptPath}\n`); @@ -750,7 +745,7 @@ async function runActionSub(action: ActionType, rest: string[], flags: CliFlags) config: cfg, modelId: flags.model, verbose: flags.verbose, - sessionManager: sm, + sessionState: sm, }, screenshotOut, ); @@ -864,7 +859,7 @@ async function runInteractiveCli(initialPrompt: string, flags: CliFlags): Promis const provision = await provisionBrowser(cfg, flags); const browser = provision.browser; const sessionPolicy = await resolveSessionFlags(flags, cwd, provision.named); - const sm = openSession({ + const sm = await openSession({ cwd, sessionDir: flags.sessionDir, sessionPath: sessionPolicy.sessionPath, @@ -888,8 +883,8 @@ async function runInteractiveCli(initialPrompt: string, flags: CliFlags): Promis verbose: flags.verbose, debugTui: flags.debugTui, imageProtocol: flags.imageProtocol, - sessionManager: sm, - resumed: sessionPolicy.resumed, + sessionState: sm, + resumed: sm.resumed, skills: startupResources.skills, startupResources, }); diff --git a/packages/cua-cli/src/models.ts b/packages/cua-cli/src/models.ts index 650b2bd1..a120480e 100644 --- a/packages/cua-cli/src/models.ts +++ b/packages/cua-cli/src/models.ts @@ -1,9 +1,4 @@ -import { - type Api, - type Model, - getModel, - getModels, -} from "@mariozechner/pi-ai"; +import { type Api, type CuaModelRef, getCuaModel, listCuaModels, type Model } from "@onkernel/cua-ai"; export type ProviderId = "openai" | "anthropic" | "gemini" | "tzafon" | "yutori"; export const SUPPORTED_PROVIDERS: ProviderId[] = ["openai", "anthropic", "gemini", "tzafon", "yutori"]; @@ -13,7 +8,7 @@ export interface SupportedModel { provider: ProviderId; model: string; name: string; - origin: "cua-override" | "pi-ai-registry"; + origin: "cua-override" | "cua-ai-registry"; default?: boolean; } @@ -46,16 +41,17 @@ export function listSupportedModels(provider?: ProviderId): SupportedModel[] { byKey.set(modelKey(entry.provider, entry.model), entry); } - for (const model of getModels(piProviderFor(p) as never) as Model[]) { - if (!supportsCuaProvider(p, model.id)) continue; - const key = modelKey(p, model.id); + for (const model of listCuaModels(toCuaProvider(p))) { + const normalizedProvider = fromCuaProvider(model.provider); + if (normalizedProvider !== p) continue; + const key = modelKey(normalizedProvider, model.model); if (byKey.has(key)) continue; byKey.set(key, { - provider: p, - model: model.id, + provider: normalizedProvider, + model: model.model, name: model.name, - origin: "pi-ai-registry", - default: model.id === DEFAULT_MODEL_ID, + origin: "cua-ai-registry", + default: model.model === DEFAULT_MODEL_ID, }); } } @@ -73,17 +69,11 @@ export function resolveProvider(modelId: string): ProviderId { export function loadModel(modelId: string): { provider: ProviderId; model: Model } { const provider = resolveProvider(modelId); - const piProvider = piProviderFor(provider); - const fromRegistry = getModel(piProvider as never, modelId as never) as Model | undefined; - if (fromRegistry) return { provider, model: fromRegistry }; - - // Provider model lists can expose working model IDs before pi-ai's generated - // registry catches up. Use conservative metadata so the request can still - // reach the provider; cost telemetry stays zero until the registry updates. - return { provider, model: dynamicModel(provider, modelId) }; + const modelRef = `${toCuaProvider(provider)}:${modelId}` as CuaModelRef; + return { provider, model: getCuaModel(modelRef) }; } -export function piProviderFor(provider: ProviderId): string { +export function toCuaProvider(provider: ProviderId): "openai" | "anthropic" | "google" | "tzafon" | "yutori" { switch (provider) { case "openai": return "openai"; @@ -98,108 +88,20 @@ export function piProviderFor(provider: ProviderId): string { } } -function supportsCuaProvider(provider: ProviderId, modelId: string): boolean { - const id = modelId.toLowerCase(); +function fromCuaProvider(provider: string): ProviderId { switch (provider) { + case "google": + return "gemini"; case "openai": - return /^gpt-5\.(4|5)(?:-|$)/.test(id); case "anthropic": - return ( - id.startsWith("claude-3-7-sonnet") || - id.startsWith("claude-opus-4") || - id.startsWith("claude-sonnet-4") || - id.startsWith("claude-haiku-4") - ); - case "gemini": - return id === "gemini-3-flash-preview" || id === "gemini-2.5-computer-use-preview-10-2025"; case "tzafon": - return id === "tzafon.northstar-cua-fast"; case "yutori": - return id === "n1-latest" || id === "n1-20260203" || id === "n1.5-latest" || id === "n1.5-20260428"; - } -} - -function dynamicModel(provider: ProviderId, modelId: string): Model { - const piProvider = piProviderFor(provider); - switch (provider) { - case "anthropic": - return { - id: modelId, - name: modelId, - api: "anthropic-messages", - provider: piProvider, - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: zeroCost(), - contextWindow: 200_000, - maxTokens: 64_000, - }; - case "gemini": - return { - id: modelId, - name: modelId, - api: "google-generative-ai", - provider: piProvider, - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: zeroCost(), - contextWindow: 1_048_576, - maxTokens: 65_536, - }; - case "tzafon": - return { - id: modelId, - name: modelId, - api: "tzafon-responses", - provider: piProvider, - baseUrl: "https://api.lightcone.ai", - reasoning: false, - input: ["text", "image"], - cost: zeroCost(), - contextWindow: 128_000, - maxTokens: 4_096, - }; - case "yutori": - return { - id: modelId, - name: modelId, - api: "yutori-chat-completions", - provider: piProvider, - baseUrl: "https://api.yutori.com/v1", - reasoning: false, - input: ["text", "image"], - cost: zeroCost(), - contextWindow: 128_000, - maxTokens: 4_096, - }; - case "openai": + return provider; default: - return { - id: modelId, - name: modelId, - api: "openai-responses", - provider: piProvider, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: zeroCost(), - contextWindow: 400_000, - maxTokens: 32_768, - }; + throw new Error(`unsupported CUA provider "${provider}"`); } } -function zeroCost(): Model["cost"] { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }; -} - function modelKey(provider: ProviderId, model: string): string { return `${provider}:${model}`; } diff --git a/packages/cua-cli/src/output/jsonl.ts b/packages/cua-cli/src/output/jsonl.ts index b0fe5e34..066a0ecc 100644 --- a/packages/cua-cli/src/output/jsonl.ts +++ b/packages/cua-cli/src/output/jsonl.ts @@ -1,4 +1,4 @@ -import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core"; +import type { Agent, AgentEvent } from "@onkernel/cua-agent"; import type { BrowserSession } from "@onkernel/cua-translator"; export interface JsonlSinkOptions { diff --git a/packages/cua-cli/src/sessions.ts b/packages/cua-cli/src/sessions.ts index 08307675..e6be565a 100644 --- a/packages/cua-cli/src/sessions.ts +++ b/packages/cua-cli/src/sessions.ts @@ -1,8 +1,15 @@ -import type { Agent, AgentMessage } from "@mariozechner/pi-agent-core"; -import { SessionManager, type SessionInfo } from "@mariozechner/pi-coding-agent"; +import { + type AgentMessage, + InMemorySessionRepo, + JsonlSessionRepo, + NodeExecutionEnv, + type Session, + type SessionTreeEntry, +} from "@onkernel/cua-agent"; import type { BrowserSession } from "@onkernel/cua-translator"; +import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; /** Custom entry types we persist alongside the LLM transcript. */ export const CUA_BROWSER_ENTRY = "cua-browser"; @@ -14,6 +21,34 @@ export interface CuaBrowserMetadata { createdAt: number; } +interface SessionHeader { + type: "session"; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +export interface SessionInfo { + path: string; + id: string; + cwd: string; + name?: string; + parentSessionPath?: string; + created: Date; + modified: Date; + messageCount: number; + firstMessage: string; +} + +export interface CuaSessionState { + env: NodeExecutionEnv; + session: Session; + resumed: boolean; + priorMessageCount: number; + getSessionFile(): string | undefined; +} + /** Resolve the default sessions directory: $XDG_DATA_HOME/cua/sessions or ~/.local/share/cua/sessions. */ export function defaultSessionDir(): string { const xdg = process.env.XDG_DATA_HOME; @@ -32,28 +67,61 @@ export interface OpenSessionOptions { } /** - * Open a SessionManager for the requested mode. + * Open a harness-native session for the requested mode. * * ephemeral=true → in-memory, never touches disk. * sessionPath set → attach to an existing session file. * default → create a fresh session in the resolved sessionDir. */ -export function openSession(opts: OpenSessionOptions): SessionManager { +export async function openSession(opts: OpenSessionOptions): Promise { + const env = new NodeExecutionEnv({ cwd: opts.cwd }); if (opts.ephemeral) { - return SessionManager.inMemory(opts.cwd); + const repo = new InMemorySessionRepo(); + const session = await repo.create(); + const context = await session.buildContext(); + return { + env, + session, + resumed: context.messages.length > 0, + priorMessageCount: context.messages.length, + getSessionFile: () => undefined, + }; } + const dir = opts.sessionDir ?? defaultSessionDir(); - const sm = SessionManager.create(opts.cwd, dir); + const repo = new JsonlSessionRepo({ sessionsRoot: dir }); + if (opts.sessionPath) { - sm.setSessionFile(opts.sessionPath); + const metadata = await readSessionMetadata(resolveSessionPathArg(opts.cwd, opts.sessionPath)); + const session = await repo.open(metadata); + const context = await session.buildContext(); + return { + env, + session, + resumed: context.messages.length > 0, + priorMessageCount: context.messages.length, + getSessionFile: () => metadata.path, + }; } - return sm; + + const session = await repo.create({ cwd: opts.cwd }); + const metadata = await session.getMetadata(); + const context = await session.buildContext(); + return { + env, + session, + resumed: context.messages.length > 0, + priorMessageCount: context.messages.length, + getSessionFile: () => "path" in metadata ? metadata.path : undefined, + }; } /** List sessions for a cwd from the resolved sessions directory. */ -export function listSessions(cwd: string, sessionDir?: string): Promise { +export async function listSessions(cwd: string, sessionDir?: string): Promise { const dir = sessionDir ?? defaultSessionDir(); - return SessionManager.list(cwd, dir); + const repo = new JsonlSessionRepo({ sessionsRoot: dir }); + const sessions = await repo.list({ cwd }); + return await Promise.all(sessions.map(async (metadata) => await toSessionInfo(repo, metadata))); } /** @@ -65,7 +133,9 @@ export function listSessions(cwd: string, sessionDir?: string): Promise { const trimmed = input.trim(); if (!trimmed) throw new Error("session reference is empty"); - if (trimmed.includes("/") || trimmed.endsWith(".jsonl")) return trimmed; + if (trimmed.includes("/") || trimmed.endsWith(".jsonl")) { + return resolveSessionPathArg(cwd, trimmed); + } const sessions = await listSessions(cwd, sessionDir); if (sessions.length === 0) throw new Error("no sessions found"); if (trimmed === "latest") { @@ -87,32 +157,8 @@ export async function findLatestSession(cwd: string, sessionDir?: string): Promi return [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime())[0]; } -/** - * Subscribe to the agent and persist user/assistant/toolResult messages - * to the SessionManager as they complete. Returns an unsubscribe. - */ -export function persistAgentEvents(agent: Agent, sm: SessionManager): () => void { - return agent.subscribe((event) => { - if (event.type !== "message_end") return; - const msg = event.message as AgentMessage; - if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "toolResult") return; - try { - sm.appendMessage(msg as never); - } catch { - // ignore single-message persistence failures - } - }); -} - -/** Replay a previously persisted session into a fresh agent's transcript. */ -export function seedAgentFromSession(agent: Agent, sm: SessionManager): void { - const ctx = sm.buildSessionContext(); - if (ctx.messages.length === 0) return; - agent.state.messages = ctx.messages; -} - /** Record browser metadata so resume can show what the previous session ran against. */ -export function appendBrowserMetadata(sm: SessionManager, browser: BrowserSession): void { +export async function appendBrowserMetadata(state: CuaSessionState, browser: BrowserSession): Promise { const meta: CuaBrowserMetadata = { sessionId: browser.sessionId, liveUrl: browser.liveUrl, @@ -120,10 +166,88 @@ export function appendBrowserMetadata(sm: SessionManager, browser: BrowserSessio createdAt: Date.now(), }; try { - sm.appendCustomEntry(CUA_BROWSER_ENTRY, meta); + await state.session.appendCustomEntry(CUA_BROWSER_ENTRY, meta); } catch { // non-fatal } } -export type { SessionInfo }; +function resolveSessionPathArg(cwd: string, sessionPath: string): string { + return isAbsolute(sessionPath) ? sessionPath : resolve(cwd, sessionPath); +} + +function textFromAgentMessage(message: AgentMessage): string { + if (message.role !== "user" && message.role !== "assistant") return ""; + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter( + (block): block is { type: "text"; text: string } => + typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string", + ) + .map((block) => block.text) + .join("\n"); +} + +async function readSessionMetadata(filePath: string): Promise<{ + id: string; + createdAt: string; + cwd: string; + path: string; + parentSessionPath?: string; +}> { + const resolvedPath = resolve(filePath); + const content = await readFile(resolvedPath, "utf8"); + const headerLine = content.split("\n").find((line) => line.trim().length > 0); + if (!headerLine) { + throw new Error(`invalid session file "${resolvedPath}": missing header`); + } + let header: SessionHeader; + try { + header = JSON.parse(headerLine) as SessionHeader; + } catch { + throw new Error(`invalid session file "${resolvedPath}": malformed header`); + } + if (header.type !== "session" || !header.id || !header.timestamp || !header.cwd) { + throw new Error(`invalid session file "${resolvedPath}": malformed session metadata`); + } + return { + id: header.id, + createdAt: header.timestamp, + cwd: header.cwd, + path: resolvedPath, + parentSessionPath: header.parentSession, + }; +} + +async function toSessionInfo( + repo: JsonlSessionRepo, + metadata: { + id: string; + createdAt: string; + cwd: string; + path: string; + parentSessionPath?: string; + }, +): Promise { + const session = await repo.open(metadata); + const branch = await session.getBranch(); + const messageEntries = branch.filter( + (entry): entry is Extract => entry.type === "message", + ); + const firstMessage = messageEntries.find((entry) => entry.message.role === "user") ?? messageEntries[0]; + const messageText = firstMessage ? textFromAgentMessage(firstMessage.message).trim() : ""; + const fileStats = await stat(metadata.path); + return { + path: metadata.path, + id: metadata.id, + cwd: metadata.cwd, + name: await session.getSessionName(), + parentSessionPath: metadata.parentSessionPath, + created: new Date(metadata.createdAt), + modified: fileStats.mtime, + messageCount: messageEntries.length, + firstMessage: messageText, + }; +} diff --git a/packages/cua-cli/src/tui/driver.ts b/packages/cua-cli/src/tui/driver.ts index 930a8b27..457a89d2 100644 --- a/packages/cua-cli/src/tui/driver.ts +++ b/packages/cua-cli/src/tui/driver.ts @@ -1,4 +1,4 @@ -import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core"; +import type { Agent, AgentEvent } from "@onkernel/cua-agent"; import type { CuaAgentHandle } from "../agent"; import { promptWithScreenshot } from "../agent-prompt"; diff --git a/packages/cua-cli/src/tui/main.ts b/packages/cua-cli/src/tui/main.ts index fe781536..843bac36 100644 --- a/packages/cua-cli/src/tui/main.ts +++ b/packages/cua-cli/src/tui/main.ts @@ -11,9 +11,7 @@ import { TUI, TUI_KEYBINDINGS, } from "@mariozechner/pi-tui"; -import type { SessionManager } from "@mariozechner/pi-coding-agent"; import type { ResourceDiagnostic } from "@mariozechner/pi-coding-agent"; -import { anthropicSupportsCompaction } from "@onkernel/cua-anthropic"; import type { BrowserSession } from "@onkernel/cua-translator"; import { homedir } from "node:os"; import { relative } from "node:path"; @@ -21,11 +19,7 @@ import { stderr } from "node:process"; import { createCuaAgent } from "../agent"; import type { Config } from "../config"; import { DEFAULT_MODEL_ID, resolveProvider } from "../models"; -import { - appendBrowserMetadata, - persistAgentEvents, - seedAgentFromSession, -} from "../sessions"; +import { appendBrowserMetadata, type CuaSessionState } from "../sessions"; import { expandSkillInvocation, type Skill, type StartupResources } from "../skills"; import { openTuiDebugLog } from "./debug-log"; import { applyAndSummarizeImageProtocol } from "./diagnostics"; @@ -45,8 +39,8 @@ export interface InteractiveOptions { verbose?: boolean; /** Image protocol override: kitty | iterm2 | none | auto (default: auto). */ imageProtocol?: string; - /** Optional session manager for transcript persistence. */ - sessionManager?: SessionManager; + /** Optional harness-native session state for transcript persistence. */ + sessionState?: CuaSessionState; /** True when seeding the agent from a previously persisted session. */ resumed?: boolean; /** Skills available for /skill:name expansion and system-prompt injection. */ @@ -95,12 +89,12 @@ export async function runInteractive(opts: InteractiveOptions): Promise void _keybindings; const liveHandle = opts.driver ? undefined - : createCuaAgent({ + : await createCuaAgent({ cwd: opts.cwd, browser: opts.browser, config: opts.config, + session: opts.sessionState, modelId: opts.modelId, - sessionId: opts.browser.sessionId, skills: opts.skills, }); const editor = new Editor(tui, editorTheme); @@ -153,15 +147,14 @@ export async function runInteractive(opts: InteractiveOptions): Promise }; let unsubscribePersist = () => {}; - const sm = opts.sessionManager; - if (liveHandle && sm && opts.resumed) seedAgentFromSession(liveHandle.agent, sm); - if (liveHandle && sm) appendBrowserMetadata(sm, opts.browser); + const sm = opts.sessionState; + if (liveHandle && sm) await appendBrowserMetadata(sm, opts.browser); if (liveHandle && sm && opts.resumed) { messages.addNotice( - `resumed from ${sm.getSessionFile() ?? "memory"} · ${liveHandle.agent.state.messages.length} prior messages · fresh browser`, + `resumed from ${sm.getSessionFile() ?? "memory"} · ${sm.priorMessageCount} prior messages · fresh browser`, ); } - unsubscribePersist = liveHandle && sm ? persistAgentEvents(liveHandle.agent, sm) : () => {}; + unsubscribePersist = () => {}; let driver: InteractiveDriver = opts.driver ?? new LiveInteractiveDriver(liveHandle!, { skipInitialScreenshot: opts.resumed === true }); @@ -384,8 +377,7 @@ function isAutoCompactEnabled( ? (handle.modelConfig as { compactThreshold?: unknown }).compactThreshold : undefined; if (handle?.provider === "anthropic") { - const modelId = typeof handle.model?.id === "string" ? handle.model.id : ""; - return compactThreshold !== false && anthropicSupportsCompaction(modelId); + return compactThreshold !== false; } return typeof compactThreshold === "number" && compactThreshold > 0; } diff --git a/packages/cua-cli/src/tui/testing/fixture.test.ts b/packages/cua-cli/src/tui/testing/fixture.test.ts index bdbe6802..ecff5d0c 100644 --- a/packages/cua-cli/src/tui/testing/fixture.test.ts +++ b/packages/cua-cli/src/tui/testing/fixture.test.ts @@ -4,12 +4,12 @@ import { fileURLToPath } from "node:url"; import { KeyCtrlC, KeyEnter, spawnSession } from "@onkernel/ptywright"; function spawnFixture() { - const fixtureMain = fileURLToPath(new URL("./fixture-main.js", import.meta.url)); + const fixtureMain = fileURLToPath(new URL("../../../src/tui/testing/fixture-main.ts", import.meta.url)); const fixtureJson = fileURLToPath(new URL("../../../src/tui/testing/fixtures/basic.json", import.meta.url)); const cwd = fileURLToPath(new URL("../../../", import.meta.url)); return spawnSession({ command: process.execPath, - args: [fixtureMain, fixtureJson], + args: ["--conditions=source", "--import", "tsx", fixtureMain, fixtureJson], cwd, cols: 160, rows: 40, diff --git a/packages/cua-cli/src/tui/testing/live.e2e.test.ts b/packages/cua-cli/src/tui/testing/live.e2e.test.ts new file mode 100644 index 00000000..96bcd637 --- /dev/null +++ b/packages/cua-cli/src/tui/testing/live.e2e.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { KeyCtrlC, spawnSession } from "@onkernel/ptywright"; + +const LIVE = process.env.CUA_CLI_E2E_LIVE === "1"; +const OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const KERNEL_API_KEY = process.env.KERNEL_API_KEY; +const SHOULD_RUN = LIVE && !!OPENAI_API_KEY && !!KERNEL_API_KEY; + +test( + "live TUI e2e: openai model executes tool call and renders assistant output", + { skip: !SHOULD_RUN, timeout: 240_000 }, + async (t) => { + const xdgConfigHome = await writeLiveConfig(); + t.after(async () => { + await rm(xdgConfigHome, { recursive: true, force: true }); + }); + + const session = spawnLiveSession(xdgConfigHome); + t.after(() => { + session.close(); + }); + + const expectedOutput = "LIVE_UI_TOOL_PASS_7b21"; + const expectedOutputBase64 = Buffer.from(expectedOutput, "utf8").toString("base64"); + const prompt = [ + "Call batch_computer_actions exactly once.", + 'Pass this exact arguments JSON: {"actions":[{"type":"screenshot"}]}', + `Then decode this base64 string and reply with only the decoded text: ${expectedOutputBase64}`, + "Do not include any additional words.", + ].join("\n"); + + await session.waitForVisible("openai/gpt-5.5", { timeoutMs: 30_000 }); + session.line(prompt); + + await session.waitForVisible("batch_computer_actions", { timeoutMs: 120_000 }); + await session.waitForVisible(expectedOutput, { timeoutMs: 120_000 }); + await session.waitForStable(1_000, { timeoutMs: 20_000 }); + + const snapshot = session.snapshot(); + assert.match(snapshot.visible, /assistant[\s\S]*LIVE_UI_TOOL_PASS_7b21/); + assert.doesNotMatch(snapshot.visible, /tool.*error/i); + + await exitSession(session); + }, +); + +function spawnLiveSession(xdgConfigHome: string) { + const cliEntry = fileURLToPath(new URL("../../../src/cli.ts", import.meta.url)); + const cwd = fileURLToPath(new URL("../../../", import.meta.url)); + return spawnSession({ + command: process.execPath, + args: ["--conditions=source", "--import", "tsx", cliEntry, "--model", "gpt-5.5", "--no-skills", "--config-profile", "live"], + cwd, + cols: 180, + rows: 48, + env: { + ...process.env, + XDG_CONFIG_HOME: xdgConfigHome, + }, + }); +} + +async function writeLiveConfig(): Promise { + const root = await mkdtemp(join(tmpdir(), "cua-cli-live-e2e-")); + const cuaConfigDir = join(root, "cua"); + const configPath = join(cuaConfigDir, "config.toml"); + await mkdir(cuaConfigDir, { recursive: true }); + await writeFile( + configPath, + [ + 'default_profile = "live"', + "", + "[profiles.live]", + `openai_api_key = "${OPENAI_API_KEY ?? ""}"`, + `kernel_api_key = "${KERNEL_API_KEY ?? ""}"`, + "", + ].join("\n"), + "utf8", + ); + return root; +} + +async function exitSession(session: ReturnType): Promise { + session.press(KeyCtrlC); + try { + await session.waitForExit({ timeoutMs: 1_500 }); + return; + } catch { + // fallthrough + } + + try { + await session.waitForVisible("aborted", { timeoutMs: 2_500 }); + } catch { + // a settled turn may not emit an explicit abort notice + } + + await session.waitForStable(100, { timeoutMs: 5_000 }); + session.press(KeyCtrlC); + await session.waitForExit({ timeoutMs: 7_000 }); +} diff --git a/packages/cua-cli/src/tui/testing/scripted-driver.ts b/packages/cua-cli/src/tui/testing/scripted-driver.ts index b471ec5b..9b5bbc47 100644 --- a/packages/cua-cli/src/tui/testing/scripted-driver.ts +++ b/packages/cua-cli/src/tui/testing/scripted-driver.ts @@ -1,4 +1,4 @@ -import type { AgentEvent } from "@mariozechner/pi-agent-core"; +import type { AgentEvent } from "@onkernel/cua-agent"; import type { AssistantMessage, Usage } from "@mariozechner/pi-ai"; import type { InteractiveDriver, InteractiveDriverListener } from "../driver"; diff --git a/packages/cua-cli/tsconfig.json b/packages/cua-cli/tsconfig.json index 5f205254..045a5d73 100644 --- a/packages/cua-cli/tsconfig.json +++ b/packages/cua-cli/tsconfig.json @@ -6,12 +6,9 @@ }, "include": ["src/**/*"], "references": [ + { "path": "../ai" }, + { "path": "../agent" }, { "path": "../cua-translator" }, - { "path": "../cua-openai" }, - { "path": "../cua-anthropic" }, - { "path": "../cua-gemini" }, - { "path": "../cua-tzafon" }, - { "path": "../cua-yutori" }, { "path": "../ptywright" } ] } diff --git a/packages/ptywright/scripts/build-ghostty.mjs b/packages/ptywright/scripts/build-ghostty.mjs index 04bcf558..45460cae 100644 --- a/packages/ptywright/scripts/build-ghostty.mjs +++ b/packages/ptywright/scripts/build-ghostty.mjs @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; import { existsSync, mkdirSync } from "node:fs"; -import { resolve } from "node:path"; +import { delimiter, dirname, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { ensureGhosttySource } from "./ensure-ghostty.mjs"; @@ -12,7 +12,8 @@ const requiredVersion = config.upstream.zigVersion; const ghosttyRoot = await ensureGhosttySource(); const zig = resolveZigBinary(); -const version = run([zig, "version"]).trim(); +const runEnv = withZigPath(zig); +const version = run([zig, "version"], { env: runEnv }).trim(); if (version !== requiredVersion) { throw new Error( `Ghostty requires Zig ${requiredVersion}, but ${zig} reported ${version}. ` + @@ -33,6 +34,7 @@ run([ config.zigGlobalCacheDir, ], { cwd: ghosttyRoot, + env: runEnv, }); function resolveZigBinary() { @@ -45,6 +47,19 @@ function resolveZigBinary() { return "zig"; } +function withZigPath(zigBinary) { + const zigDir = dirname(zigBinary); + const currentPath = process.env.PATH ?? ""; + const pathEntries = currentPath.split(delimiter).filter((entry) => entry.length > 0); + if (!pathEntries.includes(zigDir)) { + pathEntries.unshift(zigDir); + } + return { + ...process.env, + PATH: pathEntries.join(delimiter), + }; +} + function run(command, options = {}) { const result = spawnSync(command[0], command.slice(1), { stdio: "pipe", diff --git a/packages/ptywright/src/index.ts b/packages/ptywright/src/index.ts index 05a21f6a..1028c292 100644 --- a/packages/ptywright/src/index.ts +++ b/packages/ptywright/src/index.ts @@ -1,3 +1,3 @@ -export * from "./keys"; -export * from "./session"; -export * from "./terminal"; +export * from "./keys.js"; +export * from "./session.js"; +export * from "./terminal.js"; diff --git a/packages/ptywright/src/session.ts b/packages/ptywright/src/session.ts index 62eb7e70..e452cc32 100644 --- a/packages/ptywright/src/session.ts +++ b/packages/ptywright/src/session.ts @@ -1,8 +1,8 @@ import { EventEmitter } from "node:events"; import { mkdir, writeFile } from "node:fs/promises"; import { spawn, type IPty } from "node-pty"; -import { KeyEnter, type Key } from "./keys"; -import { createTerminal, type SnapshotOptions, type TerminalSnapshot, type TerminalSurface } from "./terminal"; +import { KeyEnter, type Key } from "./keys.js"; +import { createTerminal, type SnapshotOptions, type TerminalSnapshot, type TerminalSurface } from "./terminal.js"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 40; diff --git a/packages/ptywright/src/terminal.ts b/packages/ptywright/src/terminal.ts index 1ece2a5d..13abe68e 100644 --- a/packages/ptywright/src/terminal.ts +++ b/packages/ptywright/src/terminal.ts @@ -1,4 +1,4 @@ -import { loadNativeBinding, type NativeSnapshot, type NativeTerminalHandle } from "./native-loader"; +import { loadNativeBinding, type NativeSnapshot, type NativeTerminalHandle } from "./native-loader.js"; export interface CreateTerminalOptions { cols: number;