diff --git a/ap-web/src/components/AgentCard.test.tsx b/ap-web/src/components/AgentCard.test.tsx index 695c07df..0263ce08 100644 --- a/ap-web/src/components/AgentCard.test.tsx +++ b/ap-web/src/components/AgentCard.test.tsx @@ -20,6 +20,7 @@ vi.mock("@/components/icons/CodexIcon", () => ({ CodexIcon: stub("codex") })); vi.mock("@/components/icons/CursorIcon", () => ({ CursorIcon: stub("cursor") })); vi.mock("@/components/icons/GooseIcon", () => ({ GooseIcon: stub("goose") })); vi.mock("@/components/icons/NessieIcon", () => ({ NessieIcon: stub("nessie") })); +vi.mock("@/components/icons/OpenCodeIcon", () => ({ OpenCodeIcon: stub("opencode") })); vi.mock("@/components/icons/PiIcon", () => ({ PiIcon: stub("pi") })); vi.mock("lucide-react", () => ({ BotIcon: stub("bot") })); @@ -48,6 +49,7 @@ describe("AgentCard icon selection", () => { // "design-reviewer" must still read as Codex, not fall back to bot. { name: "design-reviewer", harness: "codex", expected: "codex" }, { name: "codex-native-ui", harness: "codex-native", expected: "codex" }, + { name: "opencode-native-ui", harness: "opencode-native", expected: "opencode" }, { name: "claude-native-ui", harness: "claude-native", expected: "claude" }, { name: "pi-native-ui", harness: "pi-native", expected: "pi" }, { name: "cursor-native-ui", harness: "cursor-native", expected: "cursor" }, diff --git a/ap-web/src/components/AgentCard.tsx b/ap-web/src/components/AgentCard.tsx index cda23a6d..42ae85f2 100644 --- a/ap-web/src/components/AgentCard.tsx +++ b/ap-web/src/components/AgentCard.tsx @@ -4,6 +4,7 @@ import { CodexIcon } from "@/components/icons/CodexIcon"; import { CursorIcon } from "@/components/icons/CursorIcon"; import { GooseIcon } from "@/components/icons/GooseIcon"; import { NessieIcon } from "@/components/icons/NessieIcon"; +import { OpenCodeIcon } from "@/components/icons/OpenCodeIcon"; import { PiIcon } from "@/components/icons/PiIcon"; import type { ComponentType, SVGProps } from "react"; import type { AvailableAgent } from "@/hooks/useAvailableAgents"; @@ -28,6 +29,7 @@ function iconForAgent(agent: AvailableAgent): ComponentType { description: null, harness: "pi-native", }, + { + id: "ag_opencode_native", + name: "opencode-native-ui", + description: null, + harness: "opencode-native", + }, { id: "ag_nessie", name: "nessie", @@ -162,6 +168,14 @@ describe("useAvailableAgents", () => { harness: "pi-native", skills: [], }, + { + id: "ag_opencode_native", + name: "opencode-native-ui", + display_name: "OpenCode", + description: null, + harness: "opencode-native", + skills: [], + }, { id: "ag_nessie", name: "nessie", diff --git a/ap-web/src/hooks/useTerminals.test.ts b/ap-web/src/hooks/useTerminals.test.ts index 9f269493..5353fe3a 100644 --- a/ap-web/src/hooks/useTerminals.test.ts +++ b/ap-web/src/hooks/useTerminals.test.ts @@ -553,6 +553,7 @@ describe("isAgentTerminalKey", () => { expect(isAgentTerminalKey("terminal:terminal_tui_main")).toBe(true); expect(isAgentTerminalKey("terminal:terminal_claude_main")).toBe(true); expect(isAgentTerminalKey("terminal:terminal_codex_main")).toBe(true); + expect(isAgentTerminalKey("terminal:terminal_opencode_main")).toBe(true); // pi-native: missing here is what hid the Chat/Terminal pill in // Terminal view (isShellView wrongly true) for Pi sessions. expect(isAgentTerminalKey("terminal:terminal_pi_main")).toBe(true); diff --git a/ap-web/src/hooks/useTerminals.ts b/ap-web/src/hooks/useTerminals.ts index 77b55519..bd18019f 100644 --- a/ap-web/src/hooks/useTerminals.ts +++ b/ap-web/src/hooks/useTerminals.ts @@ -63,6 +63,7 @@ export const AGENT_TERMINAL_IDS: ReadonlySet = new Set([ "terminal_tui_main", "terminal_claude_main", "terminal_codex_main", + "terminal_opencode_main", "terminal_pi_main", "terminal_cursor_main", "terminal_goose_main", diff --git a/ap-web/src/lib/nativeCodingAgents.test.ts b/ap-web/src/lib/nativeCodingAgents.test.ts index f466e36f..14c98edc 100644 --- a/ap-web/src/lib/nativeCodingAgents.test.ts +++ b/ap-web/src/lib/nativeCodingAgents.test.ts @@ -12,6 +12,10 @@ describe("nativeCodingAgentForHarness", () => { expect(nativeCodingAgentForHarness("pi-native")?.key).toBe("pi"); }); + it("resolves the canonical opencode-native harness", () => { + expect(nativeCodingAgentForHarness("opencode-native")?.key).toBe("opencode"); + }); + // The server's harness_kind returns the raw executor.config.harness, so a // `native-pi` agent must fold to the same spec — else fork/switch into it // would miss the terminal-first wrapper labels and render as chat. @@ -33,4 +37,13 @@ describe("nativeWrapperLabelsForAgent", () => { [WRAPPER_LABEL_KEY]: "pi-native-ui", }); }); + + it("stamps terminal-first labels for an opencode-native agent", () => { + expect( + nativeWrapperLabelsForAgent({ name: "my-opencode", harness: "opencode-native" }), + ).toEqual({ + [UI_MODE_LABEL_KEY]: UI_MODE_TERMINAL_VALUE, + [WRAPPER_LABEL_KEY]: "opencode-native-ui", + }); + }); }); diff --git a/ap-web/src/lib/nativeCodingAgents.ts b/ap-web/src/lib/nativeCodingAgents.ts index f4386674..5ff70995 100644 --- a/ap-web/src/lib/nativeCodingAgents.ts +++ b/ap-web/src/lib/nativeCodingAgents.ts @@ -4,7 +4,7 @@ export const WRAPPER_LABEL_KEY = "omnigent.wrapper"; export const UI_MODE_LABEL_KEY = "omnigent.ui"; export const UI_MODE_TERMINAL_VALUE = "terminal"; -export type NativeCodingAgentIconKind = "claude" | "codex" | "pi" | "cursor" | "goose"; +export type NativeCodingAgentIconKind = "claude" | "codex" | "opencode" | "pi" | "cursor" | "goose"; export type NativeCodingAgentCapability = "permissionMode" | "approvalMode"; export interface NativeCodingAgentSpec { @@ -39,6 +39,16 @@ export const NATIVE_CODING_AGENTS = [ sortRank: 20, capabilities: ["approvalMode"], }, + { + key: "opencode", + agentName: "opencode-native-ui", + harness: "opencode-native", + wrapperLabel: "opencode-native-ui", + displayName: "OpenCode", + iconKind: "opencode", + sortRank: 25, + capabilities: ["approvalMode"], + }, { key: "cursor", agentName: "cursor-native-ui", diff --git a/ap-web/src/shell/NewChatDialog.tsx b/ap-web/src/shell/NewChatDialog.tsx index 18bc3f9e..22d7daaf 100644 --- a/ap-web/src/shell/NewChatDialog.tsx +++ b/ap-web/src/shell/NewChatDialog.tsx @@ -71,7 +71,7 @@ import { AgentRowTooltip } from "@/components/AgentHoverCard"; // returns agents newest-registered first (agent_store.list sorts by // created_at desc), so pin the order users expect; any agent not listed // here falls after, in server order. -const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Polly", "Debby"]; +const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "OpenCode", "Cursor", "Pi", "Polly", "Debby"]; // Built-in agents (by name slug) — the long-lived agents the server // ships out of the box. The picker groups these first, then a divider, @@ -80,6 +80,7 @@ const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Polly", "D const BUILTIN_AGENTS = new Set([ "claude-native-ui", // Claude Code "codex-native-ui", // Codex + "opencode-native-ui", // OpenCode "pi-native-ui", // Pi "cursor-native-ui", // Cursor "goose-native-ui", // Goose diff --git a/ap-web/src/shell/SubagentsPanel.test.tsx b/ap-web/src/shell/SubagentsPanel.test.tsx index a8b652fb..4c5ba549 100644 --- a/ap-web/src/shell/SubagentsPanel.test.tsx +++ b/ap-web/src/shell/SubagentsPanel.test.tsx @@ -35,6 +35,9 @@ vi.mock("@/components/icons/ClaudeIcon", () => ({ vi.mock("@/components/icons/CodexIcon", () => ({ CodexIcon: (props: Record) => , })); +vi.mock("@/components/icons/OpenCodeIcon", () => ({ + OpenCodeIcon: (props: Record) => , +})); // Same marker treatment for the local pi glyph so selection assertions stay uniform. vi.mock("@/components/icons/PiIcon", () => ({ PiIcon: (props: Record) => , @@ -331,6 +334,11 @@ describe("SubagentsPanel", () => { labels: { "omnigent.wrapper": "codex-native-ui" }, expectedKind: "codex-native", }, + { + name: "opencode-native wrapper → opencode-native marker", + labels: { "omnigent.wrapper": "opencode-native-ui" }, + expectedKind: "opencode-native", + }, { name: "pi-native wrapper → pi-native marker", labels: { "omnigent.wrapper": "pi-native-ui" }, @@ -505,7 +513,7 @@ describe("SubagentsPanel", () => { expect(within(row).queryByText("thread_child_alpha")).toBeNull(); }); - it("uses native logos for Claude Code and Codex child rows", () => { + it("uses native logos for Claude Code, Codex, and OpenCode child rows", () => { mockChildTree({ conv_root: [ childInfo({ @@ -515,6 +523,13 @@ describe("SubagentsPanel", () => { session_name: "auth-refactor", labels: { "omnigent.wrapper": "codex-native-ui" }, }), + childInfo({ + id: "conv_opencode", + title: "opencode:port-auth-refactor", + tool: "opencode", + session_name: "port-auth-refactor", + labels: { "omnigent.wrapper": "opencode-native-ui" }, + }), childInfo({ id: "conv_claude", title: "claude_code:review-auth-refactor", @@ -531,6 +546,10 @@ describe("SubagentsPanel", () => { expect(codexRow.querySelector('[data-icon="codex"]')).not.toBeNull(); expect(codexRow.querySelector(".lucide-code-2")).toBeNull(); + const opencodeRow = childRow(container, "conv_opencode"); + expect(opencodeRow.querySelector('[data-icon="opencode"]')).not.toBeNull(); + expect(opencodeRow.querySelector(".lucide-code-2")).toBeNull(); + const claudeRow = childRow(container, "conv_claude"); expect(claudeRow.querySelector('[data-icon="claude"]')).not.toBeNull(); expect(claudeRow.querySelector(".lucide-code-2")).toBeNull(); diff --git a/ap-web/src/shell/SubagentsPanel.tsx b/ap-web/src/shell/SubagentsPanel.tsx index 4de33bcb..655aa40f 100644 --- a/ap-web/src/shell/SubagentsPanel.tsx +++ b/ap-web/src/shell/SubagentsPanel.tsx @@ -35,6 +35,7 @@ import { CodexIcon } from "@/components/icons/CodexIcon"; import { CursorIcon } from "@/components/icons/CursorIcon"; import { GooseIcon } from "@/components/icons/GooseIcon"; import { NessieIcon } from "@/components/icons/NessieIcon"; +import { OpenCodeIcon } from "@/components/icons/OpenCodeIcon"; import { OttoIcon } from "@/components/icons/OttoIcon"; import { PiIcon } from "@/components/icons/PiIcon"; import { RunningDot } from "@/components/RunningDot"; @@ -53,6 +54,7 @@ import { AddAgentDialog } from "./AddAgentDialog"; // global and must be preserved across navigation. const SESSION_SCOPED_PARAMS = ["file", "diff", "comment", "view"] as const; const CODEX_NATIVE_SUBAGENT_WRAPPER = "codex-native-ui-subagent"; +const OPENCODE_NATIVE_SUBAGENT_WRAPPER = "opencode-native-ui-subagent"; // Pi children are scaffold (no wrapper label); the spawn title's agent-type head (``tool``) is the signal. const PI_AGENT_NAME = "pi"; type AgentRowIcon = ComponentType>; @@ -305,6 +307,7 @@ function brandChildIcon(child: ChildSessionInfo): AgentRowIcon | null { const nativeAgent = nativeCodingAgentForWrapper(wrapper); if (nativeAgent?.iconKind === "claude") return ClaudeIcon; if (nativeAgent?.iconKind === "codex") return CodexIcon; + if (nativeAgent?.iconKind === "opencode") return OpenCodeIcon; if (nativeAgent?.iconKind === "pi") return PiIcon; if (nativeAgent?.iconKind === "cursor") return CursorIcon; if (nativeAgent?.iconKind === "goose") return GooseIcon; @@ -385,8 +388,11 @@ function childPrimaryLabel(child: ChildSessionInfo): string { // LLM-spawned titles cannot start with "ui:" because the spec validator // rejects "ui" as a sub-agent name. const isUserAdded = child.title?.startsWith("ui:") ?? false; - const isCodexNativeSubagent = child.labels?.[WRAPPER_LABEL_KEY] === CODEX_NATIVE_SUBAGENT_WRAPPER; - if (isCodexNativeSubagent && !isUserAdded) { + const childWrapper = child.labels?.[WRAPPER_LABEL_KEY]; + const isNativeSubagent = + childWrapper === CODEX_NATIVE_SUBAGENT_WRAPPER || + childWrapper === OPENCODE_NATIVE_SUBAGENT_WRAPPER; + if (isNativeSubagent && !isUserAdded) { return child.tool ?? child.title ?? child.id; } let titleTask: string | null = null; @@ -464,15 +470,17 @@ function MainRow({ rootSessionId, isActive }: { rootSessionId: string; isActive: ? ClaudeIcon : nativeAgent?.iconKind === "codex" ? CodexIcon - : nativeAgent?.iconKind === "pi" - ? PiIcon - : nativeAgent?.iconKind === "cursor" - ? CursorIcon - : nativeAgent?.iconKind === "goose" - ? GooseIcon - : isNessie - ? NessieIcon - : BotIcon; + : nativeAgent?.iconKind === "opencode" + ? OpenCodeIcon + : nativeAgent?.iconKind === "pi" + ? PiIcon + : nativeAgent?.iconKind === "cursor" + ? CursorIcon + : nativeAgent?.iconKind === "goose" + ? GooseIcon + : isNessie + ? NessieIcon + : BotIcon; // Native wrappers show the product name (mirroring the sidebar) instead // of the spec's YAML name (e.g. "claude-native-ui"); other agents show // their agent name, with "main" only while the session loads or when it diff --git a/ap-web/src/shell/sidebarNav.test.ts b/ap-web/src/shell/sidebarNav.test.ts index 3fe6e753..f71651e6 100644 --- a/ap-web/src/shell/sidebarNav.test.ts +++ b/ap-web/src/shell/sidebarNav.test.ts @@ -252,6 +252,13 @@ describe("getConversationIconKind", () => { }), ), ).toBe("codex"); + expect( + getConversationIconKind( + conversation("conv_opencode", null, new Date(2026, 4, 14, 9), { + labels: { "omnigent.wrapper": "opencode-native-ui" }, + }), + ), + ).toBe("opencode"); expect( getConversationIconKind( conversation("conv_pi", null, new Date(2026, 4, 14, 9), { diff --git a/ap-web/src/shell/sidebarNav.ts b/ap-web/src/shell/sidebarNav.ts index e360aa49..d2330db4 100644 --- a/ap-web/src/shell/sidebarNav.ts +++ b/ap-web/src/shell/sidebarNav.ts @@ -21,7 +21,15 @@ export const CLAUDE_NATIVE_DEFAULT_LABEL = "Claude Code"; export const CODEX_NATIVE_DEFAULT_LABEL = "Codex"; export const PI_NATIVE_DEFAULT_LABEL = "Pi"; -export type ConversationIconKind = "claude" | "codex" | "pi" | "cursor" | "goose" | "nessie" | null; +export type ConversationIconKind = + | "claude" + | "codex" + | "opencode" + | "pi" + | "cursor" + | "goose" + | "nessie" + | null; // Display label for a session with no title and no native-wrapper name — // shown in the sidebar row and as the browser tab title fallback. diff --git a/ap-web/src/test-setup.ts b/ap-web/src/test-setup.ts index 945ad376..760e128a 100644 --- a/ap-web/src/test-setup.ts +++ b/ap-web/src/test-setup.ts @@ -10,6 +10,9 @@ vi.mock("@/components/icons/ClaudeIcon", () => ({ vi.mock("@/components/icons/CodexIcon", () => ({ CodexIcon: () => null, })); +vi.mock("@/components/icons/OpenCodeIcon", () => ({ + OpenCodeIcon: () => null, +})); vi.mock("@/components/icons/CursorIcon", () => ({ CursorIcon: () => null, })); diff --git a/examples/debby/agents/opencode/config.yaml b/examples/debby/agents/opencode/config.yaml new file mode 100644 index 00000000..a3076a7f --- /dev/null +++ b/examples/debby/agents/opencode/config.yaml @@ -0,0 +1,53 @@ +spec_version: 1 +name: opencode +description: >- + Optional OpenCode native coding-agent perspective for Debby — a + third, implementation-grounded voice used only on explicit request + (OpenCode / coding perspective / three-way debate). Not part of the + default Claude + GPT fanout. + +# Native OpenCode harness (opencode-native). Only usable when the `opencode` +# CLI is installed; Debby's prompt keeps this perspective opt-in. No model is +# pinned, so it runs on whatever provider OpenCode resolves. +executor: + type: omnigent + config: + harness: opencode-native + +os_env: + type: caller_process + cwd: . + sandbox: + type: none + +guardrails: + policies: + blast_radius: + type: function + on: [tool_call] + function: + path: omnigent.inner.nessie.policies.blast_radius + arguments: + gate_pushes: false + +prompt: | + You are the OpenCode perspective of Debby, a brainstorming partner. You are a + native coding agent, so your distinctive value is an implementation-grounded + view: what would actually be involved in building or changing this, where the + real friction is, and what a hands-on engineer would notice. Use your tools to + read reference material as needed; don't create or write files unless the user + explicitly asks — your answer is the deliverable. + + You are dispatched in one of two modes; the message you receive makes which + one clear: + + - ANSWER — you are given a question. Answer it directly and well, leaning on + your implementation-grounded perspective. Be concrete and specific. + + - CRITIQUE (debate) — you are given your own previous answer (or the + question) plus another partner's answer. Engage honestly: name what it gets + right, where it is weak or impractical to build, and what you would change. + Then give your own updated answer. Converge toward what is actually correct. + + Always return a clear, self-contained response. You are one of the voices the + user sees side by side, so make your reasoning legible on its own. diff --git a/examples/debby/config.yaml b/examples/debby/config.yaml index 8415099a..a13c9d5b 100644 --- a/examples/debby/config.yaml +++ b/examples/debby/config.yaml @@ -39,11 +39,17 @@ executor: prompt: | You are Debby, a brainstorming partner with two heads. You never answer a - question from a single model's point of view. You have exactly two plain - sub-agents — neither is a coding agent; each is a pure responder: + question from a single model's point of view. Your two default sub-agents are + plain responders — neither is a coding agent: - `claude` — a Claude responder (claude-sdk harness). - `gpt` — a GPT responder (codex harness). + You also have ONE optional, non-default perspective: + - `opencode` — an OpenCode native coding-agent perspective + (opencode-native harness). Only usable when the `opencode` CLI is + installed. Do NOT dispatch it by default; see "Optional OpenCode + perspective" below. + ## Your default behavior — always fan out to both For EVERY substantive question the user asks, dispatch it to BOTH `claude` @@ -96,12 +102,26 @@ prompt: | Attribute every view to its source. Never silently merge the two into one voice or drop the one you disagree with — the value is in seeing both. + ## Optional OpenCode perspective + + You also have `opencode`, an OPTIONAL OpenCode native coding-agent + perspective. Debby's default product promise stays Claude + GPT — do NOT + dispatch `opencode` by default, and never silently replace `claude` or `gpt` + with it. Use it ONLY when the user explicitly asks for OpenCode, a + coding-agent perspective, a three-way debate, or an implementation-grounded + critique. When you do include it, present it as a third attributed voice + (e.g. `## 🟢 OpenCode`) alongside the other two — never folded into one. If + the user asks for OpenCode but the `opencode` CLI is not installed, say so and + fall back to the default two heads. + ## The `debate` skill When the user asks the partners to debate, argue, critique, stress-test, or converge — or types `/debate` — load and follow the `debate` skill. It has you relay each partner's answer to the other for criticism across a configurable number of rounds (default 1) before converging on a synthesis. + The default debate stays two-way (Claude + GPT). Only include `opencode` as a + third debater when the user explicitly asks for a three-way / OpenCode debate. ## Style @@ -142,7 +162,10 @@ guardrails: tools: # The two brainstorming partners — see agents//. Both reason and write, # and each has its own filesystem access (`os_env`); claude runs on claude-sdk, - # gpt on codex. + # gpt on codex. `opencode` is an OPTIONAL third perspective (native + # opencode-native harness); it is declared so the orchestrator CAN reach it on + # explicit request, but the prompt keeps the default fanout to claude + gpt. agents: - claude - gpt + - opencode diff --git a/examples/polly/agents/codex/config.yaml b/examples/polly/agents/codex/config.yaml index df816641..5da51a3e 100644 --- a/examples/polly/agents/codex/config.yaml +++ b/examples/polly/agents/codex/config.yaml @@ -10,6 +10,14 @@ executor: # with full bypass. The server translates this into # ``--dangerously-bypass-approvals-and-sandbox`` (native sub-agents only). yolo: true + # Opt this worker into a runtime harness override: the orchestrator may + # pass ``args.harness: opencode-native`` to sys_session_send to run this + # same scoped-task worker on OpenCode instead of Codex when a different + # vendor is wanted for cross-review. Only harnesses listed here are + # accepted; both runner dispatch and the server create route enforce it. + allowed_harnesses: + - codex-native + - opencode-native prompt: | You are Codex, a coding sub-agent dispatched by the polly diff --git a/examples/polly/agents/opencode/config.yaml b/examples/polly/agents/opencode/config.yaml new file mode 100644 index 00000000..7db383c7 --- /dev/null +++ b/examples/polly/agents/opencode/config.yaml @@ -0,0 +1,61 @@ +spec_version: 1 +name: opencode +description: OpenCode coding sub-agent — implements, cross-vendor reviews, or explores a scoped task in its own worktree. Optional; only available when the `opencode` CLI is installed. + +executor: + type: omnigent + config: + harness: opencode-native + # OpenCode runs its own tools inside the native server. Headless workers + # can't answer approval cards, so the runner-side forwarder admits + # permission requests by policy (allow when no policy is configured), + # matching the codex headless worker posture — there is no `yolo` flag to + # translate for opencode-native. + +prompt: | + You are OpenCode, a coding sub-agent dispatched by the polly orchestrator for + a single scoped task in a dedicated git worktree. Your task prompt names its + purpose — IMPLEMENT, REVIEW, or EXPLORE. Do exactly that one thing; don't + refactor or wander unprompted. + + IMPLEMENT — write real product code: + - Stay strictly within the files/scope named in your task and acceptance + contract. + - Make the change, then drive it to green: run the relevant tests, lint, and + typecheck for the code you touched. + - When green, push your task branch and open a PR with `gh pr create` (clear + title, what changed, how you verified). Never push to a protected branch + (e.g. main) or force-push — open a PR and let it be reviewed/merged. + + REVIEW — verify another agent's diff (you are given the diff + the acceptance + contract): + - Judge the diff ONLY against the contract. Do NOT edit code — surface issues + for the orchestrator to route. + - Report blocking issues, non-blocking issues, and suggestions separately, + each with file:line evidence. + + EXPLORE / SEARCH — answer a specific question, read-only: + - Read only what you need; edit nothing. Answer with file:line evidence. + + Always return a clear, structured result: for IMPLEMENT, what you changed + (file:line) and how you verified it; for REVIEW / EXPLORE, your findings. + Note anything that did not fit the task. + +os_env: + type: caller_process + cwd: . + sandbox: + type: none + +# Implementers open their own PRs, so push / gh pr create are allowed +# (gate_pushes: false). Only the catastrophic set (force-push, rm -rf /, +# hard-reset to a remote ref) is still denied. +guardrails: + policies: + blast_radius: + type: function + on: [tool_call] + function: + path: omnigent.inner.nessie.policies.blast_radius + arguments: + gate_pushes: false diff --git a/examples/polly/config.yaml b/examples/polly/config.yaml index 78409ab5..fb3bf896 100644 --- a/examples/polly/config.yaml +++ b/examples/polly/config.yaml @@ -2,7 +2,8 @@ spec_version: 1 name: polly description: >- A coding orchestrator that breaks your goal into pieces and hands them - to a team of Claude Code, Codex, and Pi sub-agents to build. Polly + to a team of Claude Code, Codex, Pi, and (optionally) OpenCode sub-agents + to build. Polly writes no code itself — it plans and splits up the work, delegates all of it (investigation / implementation / review), then has a separate independent different-model reviewer double-check the work before @@ -56,21 +57,28 @@ prompt: | a "docs" task starts requiring code changes or real code investigation, STOP and delegate that part. - You have exactly THREE sub-agents. `claude_code` and `codex` are real CLI - coding harnesses that run in their own terminal — the human can open either - in the UI's Subagents panel and watch or TAKE OVER. `pi` runs headless (no - terminal to open): + You have up to FOUR sub-agents. `claude_code`, `codex`, and `opencode` are + real CLI coding harnesses that run in their own terminal — the human can open + any of them in the UI's Subagents panel and watch or TAKE OVER. `pi` runs + headless (no terminal to open): - `claude_code` — Claude Code (`claude-native` harness). - `codex` — Codex (`codex-native` harness). - `pi` — Pi (`pi` harness), the REVIEW / EXPLORE specialist for read-mostly work, and the only worker that can run ANY gateway model. + - `opencode` — OpenCode (`opencode-native` harness), a native coding harness + with terminal-takeover support. OPTIONAL: only usable when the `opencode` + CLI is installed (see the roster preflight). Useful as a fourth vendor for + cross-review. Roster preflight (FIRST turn, before any dispatch). Each worker needs its own - CLI on PATH — `claude` for `claude_code`, `codex` for `codex`, `pi` for `pi`. - Before delegating anything, run exactly ONE - `sys_os_shell("command -v claude codex pi || true")`. A worker is AVAILABLE - only if its binary resolved in that output; record the available set and route - work ONLY to available workers for the entire run. Do this in the same first + CLI on PATH — `claude` for `claude_code`, `codex` for `codex`, `pi` for `pi`, + `opencode` for `opencode`. Before delegating anything, run exactly ONE + `sys_os_shell("command -v claude codex pi opencode || true")`. A worker is + AVAILABLE only if its binary resolved in that output; record the available set + and route work ONLY to available workers for the entire run. `opencode` is + optional — if it is absent, do not mention it (it is not part of the default + promise); only name a MISSING `claude_code` / `codex` / `pi`. Do this in the + same first turn you start planning (the preflight `sys_os_shell` call counts as acting — don't end the turn on the preflight alone). The preflight is SILENT internal plumbing: do not announce it, explain it, or report its result in chat — when @@ -92,7 +100,7 @@ prompt: | turn runs on; it does NOT constrain how many sub-agents you spawn, which workers, or which models they get. Those choices stay entirely yours. - Delegate ALL coding work through these three via `sys_session_send`, each + Delegate ALL coding work through these workers via `sys_session_send`, each launched in its OWN git worktree. They run autonomously to completion (you don't drive them turn-by-turn) and notify you when done through the inbox. Collect finished worker results with `sys_read_inbox`; use @@ -145,12 +153,22 @@ prompt: | wanted. Cross-vendor verification is the point: review is ALWAYS done by a DIFFERENT - vendor than the implementer — `claude_code`'s PR is reviewed by `codex` or - `pi`, `codex`'s by `claude_code` or `pi`. Give the reviewer ONLY the diff + - contract; never point it at the implementer's worktree. Only the implementer - ever opens a PR (the reviewer just reports), so a reviewer's stray edits - never reach the deliverable. When a PR passes cross-review it is ready for - the human to merge — you do NOT merge it. + vendor than the implementer — `claude_code`'s PR is reviewed by `codex`, + `pi`, or `opencode`; `codex`'s by `claude_code`, `pi`, or `opencode`. Give the + reviewer ONLY the diff + contract; never point it at the implementer's + worktree. Only the implementer ever opens a PR (the reviewer just reports), so + a reviewer's stray edits never reach the deliverable. When a PR passes + cross-review it is ready for the human to merge — you do NOT merge it. + + Track BOTH the harness AND the resolved model provider when picking a + reviewer. A reviewer is strongest when it differs from the implementer in + model provider, not just harness. `opencode` adds a fourth harness, but a + harness alone is not a vendor: `opencode` driving an OpenAI model is NOT an + independent cross-vendor reviewer for `codex` (also OpenAI), and `opencode` + driving Anthropic is NOT independent of `claude_code` (also Anthropic). Prefer + a different model provider when one is available; if you only have a different + harness on the SAME provider, say so and treat the review as weaker than a + full cross-vendor review. Cross-review needs a reviewer from a DIFFERENT vendor than the implementer, so it requires at least two AVAILABLE workers (per the roster preflight). If @@ -173,7 +191,7 @@ prompt: | terminal via `sys_terminal_launch` (it accepts a `cwd` override so you can run it inside a per-task worktree). NEVER use this terminal to launch coding agents / sub-agents — all delegation goes through `sys_session_send` to - `claude_code` / `codex` / `pi`. + `claude_code` / `codex` / `pi` / `opencode`. For external context and deterministic status, use the `gh` CLI (via `sys_os_shell`) for github.com. Reach for such tools sparingly — only to @@ -276,12 +294,15 @@ terminals: tools: # Coding sub-agents — see agents//. claude_code and codex are real CLI - # coding harnesses; pi is a headless multi-model worker. Each implements, + # coding harnesses; pi is a headless multi-model worker; opencode is an + # optional fourth native CLI harness (only usable when the `opencode` CLI is + # installed — the prompt's roster preflight gates it). Each implements, # reviews (cross-vendor), and explores. agents: - claude_code - codex - pi + - opencode # Mechanism-layer enforcement — runner-side tool gate, no server change. guardrails: diff --git a/omnigent/_wrapper_labels.py b/omnigent/_wrapper_labels.py index 1a86db86..5cffc904 100644 --- a/omnigent/_wrapper_labels.py +++ b/omnigent/_wrapper_labels.py @@ -50,6 +50,10 @@ CODEX_NATIVE_WRAPPER_VALUE = "codex-native-ui" # ``conversations.labels[WRAPPER_LABEL_KEY]``. PI_NATIVE_WRAPPER_VALUE = "pi-native-ui" +# Value the ``omnigent opencode`` wrapper writes into +# ``conversations.labels[WRAPPER_LABEL_KEY]``. +OPENCODE_NATIVE_WRAPPER_VALUE = "opencode-native-ui" + # Value the ``omnigent cursor`` wrapper writes into # ``conversations.labels[WRAPPER_LABEL_KEY]``. CURSOR_NATIVE_WRAPPER_VALUE = "cursor-native-ui" diff --git a/omnigent/cli.py b/omnigent/cli.py index 4a50547f..084c5e68 100644 --- a/omnigent/cli.py +++ b/omnigent/cli.py @@ -189,6 +189,9 @@ _GLOBAL_CONFIG_KEYS: frozenset[str] = frozenset( "default_agent", "harness", "model", + # OpenCode-specific default model (``provider/model``) the native + # ``omni opencode`` TUI launches on; set via `omni setup` → OpenCode. + "opencode_model", "server", _AUTO_OPEN_CONVERSATION_CONFIG_KEY, } @@ -1171,6 +1174,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset( "host", "lakebox", "login", + "opencode", "pane-picker", "pane-split", "pi", @@ -4308,6 +4312,105 @@ def codex( ) +@cli.command( + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + } +) +@click.option( + "--server", + default=None, + help=( + "Remote omnigent URL. Ensures the host daemon, asks the " + "daemon-spawned runner to launch OpenCode, and attaches this TTY. " + 'Pass --server "" to auto-spawn a persistent local server in the ' + "background and use that instead of a remote one." + ), +) +@click.option( + "-r", + "--resume", + "resume", + is_flag=False, + flag_value=_RESUME_PICKER_SENTINEL, + default=None, + help=( + "Resume a prior Omnigent conversation. With a conversation id " + "(e.g. ``--resume conv_abc123``) attaches directly; with no value " + "opens an interactive picker scoped to opencode-native sessions." + ), +) +@click.option( + "--session", + "session_id", + metavar="SESSION_ID", + default=None, + hidden=True, + help="Deprecated alias for ``--resume ``; kept for one release.", +) +@click.option("--model", default=None, help="OpenCode model to use for the native session.") +@click.argument("opencode_args", nargs=-1, type=click.UNPROCESSED) +def opencode( + server: str | None, + resume: str | None, + session_id: str | None, + model: str | None, + opencode_args: tuple[str, ...], +) -> None: + # :param server: Remote Omnigent server URL, or None for local. + # :param resume: None, picker sentinel, or a conversation id. + # :param session_id: Legacy ``--session`` id; mutually exclusive with ``--resume``. + # :param model: OpenCode model id pinned on the wrapper spec. + # :param opencode_args: Pass-through args persisted for the ``opencode attach`` TUI. + """Launch OpenCode TUI in an Omnigent terminal. + + \b + Examples: + omnigent opencode + omnigent opencode --resume conv_abc123 + omnigent opencode --resume # interactive picker + omnigent opencode --server https://.databricksapps.com + """ + from omnigent.opencode_native import run_opencode_native + + cfg = _load_effective_config() + if server is None: + server = cfg.get("server") + if model is None: + # Prefer the OpenCode-specific default (set in `omni setup` → OpenCode → + # "Set default model"); fall back to the shared `model` key for back-compat. + model = cfg.get("opencode_model") or cfg.get("model") + auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg) + + # Validate option combinations before any side effects (see the codex + # command): _ensure_backend can spawn the daemon and take the full + # local-server-discover timeout, which would mask a bad arg pair as an + # outage instead of a usage error. + choice = _split_resume_value(resume) + if session_id is not None and (choice.picker or choice.conversation_id is not None): + raise click.UsageError( + "--session and --resume are mutually exclusive; " + "prefer --resume (--session is deprecated).", + ) + + # Ensure the host daemon (local when ``--server`` is omitted/empty, remote + # otherwise); the daemon-spawned runner owns ``opencode serve`` + the TUI, + # and this CLI attaches to the tmux terminal. + server = _ensure_backend(server) + resolved_session_id = ( + choice.conversation_id if choice.conversation_id is not None else session_id + ) + run_opencode_native( + server=server, + session_id=resolved_session_id, + resume_picker=choice.picker, + opencode_args=opencode_args, + model=model, + auto_open_conversation=auto_open_conversation, + ) + + @cli.command( context_settings={ "ignore_unknown_options": True, @@ -5153,6 +5256,12 @@ def _dispatch_native_terminal_harness( from omnigent.cursor_native import run_cursor_native run_cursor_native(cursor_args=passthrough, **common) + elif native_agent.key == "opencode": + from omnigent.opencode_native import run_opencode_native + + # OpenCode pins its model on the wrapper spec (like Codex), so it takes + # ``model`` first-class rather than via a ``--model`` passthrough arg. + run_opencode_native(opencode_args=(), model=model, **common) else: # pragma: no cover - new native agent added without a dispatch arm raise click.ClickException(f"No native terminal launcher wired for harness {harness!r}.") return True @@ -9542,6 +9651,245 @@ def _remove_credential(provider: str) -> str | None: return f"✓ Removed {label}" +def _launch_opencode_auth_login() -> str | None: + """Launch interactive ``opencode auth login``; return a post-login status. + + ``opencode auth login`` is interactive (pick a provider, sign in), so this + hands the terminal to ``opencode`` and re-reads the credential state on + return. Mirrors :func:`_launch_goose_configure`. + """ + from omnigent.onboarding.harness_install import ( + OPENCODE_KEY, + harness_cli_installed, + harness_install_spec, + ) + from omnigent.onboarding.interactive import console + from omnigent.onboarding.opencode_auth import opencode_auth_summary + + if not harness_cli_installed(OPENCODE_KEY): + return "✗ opencode CLI not found" + spec = harness_install_spec(OPENCODE_KEY) + assert spec is not None + console.print( + " [dim]Launching [bold]opencode auth login[/bold] — pick a provider and " + "sign in, then return.[/dim]" + ) + with contextlib.suppress(OSError, KeyboardInterrupt): + subprocess.run([spec.binary, "auth", "login"], check=False) + summary = opencode_auth_summary() + if summary.has_provider: + return f"✓ providers: {summary.describe()}" + return "No provider detected yet" + + +def _run_opencode_auth_list() -> None: + """Show ``opencode auth list`` (stored credentials + detected env providers).""" + from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_install_spec + + spec = harness_install_spec(OPENCODE_KEY) + if spec is None: + return + with contextlib.suppress(OSError, KeyboardInterrupt): + subprocess.run([spec.binary, "auth", "list"], check=False) + + +def _list_opencode_models() -> list[str]: + """Return the ``provider/model`` ids OpenCode can launch (``opencode models``). + + Best-effort: an absent CLI or a failed/empty invocation yields ``[]`` (the + caller then tells the user to sign a provider in first). + """ + from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_install_spec + + spec = harness_install_spec(OPENCODE_KEY) + if spec is None: + return [] + try: + result = subprocess.run( + [spec.binary, "models"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return [] + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def _set_opencode_default_model(current: str | None) -> str | None: + """Pick OpenCode's default model and persist it as ``opencode_model``. + + The choice is what ``omni opencode`` launches on when no ``--model`` is + given — written into the per-session ``opencode.json`` at spawn so the TUI + starts on it instead of ``opencode/big-pickle``. Returns a status line for + the drill-in, or ``None`` when cancelled. + + :param current: The currently-persisted default model, or ``None``. + """ + from omnigent.onboarding.interactive import console, select + from omnigent.onboarding.opencode_auth import reachable_provider_ids + + models = _list_opencode_models() + if not models: + return "✗ no models — sign in to a provider first (opencode auth login)" + # `opencode models` can list hundreds of `provider/model` ids across every + # provider on models.dev — too long for the picker (it overflows the + # viewport and flickers). Narrow to the providers the user can actually + # authenticate (stored auth.json + env keys); fall back to the full list + # only if that filter would hide everything. + reachable = reachable_provider_ids() + if reachable: + scoped = [m for m in models if m.split("/", 1)[0] in reachable] + models = scoped or models + options = list(models) + clear_index = -1 + if current is not None: + clear_index = len(options) + options.append("Clear default (use OpenCode's own default)") + default = models.index(current) if current in models else 0 + # Even filtered to reachable providers the list can exceed the screen, so + # bound the picker to a scrolling viewport sized to the terminal (leaving + # room for the title / status / footer / "N more" markers). + rows = shutil.get_terminal_size(fallback=(80, 24)).lines + idx = select( + "Pick OpenCode's default model", + options, + default=default, + clear_on_exit=True, + status=f"current: {current}" if current else None, + max_visible=max(5, rows - 8), + ) + if idx < 0: + return None + if idx == clear_index: + _save_global_config({}, unset_keys=("opencode_model",)) + console.print(" [green]✓ default model cleared[/green]") + return "✓ default model cleared" + chosen = models[idx] + _save_global_config({"opencode_model": chosen}) + console.print(f" [green]✓ default model set to[/green] [bold]{chosen}[/bold]") + return f"✓ default model: {chosen}" + + +def _print_opencode_auth_help() -> None: + """Explain where OpenCode's model credentials come from.""" + from omnigent.onboarding.interactive import console + + console.print( + " OpenCode resolves a model from the provider its agent uses:\n" + " • [bold]opencode auth login[/bold] — sign in to a provider (OpenAI, Anthropic, …);\n" + " stored in ~/.local/share/opencode/auth.json.\n" + " • Provider env vars (OPENAI_API_KEY / ANTHROPIC_API_KEY / …) are auto-detected.\n" + " • Databricks gateway: set an agent ``profile`` (configured under Claude / Codex);\n" + " Omnigent synthesizes opencode's per-session provider config from it.\n" + " Omnigent stores no OpenCode credential of its own.\n" + " [dim]Tip:[/dim] 'Set default model' picks which model `omni opencode` launches on\n" + " (otherwise OpenCode uses its built-in default, opencode/big-pickle)." + ) + + +def _manage_opencode_harness() -> None: + """Run the level-2 drill-in for OpenCode: ensure the CLI, then manage providers. + + OpenCode owns its own provider auth — ``opencode auth login`` (stored in + ``~/.local/share/opencode/auth.json``) or ambient provider env vars — so, + like the Goose / Qwen drill-ins, this reports which providers OpenCode can + reach and offers to launch its native login; it never stores a key through + Omnigent. (For the Databricks-gateway path the agent's ``profile`` is + synthesized into opencode's per-session config instead — set under + Claude / Codex.) + + OpenCode is npm-installable, so a missing CLI gates the drill-in with an + install offer. + + :returns: None. Side effect: may ``npm install`` the opencode CLI. + """ + from omnigent.onboarding.harness_install import ( + OPENCODE_KEY, + harness_cli_installed, + harness_install_command, + install_harness_cli, + ) + from omnigent.onboarding.interactive import console, select + + if not harness_cli_installed(OPENCODE_KEY): + cmd = " ".join(harness_install_command(OPENCODE_KEY)) + choice = select( + "OpenCode's CLI isn't installed. Install it now?", + [ + f"Yes — install ({cmd})", + "No — back to harnesses", + "I'll run it myself (show the command)", + ], + descriptions=[ + f"Runs `{cmd}` (needs npm).", + "Return to the harness picker without installing.", + "Print the command so you can install it yourself, then return.", + ], + default=0, + clear_on_exit=True, + ) + if choice == 0: + console.print(f" [dim]Installing OpenCode — running `{cmd}`…[/dim]") + if install_harness_cli(OPENCODE_KEY): + console.print(" [green]✓ OpenCode installed[/green]") + else: + console.print( + f" [red]Install failed.[/red] Run it manually, then re-open: " + f"[bold]{cmd}[/bold]" + ) + return + elif choice == 2: # run it yourself + console.print(f" Install OpenCode with:\n [bold]{cmd}[/bold]") + return + else: + return + + # OpenCode owns its provider auth (``opencode auth login`` → auth.json) or + # ambient env keys; Omnigent stores nothing. Report what's reachable and + # offer to run its native login — like the Goose/Qwen drill-ins. + status: str | None = None + while True: + from omnigent.onboarding.opencode_auth import opencode_auth_summary + + summary = opencode_auth_summary() + default_model = _load_effective_config().get("opencode_model") + header = ( + f"OpenCode — providers: {summary.describe()}" + if summary.has_provider + else "OpenCode — no provider configured yet" + ) + model_label = ( + f"Set default model (current: {default_model})" + if default_model + else "Set default model" + ) + rows: list[_HarnessMenuRow] = [ + _HarnessMenuRow("Run opencode auth login", action="login"), + _HarnessMenuRow(model_label, action="model"), + _HarnessMenuRow("List providers & credentials", action="list"), + _HarnessMenuRow("Show provider options", action="help"), + _HarnessMenuRow("← Back", action="back"), + ] + idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status) + if idx < 0: # Esc / q + return + action = rows[idx].action + if action == "back": + return + if action == "login": + status = _launch_opencode_auth_login() + elif action == "model": + status = _set_opencode_default_model(default_model) + elif action == "list": + _run_opencode_auth_list() + status = None + elif action == "help": + _print_opencode_auth_help() + status = None + + def _run_configure_harnesses_interactive() -> None: """Run the interactive model/credential three-level picker. @@ -9573,6 +9921,7 @@ def _run_configure_harnesses_interactive() -> None: from omnigent.onboarding.harness_install import ( CURSOR_KEY, GOOSE_KEY, + OPENCODE_KEY, QWEN_KEY, harness_cli_installed, harness_install_command, @@ -9620,6 +9969,11 @@ def _run_configure_harnesses_interactive() -> None: # provider family (its v1 auth is the CLI's own env vars / ``/auth`` flow, # not an Omnigent credential), so it dispatches to its own drill-in. _QWEN = "\x00qwen" + # Sentinel marking the OpenCode row — native-server harness with no Omnigent + # credential of its own (it routes through the bound agent's Databricks + # gateway profile or ambient provider env), so it dispatches to its own + # binary-install/info drill-in. + _OPENCODE = "\x00opencode" # Sentinel marking the Goose row — like Qwen/Antigravity/Cursor it is not a # provider family (Goose owns its own auth via ``goose configure``, not an # Omnigent credential), so it dispatches to its own drill-in. @@ -9754,6 +10108,32 @@ def _run_configure_harnesses_interactive() -> None: options.append(f" {qwen_sub}") selectable.append(False) row_target.append(None) + # OpenCode (native-server harness): readiness is just whether the + # ``opencode`` CLI is installed — it has no Omnigent-stored credential, + # routing through the bound agent's Databricks gateway profile or + # ambient provider env. Its drill-in installs the CLI and explains that. + # OpenCode: ready = CLI installed AND a provider reachable (a stored + # ``opencode auth login`` credential or a provider env key). Drill-in + # manages its native login. (Gateway path uses the agent profile.) + from omnigent.onboarding.opencode_auth import opencode_auth_summary + + opencode_summary = opencode_auth_summary() + opencode_ready = opencode_summary.ready + options.append(f"{' ' if opencode_ready else '[red]✗[/] '}OpenCode") + selectable.append(True) + row_target.append(_OPENCODE) + if not opencode_summary.installed: + from rich.markup import escape as _rich_escape + + opencode_cmd = _rich_escape(" ".join(harness_install_command(OPENCODE_KEY))) + opencode_sub = f"[dim]not installed — open to install ({opencode_cmd})[/]" + elif opencode_ready: + opencode_sub = f"[green]✓[/] {opencode_summary.describe()}" + else: + opencode_sub = "[dim]installed — open to sign in (opencode auth login)[/]" + options.append(f" {opencode_sub}") + selectable.append(False) + row_target.append(None) # Goose (its own provider config — no provider family, like Cursor / # Antigravity / Qwen). Goose owns its auth via ``goose configure`` # (keyring / ~/.config/goose/config.yaml); Omnigent stores no key, so @@ -9805,6 +10185,8 @@ def _run_configure_harnesses_interactive() -> None: _manage_antigravity_harness() elif target == _QWEN: _manage_qwen_harness() + elif target == _OPENCODE: + _manage_opencode_harness() elif target == _GOOSE: _manage_goose_harness() else: # Quit row (or, defensively, a non-family row) diff --git a/omnigent/entities/conversation.py b/omnigent/entities/conversation.py index 9dc6a39a..2a1d602f 100644 --- a/omnigent/entities/conversation.py +++ b/omnigent/entities/conversation.py @@ -121,9 +121,13 @@ class Conversation: first turn, so a later switch would orphan the running process. Only valid for ``executor.type: omnigent`` agents; the create route validates against ``OMNIGENT_HARNESSES``. - Sub-agent sessions never inherit it (their own rows stay - ``None``), so e.g. polly's workers keep their declared - harnesses when the brain is overridden. + Sub-agent sessions never *inherit* the parent brain's override, + so e.g. polly's workers keep their declared harnesses when the + brain is overridden. A sub-agent session MAY, however, carry its + own create-time override when ``sys_session_send`` supplied an + allowlisted ``args.harness`` (gated by the sub-agent spec's + ``executor.config.allowed_harnesses``); that value is set on the + child's own row, not inherited. :param sub_agent_name: For sub-agent sessions (``kind="sub_agent"``), the sub-agent type name within the parent's spec tree, e.g. ``"summarizer"``. The runner uses this to resolve the diff --git a/omnigent/harness_aliases.py b/omnigent/harness_aliases.py index 0a3d7bd2..540fa1f7 100644 --- a/omnigent/harness_aliases.py +++ b/omnigent/harness_aliases.py @@ -20,6 +20,11 @@ HARNESS_ALIASES: dict[str, str] = { "native-goose": "goose-native", # Qwen Code harness alias. "qwen-code": "qwen", + # OpenCode native-server harness: the bare ``opencode`` name and the + # reversed ``native-opencode`` spelling both fold to ``opencode-native`` + # (there is no separate SDK ``opencode`` harness, so the bare name is free). + "opencode": "opencode-native", + "native-opencode": "opencode-native", } # Canonical native-CLI harness spellings. These harnesses type messages into @@ -40,6 +45,8 @@ NATIVE_HARNESSES: frozenset[str] = frozenset( "native-cursor", "goose-native", "native-goose", + "opencode-native", + "native-opencode", } ) diff --git a/omnigent/inner/opencode_native_executor.py b/omnigent/inner/opencode_native_executor.py new file mode 100644 index 00000000..44090886 --- /dev/null +++ b/omnigent/inner/opencode_native_executor.py @@ -0,0 +1,167 @@ +"""Executor that bridges Omnigent web turns into a native OpenCode session. + +Built on :class:`omnigent.native_server_harness.NativeServerHarness`: the +runner owns the ``opencode serve`` process + SSE forwarder, and this +executor injects the latest web turn over the +:class:`omnigent.opencode_http_transport.OpenCodeHttpTransport` using the +loopback URL + auth secret published in the bridge state. Output is +streamed back by the runner-side forwarder, so ``run_turn`` only admits the +prompt and yields ``TurnComplete`` — the same injection/completion split as +codex-native. +""" + +from __future__ import annotations + +import dataclasses +import json +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from omnigent.native_server_harness import NativeServerHarness +from omnigent.native_server_transport import NativePrompt +from omnigent.opencode_http_transport import OpenCodeHttpTransport +from omnigent.opencode_native_bridge import ( + OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR, + OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR, + read_bridge_state, +) + +# Canonical harness id, surfaced in harness error messages. +OPENCODE_NATIVE_HARNESS_ID = "opencode-native" + + +class OpenCodeNativeExecutor(NativeServerHarness): + """ + Harness-side executor for ``omnigent opencode`` web UI turns. + + :param bridge_dir: Optional bridge directory override. ``None`` reads + :data:`OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR`. + """ + + def __init__(self, bridge_dir: Path | None = None) -> None: + self._bridge_dir = bridge_dir or _bridge_dir_from_env() + self._request_session_id = _request_session_id_from_env() + super().__init__( + harness_id=OPENCODE_NATIVE_HARNESS_ID, + # OpenCode has no live-steer endpoint, so a mid-turn message is + # admitted as a new prompt and the native server's own queue + # promotes it when the active turn finishes. + supports_enqueue=True, + transport=OpenCodeHttpTransport(bridge_dir=self._bridge_dir), + resolve_session_id=self._resolve_opencode_session_id, + build_prompt=self._build_prompt_with_model_override, + ) + + def _build_prompt_with_model_override(self, content: Any) -> NativePrompt | None: + """ + Build a prompt, pinning the resolved model so it governs from turn one. + + OpenCode's ``POST /session`` create body does NOT accept a model + (verified against the OpenCode SDK ``SessionCreateData``); the model + is a per-prompt field (``{"providerID", "modelID"}``). So the + session's ``model_override`` is applied to EVERY injected prompt + here. Because OpenCode persists the last-used model as the session + default, pinning the first injected turn also governs subsequent + TUI-typed turns — the override controls the run from the start, not + just a later web turn. A per-turn ``config.model`` (if any) still + wins: the base ``run_turn`` only fills the model when the prompt + leaves it unset, so it skips a prompt this method already pinned. + + :param content: Executor message content (string or content blocks). + :returns: The prompt with the resolved model applied, or ``None`` + when there is nothing to send. + """ + prompt = _content_to_native_prompt(content) + if prompt is None or prompt.model: + return prompt + state = read_bridge_state(self._bridge_dir) + model = state.model_override if state is not None else None + if not model: + return prompt + return dataclasses.replace(prompt, model=model) + + async def _resolve_opencode_session_id(self) -> str | None: + """ + Resolve the OpenCode session id from bridge state. + + :returns: The OpenCode session id when this harness may inject into + it, else ``None``. + """ + state = read_bridge_state(self._bridge_dir) + if state is None: + return None + if not _session_is_active(state.session_id, self._request_session_id): + return None + return state.opencode_session_id + + +def _bridge_dir_from_env() -> Path: + """ + Resolve the native OpenCode bridge directory from harness spawn env. + + :returns: Bridge directory path. + :raises RuntimeError: If the env var is missing. + """ + raw = os.environ.get(OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR, "").strip() + if not raw: + raise RuntimeError(f"{OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR} is required") + return Path(raw) + + +def _request_session_id_from_env() -> str | None: + """ + Resolve the Omnigent session id that requested this harness process. + + :returns: Omnigent session id, e.g. ``"conv_abc123"``, or ``None``. + """ + raw = os.environ.get(OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR, "").strip() + return raw or None + + +def _session_is_active(session_id: str, request_session_id: str | None) -> bool: + """ + Return whether this harness may inject into the native session. + + :param session_id: Session id from bridge state. + :param request_session_id: Session id from harness spawn env. + :returns: ``True`` when injection is allowed. + """ + return request_session_id is None or request_session_id == session_id + + +def _content_to_native_prompt(content: Any) -> NativePrompt | None: + """ + Normalize executor message content into a :class:`NativePrompt`. + + Text blocks are concatenated; image/file blocks pass through as + attachments (the transport renders them as OpenCode file parts using + their data URIs, so there is no socket-size limit to work around). + + :param content: Message content — a string or a list of content blocks + such as ``{"type": "input_text", "text": "..."}`` and + ``{"type": "input_image", "image_url": "data:image/png;base64,..."}``. + :returns: The prompt, or ``None`` when there is nothing to send. + """ + if isinstance(content, str): + return NativePrompt(text=content) if content else None + if isinstance(content, list): + texts: list[str] = [] + attachments: list[Mapping[str, Any]] = [] + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type in {"input_text", "text"}: + text = block.get("text") + if isinstance(text, str) and text: + texts.append(text) + elif block_type in {"input_image", "input_file"}: + attachments.append(block) + if not texts and not attachments: + return None + return NativePrompt(text="\n".join(texts), attachments=tuple(attachments)) + if content is None: + return None + return NativePrompt(text=json.dumps(content, ensure_ascii=True)) diff --git a/omnigent/inner/opencode_native_harness.py b/omnigent/inner/opencode_native_harness.py new file mode 100644 index 00000000..eba27b5e --- /dev/null +++ b/omnigent/inner/opencode_native_harness.py @@ -0,0 +1,29 @@ +"""``harness: opencode-native`` wrap for the native OpenCode server.""" + +from __future__ import annotations + +from fastapi import FastAPI + +from omnigent.inner.executor import Executor +from omnigent.inner.opencode_native_executor import OpenCodeNativeExecutor +from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter + + +def _build_opencode_native_executor() -> Executor: + """ + Construct the native OpenCode bridge executor. + + :returns: An :class:`OpenCodeNativeExecutor` configured from the + harness spawn environment. + """ + return OpenCodeNativeExecutor() + + +def create_app() -> FastAPI: + """ + Build the ``opencode-native`` harness FastAPI app. + + :returns: The FastAPI app from :class:`ExecutorAdapter`. + """ + adapter = ExecutorAdapter(executor_factory=_build_opencode_native_executor) + return adapter.build() diff --git a/omnigent/native_coding_agents.py b/omnigent/native_coding_agents.py index 51cbd487..02fcdaa3 100644 --- a/omnigent/native_coding_agents.py +++ b/omnigent/native_coding_agents.py @@ -9,6 +9,7 @@ from omnigent._wrapper_labels import ( CODEX_NATIVE_WRAPPER_VALUE, CURSOR_NATIVE_WRAPPER_VALUE, GOOSE_NATIVE_WRAPPER_VALUE, + OPENCODE_NATIVE_WRAPPER_VALUE, PI_NATIVE_WRAPPER_VALUE, UI_MODE_LABEL_KEY, UI_MODE_TERMINAL_VALUE, @@ -67,6 +68,16 @@ PI_NATIVE_CODING_AGENT = NativeCodingAgent( terminal_name="pi", ) +OPENCODE_NATIVE_CODING_AGENT = NativeCodingAgent( + key="opencode", + display_name="OpenCode", + agent_name="opencode-native-ui", + harness="opencode-native", + wrapper_label=OPENCODE_NATIVE_WRAPPER_VALUE, + terminal_name="opencode", + subagent_wrapper_label="opencode-native-ui-subagent", +) + CURSOR_NATIVE_CODING_AGENT = NativeCodingAgent( key="cursor", display_name="Cursor", @@ -89,6 +100,7 @@ NATIVE_CODING_AGENTS: tuple[NativeCodingAgent, ...] = ( CLAUDE_NATIVE_CODING_AGENT, CODEX_NATIVE_CODING_AGENT, PI_NATIVE_CODING_AGENT, + OPENCODE_NATIVE_CODING_AGENT, CURSOR_NATIVE_CODING_AGENT, GOOSE_NATIVE_CODING_AGENT, ) diff --git a/omnigent/native_server_harness.py b/omnigent/native_server_harness.py new file mode 100644 index 00000000..d073bfa1 --- /dev/null +++ b/omnigent/native_server_harness.py @@ -0,0 +1,225 @@ +"""Shared :class:`Executor` base for native-server harnesses. + +The runner owns the native server + SSE/WS forwarder; this executor is the +harness-side seam that injects web turns over a +:class:`~omnigent.native_server_transport.NativeServerTransport`. It is +deliberately thin and transport-agnostic — the same orchestration drives +both codex-native (WS JSON-RPC) and opencode-native (HTTP + SSE): + +- ``run_turn`` resolves the native session id from bridge state (briefly + polling on first turn while the runner boots the server), builds a + :class:`NativePrompt` from the latest user message, injects it via the + transport, and yields ``TurnComplete`` — streaming is the forwarder's + job, matching codex-native's injection/completion split. +- ``interrupt_session`` and ``enqueue_session_message`` route through the + transport's ``abort`` / ``send_prompt``. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +from omnigent.inner.executor import ( + EnqueuedContent, + Executor, + ExecutorConfig, + ExecutorError, + ExecutorEvent, + Message, + ToolSpec, + TurnComplete, +) +from omnigent.native_server_transport import NativePrompt, NativeServerTransport + +_logger = logging.getLogger(__name__) + +# Resolve the native session id from bridge state (``None`` until ready). +SessionResolver = Callable[[], Awaitable[str | None]] +# Build a :class:`NativePrompt` from message content. +PromptBuilder = Callable[[Any], NativePrompt | None] + + +class NativeServerHarness(Executor): + """ + Transport-driven executor for native-server harnesses. + + :param harness_id: Canonical harness id, e.g. ``"opencode-native"`` (used + in harness error messages). + :param supports_enqueue: Whether the harness supports mid-turn enqueue + (steer-or-queue); drives :meth:`supports_live_message_queue`. + :param transport: The transport to inject turns over. + :param resolve_session_id: Async callable returning the native session + id (or ``None`` until the runner has booted the server). + :param build_prompt: Callable turning message content into a + :class:`NativePrompt` (``None`` when there is nothing to send). + :param boot_poll_attempts: Times to poll for the session id on the + first turn before giving up. + :param boot_poll_delay: Seconds between boot polls. + """ + + def __init__( + self, + *, + harness_id: str, + supports_enqueue: bool, + transport: NativeServerTransport, + resolve_session_id: SessionResolver, + build_prompt: PromptBuilder, + boot_poll_attempts: int = 60, + boot_poll_delay: float = 1.0, + ) -> None: + self._harness_id = harness_id + self._supports_enqueue = supports_enqueue + self.transport = transport + self._resolve_session_id = resolve_session_id + self._build_prompt = build_prompt + self._boot_poll_attempts = boot_poll_attempts + self._boot_poll_delay = boot_poll_delay + # Serialize injection (run_turn vs enqueue) against the one cached + # executor instance, mirroring the codex-native inject lock. + self._inject_lock = asyncio.Lock() + + def supports_streaming(self) -> bool: + """:returns: ``False`` — the runner-side forwarder emits output.""" + return False + + def handles_tools_internally(self) -> bool: + """:returns: ``True`` — the native server runs its own tools.""" + return True + + def supports_live_message_queue(self) -> bool: + """:returns: Whether the harness supports mid-turn enqueue.""" + return self._supports_enqueue + + async def _await_session_id(self) -> str | None: + """ + Resolve the native session id, polling while the server boots. + + :returns: The native session id, or ``None`` if it never appears. + """ + session_id = await self._resolve_session_id() + if session_id is not None: + return session_id + for _ in range(self._boot_poll_attempts): + await asyncio.sleep(self._boot_poll_delay) + session_id = await self._resolve_session_id() + if session_id is not None: + return session_id + return None + + async def run_turn( + self, + messages: list[Message], + tools: list[ToolSpec], + system_prompt: str, + config: ExecutorConfig | None = None, + ) -> AsyncIterator[ExecutorEvent]: + """ + Inject the latest user message into the native session. + + :param messages: Conversation history; the latest user message is + delivered. + :param tools: Omnigent tool schemas (ignored — native owns tools). + :param system_prompt: Agent system prompt (ignored — set at + session creation). + :param config: Per-turn config (model override applied if present). + :returns: Async iterator yielding one terminal event. + """ + del tools, system_prompt + prompt = _latest_user_prompt(messages, self._build_prompt) + if prompt is None or prompt.is_empty(): + yield ExecutorError(message=f"{self._harness_id} turn had no user input to send") + return + if config is not None and config.model and not prompt.model: + prompt = _with_model(prompt, config.model) + error_msg: str | None = None + async with self._inject_lock: + session_id = await self._await_session_id() + if session_id is None: + error_msg = f"{self._harness_id} bridge state is missing" + else: + try: + await self.transport.send_prompt(session_id, prompt) + except Exception as exc: # noqa: BLE001 - converted to a harness error event. + error_msg = f"{self._harness_id} executor error: {exc}" + if error_msg is not None: + yield ExecutorError(message=error_msg) + else: + yield TurnComplete(response=None) + + async def interrupt_session(self, session_key: str) -> bool: + """ + Abort the active native turn. + + :param session_key: Adapter session key (unused; bridge is + per-conversation). + :returns: ``True`` when an abort was issued. + """ + del session_key + session_id = await self._resolve_session_id() + if session_id is None: + return False + try: + return await self.transport.abort(session_id) + except Exception: # noqa: BLE001 - interruption is best effort. + _logger.warning("%s abort failed", self._harness_id, exc_info=True) + return False + + async def enqueue_session_message(self, session_key: str, content: EnqueuedContent) -> bool: + """ + Inject a mid-session message (steer-or-queue). + + OpenCode has no live-steer endpoint, so the message is admitted as + a new prompt; the native server's own queue promotes it when the + active turn finishes. + + :param session_key: Adapter session key (unused). + :param content: User-supplied content. + :returns: ``True`` when the message was admitted. + """ + del session_key + prompt = self._build_prompt(content) + if prompt is None or prompt.is_empty(): + return False + async with self._inject_lock: + session_id = await self._resolve_session_id() + if session_id is None: + return False + try: + await self.transport.send_prompt(session_id, prompt) + except Exception: # noqa: BLE001 - enqueue is best effort. + _logger.warning("%s enqueue failed", self._harness_id, exc_info=True) + return False + return True + + +def _latest_user_prompt( + messages: list[Message], build_prompt: PromptBuilder +) -> NativePrompt | None: + """ + Build a :class:`NativePrompt` from the latest user message. + + :param messages: Executor message list. + :param build_prompt: Content → prompt builder. + :returns: The prompt, or ``None`` when there is no user content. + """ + for message in reversed(messages): + if message.get("role") == "user": + return build_prompt(message.get("content")) + return None + + +def _with_model(prompt: NativePrompt, model: str) -> NativePrompt: + """ + Return a copy of *prompt* with *model* applied. + + :param prompt: The prompt to copy. + :param model: Model id to pin. + :returns: A new prompt carrying the model. + """ + import dataclasses + + return dataclasses.replace(prompt, model=model) diff --git a/omnigent/native_server_transport.py b/omnigent/native_server_transport.py new file mode 100644 index 00000000..1341e7ab --- /dev/null +++ b/omnigent/native_server_transport.py @@ -0,0 +1,170 @@ +"""Transport abstraction for native-server harnesses. + +A *native-server* harness drives a per-conversation server process the +runner owns: the runner starts the server + an event forwarder, and the +harness injects web turns over this transport. OpenCode speaks HTTP + SSE +(:class:`omnigent.opencode_http_transport.OpenCodeHttpTransport`); +:class:`NativeServerTransport` is the seam so the orchestration in +:class:`omnigent.native_server_harness.NativeServerHarness` stays +protocol-agnostic. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Protocol, runtime_checkable + + +@dataclass(frozen=True) +class NativeLaunchConfig: + """ + Inputs needed to start/resume a native server for one conversation. + + :param omnigent_session_id: Omnigent conversation id, e.g. + ``"conv_abc123"``. + :param workspace: Working directory the server runs in. + :param model_override: Persisted model override, or ``None``. + :param terminal_launch_args: Pass-through CLI args for the TUI/server. + :param external_session_id: Native session id to resume, or ``None``. + :param server_url: Existing server URL when reusing one, or ``None``. + :param auth_headers: Auth headers for the native server. + """ + + omnigent_session_id: str + workspace: str + model_override: str | None = None + terminal_launch_args: tuple[str, ...] = () + external_session_id: str | None = None + server_url: str | None = None + auth_headers: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class NativeServerHandle: + """ + A running native server's connection coordinates. + + :param base_url: Server base URL / transport endpoint. + :param env: Environment used to launch the server. + :param bridge_dir: The per-session bridge directory. + :param process_id: OS pid of the server process, when known. + """ + + base_url: str + env: Mapping[str, str] + bridge_dir: Path + process_id: int | None = None + + +@dataclass(frozen=True) +class NativePrompt: + """ + A normalized prompt to inject into a native session. + + :param text: The user text. + :param attachments: Attachment descriptors (image/file blocks). + :param system_prompt: Optional per-prompt system override. + :param model: Optional per-prompt model id. + :param metadata: Transport-specific extras. + """ + + text: str + attachments: tuple[Mapping[str, Any], ...] = () + system_prompt: str | None = None + model: str | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def is_empty(self) -> bool: + """:returns: ``True`` when there is nothing to send.""" + return not self.text and not self.attachments + + +@dataclass(frozen=True) +class NativeEvent: + """ + A transport-neutral native server event. + + :param id: Optional event id. + :param type: Event discriminator. + :param payload: The event's properties/params. + :param raw: The full raw envelope. + """ + + id: str | None + type: str + payload: Mapping[str, Any] + raw: Mapping[str, Any] + + +@dataclass(frozen=True) +class NativePermissionDecision: + """ + A permission decision to relay to the native server. + + :param request_id: Native permission request id. + :param decision: Normalized decision. + :param message: Optional human-readable note. + """ + + request_id: str + decision: Literal["allow_once", "allow_always", "reject"] + message: str | None = None + + +@runtime_checkable +class NativeServerTransport(Protocol): + """ + Protocol every native-server transport implements. + + Implementations encapsulate all wire details (process launch, session + lifecycle, prompt injection, abort, event stream, fork, permission + replies, TUI attach). The shared + :class:`~omnigent.native_server_harness.NativeServerHarness` calls only + these methods. + """ + + descriptor_id: str + + async def start_server(self, launch: NativeLaunchConfig) -> NativeServerHandle: + """Start (or attach to) the native server; return its handle.""" + raise NotImplementedError + + async def stop_server(self) -> None: + """Stop the native server process this transport started.""" + raise NotImplementedError + + async def create_or_resume_session(self, launch: NativeLaunchConfig) -> str: + """Resume ``launch.external_session_id`` or create a new session id.""" + raise NotImplementedError + + async def send_prompt(self, session_id: str, prompt: NativePrompt) -> Mapping[str, Any]: + """Inject a prompt into the native session.""" + raise NotImplementedError + + async def abort(self, session_id: str) -> bool: + """Abort the native session's active work.""" + raise NotImplementedError + + def events(self, session_id: str) -> AsyncIterator[NativeEvent]: + """Stream native events for *session_id*.""" + raise NotImplementedError + + async def list_history(self, session_id: str) -> list[Mapping[str, Any]]: + """Return the native session's message history.""" + raise NotImplementedError + + async def fork(self, session_id: str, *, at_message_id: str | None = None) -> str: + """Fork the native session; return the new session id.""" + raise NotImplementedError + + async def reply_permission(self, decision: NativePermissionDecision) -> None: + """Relay a permission decision to the native server.""" + raise NotImplementedError + + def build_tui_attach_command( + self, launch: NativeLaunchConfig, session_id: str + ) -> tuple[list[str], Mapping[str, str]]: + """Build the ``(argv, env)`` for a terminal TUI takeover.""" + raise NotImplementedError diff --git a/omnigent/onboarding/harness_install.py b/omnigent/onboarding/harness_install.py index 7eb2f58d..076d67be 100644 --- a/omnigent/onboarding/harness_install.py +++ b/omnigent/onboarding/harness_install.py @@ -56,6 +56,11 @@ QWEN_KEY = "qwen" # installer rather than npm — so it carries an ``install_hint``, not a ``package``. CURSOR_KEY = "cursor" +# OpenCode native harness CLI (``opencode serve`` / ``opencode attach``), +# installed via the ``opencode-ai`` npm package. No login/logout/status argv +# is wired yet — readiness is binary-only until an auth check exists. +OPENCODE_KEY = "opencode" + # Goose authenticates against its own config (``goose configure`` → keyring / # ``~/.config/goose/config.yaml``) with no Omnigent-managed credential, and ships # via Homebrew / a curl installer rather than npm — so it carries an @@ -124,6 +129,11 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { status_args=("login", "status"), ), PI_KEY: HarnessInstallSpec("Pi", "pi", "@earendil-works/pi-coding-agent"), + # Pin the install to the supported 1.17.x range: opencode-ai's npm ``latest`` + # is a ``0.0.0-beta-*`` pre-release, so a bare ``opencode-ai`` would install a + # version the runtime version-check (``check_opencode_version``, + # >=1.17.7,<1.18.0) then rejects. ``~1.17.7`` mirrors that exact range. + OPENCODE_KEY: HarnessInstallSpec("OpenCode", "opencode", "opencode-ai@~1.17.7"), QWEN_KEY: HarnessInstallSpec( "Qwen Code", "qwen", @@ -161,10 +171,11 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { # :data:`_HARNESS_INSTALL` family key. Only the CLI-backed harnesses appear # here — the ones that cannot launch without a binary on ``PATH``: # ``claude-native`` wraps the ``claude`` CLI, ``codex-native`` the ``codex`` -# CLI, ``pi`` / ``pi-native`` the ``pi`` CLI, ``qwen`` / ``qwen-code`` the -# ``qwen`` CLI, and ``cursor-native`` / ``native-cursor`` the ``cursor-agent`` -# CLI (the native Cursor TUI, installed via Cursor's curl installer rather than -# npm — see its ``install_hint``). +# CLI, ``pi`` / ``pi-native`` the ``pi`` CLI, ``opencode-native`` the +# ``opencode`` CLI, ``qwen`` / ``qwen-code`` the ``qwen`` CLI, and +# ``cursor-native`` / ``native-cursor`` the ``cursor-agent`` CLI (the native +# Cursor TUI, installed via Cursor's curl installer rather than npm — see its +# ``install_hint``). # SDK-based harnesses run in-process and are deliberately absent, so they # resolve to "no CLI required": ``claude-sdk``, ``codex``, ``openai-agents-sdk``, # and the SDK ``cursor`` harness (which drives the ``cursor-sdk`` Python package @@ -183,6 +194,10 @@ _HARNESS_NAME_TO_KEY: dict[str, str] = { GOOSE_KEY: GOOSE_KEY, QWEN_KEY: QWEN_KEY, "qwen-code": QWEN_KEY, + # Native OpenCode (``opencode-native``) wraps the ``opencode`` CLI; its + # ``native-opencode`` reversed spelling gates on the same binary. + "opencode-native": OPENCODE_KEY, + "native-opencode": OPENCODE_KEY, } diff --git a/omnigent/onboarding/harness_readiness.py b/omnigent/onboarding/harness_readiness.py index 481abff1..380163cc 100644 --- a/omnigent/onboarding/harness_readiness.py +++ b/omnigent/onboarding/harness_readiness.py @@ -30,6 +30,7 @@ from omnigent.harness_aliases import HARNESS_ALIASES, canonicalize_harness from omnigent.onboarding.harness_install import ( CURSOR_KEY, GOOSE_KEY, + OPENCODE_KEY, PI_KEY, QWEN_KEY, harness_cli_installed, @@ -56,6 +57,11 @@ _SDK_HARNESSES: frozenset[str] = frozenset( # be gated explicitly or they fail open like an unknown harness. _PI_HARNESSES: frozenset[str] = frozenset({PI_SURFACE, "pi-native"}) +# Native OpenCode harness. Like pi, it wraps a CLI (``opencode``) with no +# ``_HARNESS_FAMILY`` entry, so it must be gated explicitly or it would fail +# open like an unknown harness. +_OPENCODE_HARNESSES: frozenset[str] = frozenset({"opencode-native"}) + # Native Cursor harnesses. These boot the ``cursor-agent`` TUI (``omni cursor``) # and so, like the other native CLI harnesses, can't launch without that binary # on ``PATH`` — gate them on it. Distinct from the SDK ``cursor`` harness @@ -100,9 +106,13 @@ def _install_key(canonical: str) -> str: :param canonical: A canonical CLI-wrapping harness id keyed in ``_HARNESS_FAMILY`` (e.g. ``"codex-native"``), or ``"pi"``. :returns: ``"anthropic"`` / ``"openai"`` for the claude/codex CLIs, - :data:`~omnigent.onboarding.harness_install.PI_KEY` for pi, or - :data:`~omnigent.onboarding.harness_install.QWEN_KEY` for qwen. + :data:`~omnigent.onboarding.harness_install.OPENCODE_KEY` for + opencode-native, + :data:`~omnigent.onboarding.harness_install.QWEN_KEY` for qwen, or + :data:`~omnigent.onboarding.harness_install.PI_KEY` for pi. """ + if canonical in _OPENCODE_HARNESSES: + return OPENCODE_KEY if canonical in _QWEN_HARNESSES: return QWEN_KEY return _HARNESS_FAMILY.get(canonical) or PI_KEY @@ -162,6 +172,7 @@ def harness_is_configured(harness: str) -> bool: if ( canonical not in _HARNESS_FAMILY and canonical not in _PI_HARNESSES + and canonical not in _OPENCODE_HARNESSES and canonical not in _QWEN_HARNESSES ): # Unknown harness — the daemon has no install metadata for it, so @@ -188,6 +199,7 @@ def configured_harness_map() -> dict[str, bool]: spellings.update(_EXECUTOR_TYPE_HARNESS_ALIASES) spellings.update(HARNESS_ALIASES) spellings.update(_PI_HARNESSES) + spellings.update(_OPENCODE_HARNESSES) spellings.update(_CURSOR_NATIVE_HARNESSES) spellings.update(_GOOSE_NATIVE_HARNESSES) spellings.update(_QWEN_HARNESSES) diff --git a/omnigent/onboarding/interactive.py b/omnigent/onboarding/interactive.py index c0add3d3..9281513c 100644 --- a/omnigent/onboarding/interactive.py +++ b/omnigent/onboarding/interactive.py @@ -81,6 +81,8 @@ def _render_menu( width: int, selectable: list[bool], status: str | None = None, + max_visible: int | None = None, + window_start: int = 0, ) -> str: """Render the menu frame to an ANSI string for the termios redraw. @@ -120,8 +122,23 @@ def _render_menu( render_console.print(Text.from_markup(f" [bold {ACCENT}]{title}[/]")) render_console.print() + # Optional scrolling viewport: when *max_visible* is set and the list is + # longer, render only ``options[window_start : window_start + max_visible]`` + # (the caller keeps the selected row inside this window) plus dim "N more" + # markers, so a long flat list fits one screen instead of overflowing and + # flickering. ``None`` (the default) renders every row, unchanged. + n_options = len(options) + if max_visible is not None and n_options > max_visible: + win_start = max(0, min(window_start, n_options - max_visible)) + win_end = win_start + max_visible + else: + win_start, win_end = 0, n_options + if win_start > 0: + render_console.print(Text.from_markup(f" [{MUTED}]↑ {win_start} more[/]")) + last_choice = -1 # index of the most recent selectable (group-owning) row - for i, label in enumerate(options): + for i in range(win_start, win_end): + label = options[i] if not selectable[i]: # Sub-line(s) under the preceding choice (a harness's default + # "+N more" summary): indented, no pointer. ↑/↓ skip them. Their @@ -149,6 +166,9 @@ def _render_menu( last_choice = i render_console.print(Text.from_markup(f" {label}")) + if win_end < n_options: + render_console.print(Text.from_markup(f" [{MUTED}]↓ {n_options - win_end} more[/]")) + if descriptions is not None and descriptions[selected]: render_console.print() render_console.print(Text.from_markup(f" [dim italic]{descriptions[selected]}[/]")) @@ -280,6 +300,7 @@ def select( selectable: list[bool] | None = None, clear_on_exit: bool = False, status: str | None = None, + max_visible: int | None = None, ) -> int: """Show a theme-picker-styled arrow-key menu and return the choice. @@ -320,6 +341,11 @@ def select( title (part of the frame, so it clears with ``clear_on_exit``). Pass the prior action's result so a re-rendering loop shows only the latest, never an accumulating stack. No-op on the fallback. + :param max_visible: Optional cap on visible rows. When set and the list + is longer, the menu shows a scrolling viewport that follows the + cursor (with "N more" markers) so a long flat list fits one screen + instead of overflowing and flickering. ``None`` renders every row. + No-op on the numbered fallback. :returns: The chosen zero-based index into *options* (always a selectable row), or ``-1`` when the user aborts — Esc / Ctrl-C / Ctrl-D on the TTY, or ``q`` on the numbered fallback. @@ -347,9 +373,20 @@ def select( # occupied so the next redraw can move up and overwrite it # (the ``_theme_picker`` redraw idiom). prev_lines = [0] + # Scrolling-viewport start index (mutable for the redraw closure); only + # used when ``max_visible`` bounds a long list. + window_start = [0] def _redraw() -> None: """Clear the prior frame region and reprint the menu in place.""" + if max_visible is not None and len(options) > max_visible: + # Keep the selected row inside the [start, start+max_visible) window, + # scrolling the window just enough to follow the cursor. + if selected < window_start[0]: + window_start[0] = selected + elif selected >= window_start[0] + max_visible: + window_start[0] = selected - max_visible + 1 + window_start[0] = max(0, min(window_start[0], len(options) - max_visible)) rendered = _render_menu( title, options, @@ -358,6 +395,8 @@ def select( width=width, selectable=mask, status=status, + max_visible=max_visible, + window_start=window_start[0], ) if prev_lines[0] > 0: sys.stdout.write(f"\033[{prev_lines[0]}A") diff --git a/omnigent/onboarding/opencode_auth.py b/omnigent/onboarding/opencode_auth.py new file mode 100644 index 00000000..8dd909d3 --- /dev/null +++ b/omnigent/onboarding/opencode_auth.py @@ -0,0 +1,136 @@ +"""OpenCode readiness + credential reporting for ``omnigent setup``. + +Like :mod:`omnigent.onboarding.goose_auth`, Omnigent stores **no** OpenCode +credentials: OpenCode owns its own provider auth via ``opencode auth login`` +(stored in ``~/.local/share/opencode/auth.json``) or ambient provider env vars +(``OPENAI_API_KEY`` / ``ANTHROPIC_API_KEY`` / …). This module is a thin, +read-only reporter so ``omnigent setup`` can show which providers OpenCode can +reach and offer to run its native login — without ever touching its secrets. + +It reads ``auth.json`` directly (a JSON object keyed by provider id — see +``packages/opencode/src/auth`` in the OpenCode source) rather than scraping +``opencode auth list`` output, and checks a curated set of common provider env +vars. Both are best-effort: a missing/unreadable file or unknown env var simply +reports "nothing configured", never raises. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path + +from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_cli_installed + +# Common OpenCode providers → (provider id, display label, env var). The +# provider id matches OpenCode's own id (the ``auth.json`` key and the +# ``provider/model`` prefix in ``opencode models``). Not exhaustive (OpenCode +# resolves many providers from models.dev); this is the set worth surfacing in +# setup, including the ``OPENAI_*`` pair the Databricks-gateway path uses. +_ENV_PROVIDER_VARS: tuple[tuple[str, str, str], ...] = ( + ("openai", "OpenAI", "OPENAI_API_KEY"), + ("anthropic", "Anthropic", "ANTHROPIC_API_KEY"), + ("google", "Google Gemini", "GEMINI_API_KEY"), + ("google", "Google Gemini", "GOOGLE_GENERATIVE_AI_API_KEY"), + ("groq", "Groq", "GROQ_API_KEY"), + ("openrouter", "OpenRouter", "OPENROUTER_API_KEY"), + ("xai", "xAI", "XAI_API_KEY"), + ("mistral", "Mistral", "MISTRAL_API_KEY"), + ("deepseek", "DeepSeek", "DEEPSEEK_API_KEY"), +) + + +def opencode_auth_path() -> Path: + """Return OpenCode's ``auth.json`` path for this process's HOME. + + Honors ``XDG_DATA_HOME``; defaults to ``~/.local/share/opencode/auth.json`` + (OpenCode's ``Global.Path.data``). + """ + xdg = os.environ.get("XDG_DATA_HOME", "").strip() + base = Path(xdg) if xdg else Path.home() / ".local" / "share" + return base / "opencode" / "auth.json" + + +def _stored_providers() -> tuple[str, ...]: + """Return provider ids with stored credentials in ``auth.json``. + + Best-effort: a missing/unreadable/non-object file yields ``()``. + """ + try: + data = json.loads(opencode_auth_path().read_text(encoding="utf-8")) + except (OSError, ValueError): + return () + if not isinstance(data, dict): + return () + return tuple(str(k) for k in data) + + +def _env_providers(environ: dict[str, str] | None = None) -> tuple[str, ...]: + """Return provider labels whose API-key env var is present.""" + env = os.environ if environ is None else environ + seen: list[str] = [] + for _provider_id, label, var in _ENV_PROVIDER_VARS: + if env.get(var, "").strip() and label not in seen: + seen.append(label) + return tuple(seen) + + +def reachable_provider_ids(environ: dict[str, str] | None = None) -> frozenset[str]: + """Return OpenCode provider ids reachable from stored auth + env keys. + + Ids match OpenCode's own (the ``provider/model`` prefix), so callers can + filter a model list down to what the user can actually authenticate. + """ + env = os.environ if environ is None else environ + ids = set(_stored_providers()) + for provider_id, _label, var in _ENV_PROVIDER_VARS: + if env.get(var, "").strip(): + ids.add(provider_id) + return frozenset(ids) + + +@dataclass(frozen=True) +class OpenCodeAuthSummary: + """What setup needs to know about the local OpenCode credentials. + + :param installed: ``opencode`` binary present on ``PATH``. + :param stored_providers: Provider ids with credentials in ``auth.json``. + :param env_providers: Provider labels whose API-key env var is set. + """ + + installed: bool + stored_providers: tuple[str, ...] + env_providers: tuple[str, ...] + + @property + def has_provider(self) -> bool: + """Whether any provider is reachable (stored credential or env key).""" + return bool(self.stored_providers or self.env_providers) + + @property + def ready(self) -> bool: + """Launchable when the CLI is installed AND a provider is configured.""" + return self.installed and self.has_provider + + def describe(self) -> str: + """A short human summary of configured providers, e.g. + ``"2 stored (anthropic, openai) + env: OpenAI"``. + """ + parts: list[str] = [] + if self.stored_providers: + parts.append( + f"{len(self.stored_providers)} stored ({', '.join(sorted(self.stored_providers))})" + ) + if self.env_providers: + parts.append(f"env: {', '.join(self.env_providers)}") + return " · ".join(parts) if parts else "no provider configured yet" + + +def opencode_auth_summary() -> OpenCodeAuthSummary: + """Summarize the local OpenCode credential state for setup display.""" + return OpenCodeAuthSummary( + installed=harness_cli_installed(OPENCODE_KEY), + stored_providers=_stored_providers(), + env_providers=_env_providers(), + ) diff --git a/omnigent/opencode_http_transport.py b/omnigent/opencode_http_transport.py new file mode 100644 index 00000000..d8829234 --- /dev/null +++ b/omnigent/opencode_http_transport.py @@ -0,0 +1,284 @@ +"""OpenCode HTTP + SSE implementation of :class:`NativeServerTransport`. + +All OpenCode wire details live here; +:class:`omnigent.native_server_harness.NativeServerHarness` drives it through +the transport protocol only. + +The transport can build its client from three sources, in priority order: +an injected ``client_factory`` (tests), a running +:class:`OpenCodeNativeServer` (runner-side), or the persisted bridge state +(harness-side, where only the URL + auth secret are known). +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Callable, Mapping +from pathlib import Path +from typing import Any + +from omnigent.native_server_transport import ( + NativeEvent, + NativeLaunchConfig, + NativePermissionDecision, + NativePrompt, + NativeServerHandle, +) +from omnigent.opencode_native_app_server import ( + OpenCodeNativeServer, + build_opencode_attach_args, + client_for_state, + opencode_terminal_env, +) +from omnigent.opencode_native_bridge import read_bridge_state +from omnigent.opencode_native_client import OpenCodeClient + +_logger = logging.getLogger(__name__) + +ClientFactory = Callable[[], OpenCodeClient] + +# Public surface of this transport module. ``ClientFactory`` is the documented +# annotation for ``OpenCodeHttpTransport(client_factory=...)``; export it so the +# alias reads as intended public API (its only other use is a PEP 563 stringified +# annotation, which static analysis can't see as a load). +__all__ = ["ClientFactory", "OpenCodeHttpTransport", "build_prompt_payload"] + + +def build_prompt_payload(prompt: NativePrompt) -> dict[str, Any]: + """ + Build an OpenCode prompt request body from a :class:`NativePrompt`. + + :param prompt: The normalized prompt. + :returns: A ``{"parts": [...], ...}`` body for ``POST + /session/{id}/message`` or ``/prompt_async``. + """ + parts: list[dict[str, Any]] = [] + if prompt.text: + parts.append({"type": "text", "text": prompt.text}) + for attachment in prompt.attachments: + part = _attachment_to_part(attachment) + if part is not None: + parts.append(part) + payload: dict[str, Any] = {"parts": parts} + if prompt.system_prompt: + payload["system"] = prompt.system_prompt + model = _split_model(prompt.model) + if model is not None: + payload["model"] = model + return payload + + +def _attachment_to_part(attachment: Mapping[str, Any]) -> dict[str, Any] | None: + """ + Convert an Omnigent attachment block into an OpenCode file part. + + :param attachment: An ``input_image`` / ``input_file`` content block. + :returns: A ``FilePartInput`` dict, or ``None`` when unconvertible. + """ + block_type = attachment.get("type") + if block_type == "input_image": + url = attachment.get("image_url") + if isinstance(url, str) and url: + mime = _mime_from_data_uri(url) or "image/png" + return {"type": "file", "mime": mime, "url": url} + if block_type == "input_file": + url = attachment.get("file_data") or attachment.get("url") + if isinstance(url, str) and url: + mime = _mime_from_data_uri(url) or "application/octet-stream" + part: dict[str, Any] = {"type": "file", "mime": mime, "url": url} + filename = attachment.get("filename") + if isinstance(filename, str) and filename: + part["filename"] = filename + return part + return None + + +def _mime_from_data_uri(uri: str) -> str | None: + """ + Extract the MIME type from a ``data:`` URI. + + :param uri: A data URI, e.g. ``"data:image/png;base64,..."``. + :returns: The MIME type, or ``None``. + """ + if not uri.startswith("data:"): + return None + head = uri[len("data:") :].split(",", 1)[0] + mime = head.split(";", 1)[0] + return mime or None + + +def _split_model(model: str | None) -> dict[str, str] | None: + """ + Split a ``provider/model`` id into the OpenCode prompt model object. + + :param model: A model id, e.g. ``"anthropic/claude-opus-4"``; ``None`` + means no pin. + :returns: ``{"providerID": ..., "modelID": ...}`` or ``None``. + """ + if not model: + return None + provider, sep, model_id = model.partition("/") + if sep and provider and model_id: + return {"providerID": provider, "modelID": model_id} + return None + + +class OpenCodeHttpTransport: + """ + HTTP + SSE transport for opencode-native. + + :param bridge_dir: Bridge dir to read server URL + auth from when no + server/client is injected (harness-side). + :param server: A running :class:`OpenCodeNativeServer` (runner-side). + :param client_factory: Optional client builder (tests). + :param directory: Workspace directory routing header. + """ + + descriptor_id = "opencode-native" + + def __init__( + self, + *, + bridge_dir: Path | None = None, + server: OpenCodeNativeServer | None = None, + client_factory: ClientFactory | None = None, + directory: str | None = None, + ) -> None: + self._bridge_dir = bridge_dir + self._server = server + self._client_factory = client_factory + self._directory = directory + + def _client(self) -> OpenCodeClient: + """ + Build a client from the injected factory, server, or bridge state. + + :returns: A fresh :class:`OpenCodeClient` (caller closes it). + :raises RuntimeError: When no connection coordinates are available. + """ + if self._client_factory is not None: + return self._client_factory() + if self._server is not None: + return self._server.client(directory=self._directory) + if self._bridge_dir is not None: + state = read_bridge_state(self._bridge_dir) + if state is not None: + return client_for_state( + base_url=state.server_base_url, + auth_secret=state.auth_secret, + directory=self._directory or state.workspace, + ) + raise RuntimeError("OpenCodeHttpTransport has no server/client/bridge state") + + async def start_server(self, launch: NativeLaunchConfig) -> NativeServerHandle: + """Start the OpenCode server and return its handle.""" + if self._server is None: + self._server = OpenCodeNativeServer( + bridge_dir=self._bridge_dir or Path(launch.workspace), + workspace=Path(launch.workspace), + ) + await self._server.start() + pid = self._server.process.pid if self._server.process is not None else None + return NativeServerHandle( + base_url=self._server.base_url, + env=self._server.env, + bridge_dir=self._server.bridge_dir, + process_id=pid, + ) + + async def stop_server(self) -> None: + """Stop the OpenCode server, if this transport started one.""" + if self._server is not None: + await self._server.close() + + async def create_or_resume_session(self, launch: NativeLaunchConfig) -> str: + """Resume the external session id, or create a new OpenCode session.""" + client = self._client() + try: + if launch.external_session_id: + existing = await client.get_session(launch.external_session_id) + if existing is not None: + return existing.id + created = await client.create_session( + {"title": f"omnigent:{launch.omnigent_session_id}"} + ) + return created.id + finally: + await client.aclose() + + async def send_prompt(self, session_id: str, prompt: NativePrompt) -> Mapping[str, Any]: + """Inject a prompt via ``POST /session/{id}/prompt_async``.""" + client = self._client() + try: + return await client.prompt_async(session_id, build_prompt_payload(prompt)) + finally: + await client.aclose() + + async def abort(self, session_id: str) -> bool: + """Abort active work via ``POST /session/{id}/abort``.""" + client = self._client() + try: + return await client.abort(session_id) + finally: + await client.aclose() + + async def events(self, session_id: str) -> AsyncIterator[NativeEvent]: + """Stream native events, filtered to *session_id*.""" + del session_id + client = self._client() + try: + async for event in client.events(): + yield NativeEvent( + id=event.id, + type=event.type, + payload=event.properties, + raw=event.raw, + ) + finally: + await client.aclose() + + async def list_history(self, session_id: str) -> list[Mapping[str, Any]]: + """Return the session's message history.""" + client = self._client() + try: + return list(await client.list_messages(session_id)) + finally: + await client.aclose() + + async def fork(self, session_id: str, *, at_message_id: str | None = None) -> str: + """Fork the session via ``POST /session/{id}/fork``.""" + client = self._client() + try: + payload = {"messageID": at_message_id} if at_message_id else None + forked = await client.fork(session_id, payload) + return forked.id + finally: + await client.aclose() + + async def reply_permission(self, decision: NativePermissionDecision) -> None: + """Relay a permission decision via ``POST /permission/{id}/reply``.""" + reply_map = {"allow_once": "once", "allow_always": "always", "reject": "reject"} + client = self._client() + try: + await client.reply_permission( + decision.request_id, + {"reply": reply_map[decision.decision], "message": decision.message or ""}, + ) + finally: + await client.aclose() + + def build_tui_attach_command( + self, launch: NativeLaunchConfig, session_id: str + ) -> tuple[list[str], Mapping[str, str]]: + """Build the ``opencode attach`` argv + env for a TUI takeover.""" + server_url = launch.server_url or (self._server.base_url if self._server else "") + argv = build_opencode_attach_args( + server_url=server_url, + workspace=launch.workspace, + session_id=session_id, + opencode_args=launch.terminal_launch_args, + ) + env: Mapping[str, str] = ( + opencode_terminal_env(self._server) if self._server is not None else {} + ) + return argv, env diff --git a/omnigent/opencode_native.py b/omnigent/opencode_native.py new file mode 100644 index 00000000..125a053c --- /dev/null +++ b/omnigent/opencode_native.py @@ -0,0 +1,560 @@ +"""Native OpenCode wrapper agent spec for ``opencode-native-ui``. + +Materializes the terminal-first built-in agent the server seeds (parallel +to ``omnigent.codex_native._materialize_codex_agent_spec``). The runner +owns the ``opencode serve`` process and SSE forwarder; this spec just binds +the ``opencode-native`` harness and declares the spawn/terminal surface so +the web UI renders the session terminal-first. + +This module also hosts the interactive local ``omnigent opencode`` CLI wrapper +(:func:`run_opencode_native`, the analog of ``omnigent codex`` / ``omnigent pi``): +it ensures a local daemon + runner, creates-or-resumes the ``opencode-native-ui`` +session (whose runner auto-creates the ``opencode serve`` + ``opencode attach`` +terminal), and attaches this TTY directly to that runner-owned tmux pane — the +same web-UI takeover path, driven from the CLI. The provider/gateway comes from +the runner's ambient env / ``omnigent setup`` config (a profile-bound spec routes +through the Databricks gateway; otherwise OpenAI-/Anthropic-compatible env vars). +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any + +import click +import httpx +import yaml + +from omnigent._native_resume_hint import echo_native_resume_hint +from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress +from omnigent._wrapper_labels import OPENCODE_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE +from omnigent._wrapper_labels import WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY +from omnigent.conversation_browser import conversation_url, open_conversation_link_if_enabled +from omnigent.entities.session_resources import terminal_resource_id +from omnigent.host.daemon_launch import ( + error_text, + launch_or_reuse_daemon_runner, + wait_for_host_online, + wait_for_runner_online, +) +from omnigent.native_terminal import ( + DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S, +) +from omnigent.native_terminal import ( + DAEMON_RUNNER_ONLINE_TIMEOUT_S as _DAEMON_RUNNER_ONLINE_TIMEOUT_S, +) +from omnigent.native_terminal import ( + DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S, +) +from omnigent.native_terminal import bind_session_runner as _bind_session_runner +from omnigent.native_terminal import url_component + +# Built-in native-UI agent name (matches the descriptor's +# ``wrapper_agent_name`` and the ap-web native registry). +_AGENT_NAME = "opencode-native-ui" + + +def _materialize_opencode_agent_spec( + tmpdir: Path, + *, + model: str | None = None, +) -> Path: + """ + Write the terminal-first agent spec used by the OpenCode native UI. + + :param tmpdir: Temporary directory for the generated YAML file. + :param model: Optional model id, e.g. ``"anthropic/claude-opus-4"``. + :returns: Path to the generated YAML spec. + """ + yaml_path = tmpdir / "opencode-native-ui.yaml" + executor: dict[str, str] = {"harness": "opencode-native"} + if model is not None: + executor["model"] = model + raw: dict[str, Any] = { + "name": _AGENT_NAME, + "prompt": ( + "OpenCode is running in the session terminal. Web UI messages are " + "forwarded into the same native OpenCode server session." + ), + "executor": executor, + # Opt the native session into the child-session spawn writes + # (sys_session_create / sys_session_send / sys_session_close) so the + # wrapped opencode can author agent configs and launch them as + # sub-agent sessions. The relay derives its advertised tool set from + # this spec via ToolManager. + "spawn": True, + "os_env": { + "type": "caller_process", + "cwd": ".", + "sandbox": {"type": "none"}, + }, + # Declare a default shell terminal so the relay advertises the + # ``sys_terminal_*`` family to the wrapped opencode (the relay's gate + # is a non-empty ``terminals:`` block on this spec). + "terminals": { + "shell": { + "command": "bash", + "allow_cwd_override": True, + "os_env": { + "type": "caller_process", + "cwd": ".", + "sandbox": {"type": "none"}, + }, + }, + }, + } + yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") + return yaml_path + + +_TERMINAL_NAME = "opencode" +_TERMINAL_SESSION_KEY = "main" +_SESSION_LABELS = { + "omnigent.ui": "terminal", + _WRAPPER_LABEL_KEY: _WRAPPER_LABEL_VALUE, +} + + +@dataclass(frozen=True) +class LaunchedOpenCodeTerminal: + """Terminal resource returned by the Omnigent runner launch path.""" + + terminal_id: str + tmux_socket: Path | None + tmux_target: str | None + + +@dataclass(frozen=True) +class PreparedOpenCodeTerminal: + """Prepared native OpenCode terminal attachment details.""" + + session_id: str + terminal_id: str + tmux_socket: Path | None + tmux_target: str | None + reattached: bool + + +def opencode_terminal_resource_id() -> str: + """:returns: The deterministic terminal resource id for OpenCode.""" + return terminal_resource_id(_TERMINAL_NAME, _TERMINAL_SESSION_KEY) + + +def _preflight_local_tools() -> None: + """Verify local executables the native OpenCode wrapper needs.""" + if shutil.which("tmux") is None: + raise click.ClickException( + "tmux was not found on local PATH. The native OpenCode wrapper " + "attaches to the runner-owned OpenCode tmux terminal." + ) + + +def run_opencode_native( # pragma: no cover + *, + server: str | None, + session_id: str | None, + opencode_args: tuple[str, ...], + resume_picker: bool = False, + model: str | None = None, + auto_open_conversation: bool = False, +) -> None: + """ + Launch the OpenCode TUI in an Omnigent terminal (the ``omnigent opencode`` path). + + Mirrors ``omnigent codex`` / ``omnigent pi``: ensure a local daemon + runner, + create-or-resume the ``opencode-native-ui`` session (the runner auto-creates + the ``opencode serve`` + ``opencode attach`` terminal), then attach this TTY + to that runner-owned tmux pane. + + :param server: Resolved Omnigent server URL. ``None`` is an error (the CLI + must resolve a backend first). + :param session_id: Optional existing Omnigent conversation id to resume. + :param opencode_args: Raw ``opencode`` CLI args to persist for the TUI. + :param resume_picker: When ``True``, run the opencode-native resume picker. + :param model: Optional model id pinned on the materialized wrapper spec. + :param auto_open_conversation: Open the browser conversation URL on launch. + :returns: None after the terminal attach session ends. + """ + _preflight_local_tools() + if server is None: + raise click.ClickException( + "OpenCode requires a resolved Omnigent server URL. The CLI should resolve " + "a backend before run_opencode_native." + ) + with TemporaryDirectory(prefix="omnigent-opencode-native-") as tmpdir: + spec_path = _materialize_opencode_agent_spec(Path(tmpdir), model=model) + _run_with_remote_server( + server.rstrip("/"), + spec_path, + session_id=session_id, + resume_picker=resume_picker, + opencode_args=opencode_args, + auto_open_conversation=auto_open_conversation, + ) + + +def _run_with_remote_server( # pragma: no cover + base_url: str, + spec_path: Path, + *, + session_id: str | None, + resume_picker: bool, + opencode_args: tuple[str, ...], + auto_open_conversation: bool = False, +) -> None: + """Launch OpenCode on an Omnigent server via a daemon-spawned runner.""" + from omnigent.chat import _bundle_agent, _remote_headers + from omnigent.cli import _ensure_host_daemon + from omnigent.host.identity import load_or_create_host_identity + + headers = _remote_headers(server_url=base_url) + try: + resolved_session_id = _resolve_session_id_for_resume( + base_url=base_url, + headers=headers, + session_id=session_id, + resume_picker=resume_picker, + ) + if resolved_session_id is None and resume_picker and session_id is None: + return + + async def _drive() -> None: + with runner_startup_progress(initial_message="Preparing OpenCode...") as progress: + _update_startup_progress(progress, "Connecting to local daemon...") + _ensure_host_daemon(base_url) + host_id = load_or_create_host_identity().host_id + bundle = None if resolved_session_id is not None else _bundle_agent(spec_path) + prepared = await _prepare_opencode_terminal_via_daemon( + base_url=base_url, + headers=headers, + session_id=resolved_session_id, + session_bundle=bundle, + opencode_args=opencode_args, + host_id=host_id, + workspace=str(Path.cwd().resolve()), + startup_progress=progress, + ) + click.echo(f"Web UI: {conversation_url(base_url, prepared.session_id)}", err=True) + open_conversation_link_if_enabled( + base_url=base_url, + conversation_id=prepared.session_id, + enabled=auto_open_conversation, + warn=lambda message: click.echo(message, err=True), + ) + await _attach_terminal_resource(prepared) + if resolved_session_id is None: + echo_native_resume_hint( + native_command="opencode", + session_id=prepared.session_id, + server=base_url, + ) + + asyncio.run(_drive()) + except httpx.ConnectError as exc: + raise click.ClickException( + f"Could not reach the omnigent server at {base_url}. " + "Confirm the server is running and reachable from here " + f"(e.g. `curl {base_url}/health`), and that --server is correct." + ) from exc + + +async def _prepare_opencode_terminal_via_daemon( # pragma: no cover + *, + base_url: str, + headers: dict[str, str], + session_id: str | None, + session_bundle: bytes | None, + opencode_args: tuple[str, ...], + host_id: str, + workspace: str, + startup_progress: RunnerStartupProgress | None = None, +) -> PreparedOpenCodeTerminal: + """Create or resume an opencode-native session through a daemon runner.""" + persist_args = list(opencode_args) + timeout = httpx.Timeout(30.0, read=120.0) + async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client: + reattached = session_id is not None + if session_id is None: + if session_bundle is None: + raise click.ClickException( + "Creating an OpenCode session requires a session bundle." + ) + _update_startup_progress(startup_progress, "Creating OpenCode session...") + session_id = await _create_opencode_session( + client, session_bundle, terminal_launch_args=persist_args or None + ) + else: + _update_startup_progress(startup_progress, "Loading OpenCode session...") + payload = await _fetch_opencode_session(client, session_id) + labels = payload.get("labels") if isinstance(payload, dict) else None + if ( + not isinstance(labels, dict) + or labels.get(_WRAPPER_LABEL_KEY) != _WRAPPER_LABEL_VALUE + ): + raise click.ClickException( + f"Conversation {session_id!r} is not an opencode-native session." + ) + existing_terminal = await _find_running_opencode_terminal(client, session_id) + if existing_terminal is not None: + if persist_args: + click.echo( + "Ignoring OpenCode launch args for an already-running terminal; " + "restart the session terminal to apply them.", + err=True, + ) + _update_startup_progress(startup_progress, "OpenCode terminal ready.") + return PreparedOpenCodeTerminal( + session_id=session_id, + terminal_id=existing_terminal.terminal_id, + tmux_socket=existing_terminal.tmux_socket, + tmux_target=existing_terminal.tmux_target, + reattached=True, + ) + if persist_args: + _update_startup_progress(startup_progress, "Updating OpenCode session...") + resp = await client.patch( + f"/v1/sessions/{url_component(session_id)}", + json={"terminal_launch_args": persist_args}, + ) + if resp.status_code >= 400: + raise click.ClickException( + f"OpenCode session launch config update failed " + f"({resp.status_code}): {error_text(resp)}" + ) + + await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S) + _update_startup_progress(startup_progress, "Starting runner...") + runner_id = await launch_or_reuse_daemon_runner( + client, host_id=host_id, session_id=session_id, workspace=workspace + ) + _update_startup_progress(startup_progress, "Waiting for runner...") + await wait_for_runner_online(client, runner_id, timeout_s=_DAEMON_RUNNER_ONLINE_TIMEOUT_S) + await _bind_session_runner(client, session_id, runner_id) + _update_startup_progress(startup_progress, "Starting OpenCode terminal...") + await _ensure_opencode_terminal_on_runner(client, session_id) + terminal = await _wait_for_opencode_terminal_ready( + client, session_id, timeout_s=_DAEMON_TERMINAL_READY_TIMEOUT_S + ) + _update_startup_progress(startup_progress, "OpenCode terminal ready.") + return PreparedOpenCodeTerminal( + session_id=session_id, + terminal_id=terminal.terminal_id, + tmux_socket=terminal.tmux_socket, + tmux_target=terminal.tmux_target, + reattached=reattached, + ) + + +async def _create_opencode_session( + client: httpx.AsyncClient, + bundle: bytes, + *, + terminal_launch_args: list[str] | None = None, +) -> str: + """Create a bundled terminal-first opencode-native session.""" + metadata: dict[str, Any] = {"labels": dict(_SESSION_LABELS)} + if terminal_launch_args: + metadata["terminal_launch_args"] = terminal_launch_args + resp = await client.post( + "/v1/sessions", + data={"metadata": json.dumps(metadata)}, + files={"bundle": ("opencode-native-ui.tar.gz", bundle, "application/gzip")}, + timeout=120.0, + ) + if resp.status_code >= 400: + raise click.ClickException( + f"OpenCode session creation failed ({resp.status_code}): {error_text(resp)}" + ) + body = resp.json() + new_session_id = body.get("session_id") + if not isinstance(new_session_id, str) or not new_session_id: + raise click.ClickException( + "OpenCode session creation response did not include session_id." + ) + return new_session_id + + +async def _fetch_opencode_session(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]: + """Fetch an existing Omnigent session.""" + resp = await client.get(f"/v1/sessions/{url_component(session_id)}") + if resp.status_code == 404: + raise click.ClickException(f"Conversation {session_id!r} not found on the server.") + if resp.status_code >= 400: + raise click.ClickException( + f"Failed to fetch conversation {session_id!r} ({resp.status_code}): {error_text(resp)}" + ) + payload = resp.json() + if not isinstance(payload, dict): + raise click.ClickException("Conversation fetch returned non-object JSON.") + return payload + + +async def _ensure_opencode_terminal_on_runner(client: httpx.AsyncClient, session_id: str) -> None: + """Ask the bound runner to ensure the OpenCode terminal exists (idempotent).""" + resp = await client.post( + f"/v1/sessions/{url_component(session_id)}/resources/terminals", + json={ + "terminal": _TERMINAL_NAME, + "session_key": _TERMINAL_SESSION_KEY, + "ensure_native_terminal": True, + }, + timeout=60.0, + ) + if resp.status_code >= 400: + raise click.ClickException( + f"OpenCode terminal ensure failed ({resp.status_code}): {error_text(resp)}" + ) + + +async def _wait_for_opencode_terminal_ready( + client: httpx.AsyncClient, + session_id: str, + *, + timeout_s: float, +) -> LaunchedOpenCodeTerminal: + """Wait until the runner exposes the OpenCode terminal resource.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_s + while loop.time() < deadline: + terminal = await _find_running_opencode_terminal(client, session_id) + if terminal is not None: + return terminal + await asyncio.sleep(0.2) + raise click.ClickException( + f"The runner did not create the OpenCode terminal for {session_id!r} " + f"within {timeout_s:.0f}s." + ) + + +async def _find_running_opencode_terminal( + client: httpx.AsyncClient, + session_id: str, +) -> LaunchedOpenCodeTerminal | None: + """Return the existing running OpenCode terminal if present.""" + terminal_id = opencode_terminal_resource_id() + resp = await client.get( + f"/v1/sessions/{url_component(session_id)}" + f"/resources/terminals/{url_component(terminal_id)}" + ) + if resp.status_code == 404: + return None + if resp.status_code >= 400: + text = error_text(resp) + if resp.status_code in {409, 503} and ( + "not bound to a runner" in text or "offline" in text + ): + return None + raise click.ClickException( + f"Failed to fetch OpenCode terminal ({resp.status_code}): {text}" + ) + payload = resp.json() + metadata = payload.get("metadata") if isinstance(payload, dict) else None + if isinstance(metadata, dict) and metadata.get("running") is False: + return None + return _launched_opencode_terminal_from_payload(payload) + + +def _launched_opencode_terminal_from_payload(payload: object) -> LaunchedOpenCodeTerminal: + """Decode terminal launch metadata returned by the runner.""" + if not isinstance(payload, dict): + raise click.ClickException("OpenCode terminal launch returned non-object JSON.") + terminal_id = payload.get("id") + if not isinstance(terminal_id, str) or not terminal_id: + raise click.ClickException( + "OpenCode terminal launch response did not include terminal id." + ) + metadata = payload.get("metadata") + tmux_socket: Path | None = None + tmux_target: str | None = None + if isinstance(metadata, dict): + raw_socket = metadata.get("tmux_socket") + raw_target = metadata.get("tmux_target") + if isinstance(raw_socket, str) and raw_socket: + tmux_socket = Path(raw_socket) + if isinstance(raw_target, str) and raw_target: + tmux_target = raw_target + return LaunchedOpenCodeTerminal( + terminal_id=terminal_id, + tmux_socket=tmux_socket, + tmux_target=tmux_target, + ) + + +async def _attach_terminal_resource( # pragma: no cover + prepared: PreparedOpenCodeTerminal, +) -> None: + """Attach the current terminal to the prepared OpenCode terminal resource.""" + reason = _direct_tmux_unavailable_reason(prepared) + if reason is not None: + raise click.ClickException( + f"Runner-owned OpenCode terminal requires direct tmux attach, but {reason}" + ) + assert prepared.tmux_socket is not None and prepared.tmux_target is not None + await _attach_direct_tmux(prepared.tmux_socket, prepared.tmux_target) + + +async def _attach_direct_tmux(socket_path: Path, tmux_target: str) -> None: # pragma: no cover + """Attach the current terminal directly to the runner-owned tmux pane.""" + env = dict(os.environ) + env.pop("TMUX", None) + process = await asyncio.create_subprocess_exec( + "tmux", "-S", str(socket_path), "-f", os.devnull, "attach", "-t", tmux_target, env=env + ) + await process.wait() + + +def _direct_tmux_unavailable_reason(prepared: PreparedOpenCodeTerminal) -> str | None: + """Explain why direct tmux attach is unavailable.""" + if prepared.tmux_socket is None: + return "the terminal resource did not include a tmux socket path." + if prepared.tmux_target is None: + return "the terminal resource did not include a tmux target." + if not prepared.tmux_socket.exists(): + return f"tmux socket {prepared.tmux_socket} is not reachable from this CLI process." + if shutil.which("tmux") is None: + return "tmux is not available on PATH." + return None + + +def _resolve_session_id_for_resume( + *, + base_url: str, + headers: dict[str, str], + session_id: str | None, + resume_picker: bool, +) -> str | None: + """Translate resume inputs into a concrete opencode-native session id.""" + if session_id is not None: + return session_id + if not resume_picker: + return None + # Interactive SDK resume picker — exercised manually / via the live host + # e2e, not unit tests (it opens an OmnigentClient and an arrow-key picker). + from omnigent_client import OmnigentClient # pragma: no cover + + from omnigent.repl._resume_picker import pick_conversation_by_wrapper_label_from_sdk + + async def _drive() -> str | None: # pragma: no cover + async with OmnigentClient( + base_url=base_url, headers=headers if headers else None + ) as client: + return await pick_conversation_by_wrapper_label_from_sdk( + client, wrapper_value=_WRAPPER_LABEL_VALUE, agent_name=_AGENT_NAME + ) + + return asyncio.run(_drive()) # pragma: no cover + + +def _update_startup_progress( + startup_progress: RunnerStartupProgress | None, + message: str, +) -> None: + """Show one concise OpenCode startup milestone when a renderer is active.""" + if startup_progress is not None: + startup_progress.update(message) diff --git a/omnigent/opencode_native_app_server.py b/omnigent/opencode_native_app_server.py new file mode 100644 index 00000000..4cde4f8c --- /dev/null +++ b/omnigent/opencode_native_app_server.py @@ -0,0 +1,493 @@ +"""Process manager for a per-conversation ``opencode serve`` server. + +Mirrors :mod:`omnigent.codex_native_app_server` but for OpenCode's HTTP + +SSE transport. The runner owns this server (and the SSE forwarder); the +harness-side executor injects web turns over REST using the loopback URL +and auth secret published in the bridge state. + +Responsibilities: + +- Resolve and version-check the ``opencode`` CLI. +- Allocate a loopback port and per-session XDG data/config roots. +- Launch ``opencode serve --hostname 127.0.0.1 --port `` with a + random ``OPENCODE_SERVER_PASSWORD`` and the per-session XDG dirs. +- Poll the HTTP API for readiness. +- Expose ``base_url``, ``auth_headers``, ``xdg_data_home`` / + ``xdg_config_home``, and a process handle. +- Build the ``opencode attach`` argv + env for the terminal takeover (the + Codex ``--remote`` analog). +- Terminate the process on session close / runner shutdown. + +Security posture: bind to ``127.0.0.1`` only, random per-session password, +per-session XDG dirs (never the user's global OpenCode state). The server +is runner-internal — the web UI attaches to Omnigent terminal resources, +never to the OpenCode HTTP port. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import shutil +import socket +import subprocess +from collections.abc import Mapping, Sequence +from pathlib import Path + +import httpx +from packaging.version import InvalidVersion, Version + +from omnigent.opencode_native_bridge import ( + OPENCODE_DEFAULT_USERNAME, + OPENCODE_SERVER_PASSWORD_ENV_VAR, + OPENCODE_SERVER_USERNAME_ENV_VAR, + auth_headers_for_secret, + ensure_auth_secret, + xdg_config_home_for_bridge_dir, + xdg_data_home_for_bridge_dir, +) +from omnigent.opencode_native_client import ( + OPENCODE_MAX_VERSION_EXCLUSIVE, + OPENCODE_MIN_VERSION, + OpenCodeClient, +) + +_logger = logging.getLogger(__name__) + +# Env vars the OpenCode server inherits from the parent that are safe and +# useful (provider creds + proxy). Everything else is filtered out so the +# server runs against a clean, per-session environment. +_ENV_PASSTHROUGH_PREFIXES = ( + "OPENAI_", + "ANTHROPIC_", + "OPENCODE_", + "DATABRICKS_", + "GEMINI_", + "GOOGLE_", + "HTTP_", + "HTTPS_", + "NO_PROXY", + "ALL_PROXY", +) +_ENV_PASSTHROUGH_KEYS = ( + "PATH", + "HOME", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "no_proxy", + "http_proxy", + "https_proxy", +) +# OpenCode env vars that point the server at the user's GLOBAL config — they +# would defeat the per-session XDG isolation by re-introducing whatever +# config/model/permission settings the parent shell has set. Dropped from +# the passthrough even though they match the ``OPENCODE_`` prefix, so an +# isolated session never inherits unrelated global OpenCode config. +_ENV_OPENCODE_CONFIG_DENYLIST = frozenset( + { + "OPENCODE_CONFIG", + "OPENCODE_CONFIG_CONTENT", + } +) + +_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z]+)*)") + + +class OpenCodeVersionError(RuntimeError): + """Raised when the installed ``opencode`` CLI is an unsupported version.""" + + +class OpenCodeCliNotFoundError(RuntimeError): + """Raised when no ``opencode`` executable can be resolved on ``PATH``.""" + + +def find_opencode_cli(opencode_path: str | None = None) -> str: + """ + Resolve the ``opencode`` executable. + + :param opencode_path: Explicit path override; ``None`` searches ``PATH``. + :returns: Absolute path to the ``opencode`` binary. + :raises OpenCodeCliNotFoundError: When no binary can be resolved. + """ + if opencode_path: + if os.path.isabs(opencode_path) and os.access(opencode_path, os.X_OK): + return opencode_path + resolved = shutil.which(opencode_path) + if resolved: + return resolved + raise OpenCodeCliNotFoundError(f"opencode executable not found: {opencode_path!r}") + resolved = shutil.which("opencode") + if not resolved: + raise OpenCodeCliNotFoundError( + "opencode CLI not found on PATH; install the 'opencode-ai' npm package" + ) + return resolved + + +def parse_opencode_version(text: str) -> str | None: + """ + Extract a semver string from ``opencode --version`` output. + + :param text: Raw CLI output, e.g. ``"opencode 1.17.7"`` or ``"1.17.7"``. + :returns: The parsed version, e.g. ``"1.17.7"``, or ``None``. + """ + match = _VERSION_RE.search(text or "") + return match.group(1) if match else None + + +def check_opencode_version( + version: str, + *, + minimum: str = OPENCODE_MIN_VERSION, + maximum_exclusive: str = OPENCODE_MAX_VERSION_EXCLUSIVE, +) -> None: + """ + Validate an OpenCode version against the supported range. + + :param version: Version string, e.g. ``"1.17.7"``. + :param minimum: Inclusive lower bound. + :param maximum_exclusive: Exclusive upper bound. + :raises OpenCodeVersionError: When *version* is unparsable or outside + ``[minimum, maximum_exclusive)``. + """ + try: + parsed = Version(version) + low = Version(minimum) + high = Version(maximum_exclusive) + except InvalidVersion as exc: + raise OpenCodeVersionError(f"Unparsable OpenCode version {version!r}: {exc}") from exc + if parsed < low or parsed >= high: + raise OpenCodeVersionError( + f"Unsupported OpenCode version {version}: requires >={minimum},<{maximum_exclusive}. " + "Install a pinned 'opencode-ai' release." + ) + + +def resolve_opencode_version(opencode_path: str) -> str: + """ + Run ``opencode --version`` and return the parsed version. + + :param opencode_path: Path to the ``opencode`` binary. + :returns: Parsed version string, e.g. ``"1.17.7"``. + :raises OpenCodeVersionError: When the version cannot be determined. + """ + try: + completed = subprocess.run( + [opencode_path, "--version"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise OpenCodeVersionError(f"Could not run 'opencode --version': {exc}") from exc + output = f"{completed.stdout}\n{completed.stderr}" + version = parse_opencode_version(output) + if version is None: + raise OpenCodeVersionError(f"Could not parse OpenCode version from: {output!r}") + return version + + +def allocate_loopback_port() -> int: + """ + Allocate an ephemeral loopback TCP port. + + :returns: A free port number on ``127.0.0.1``. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def build_opencode_serve_args( + *, + hostname: str, + port: int, + opencode_args: Sequence[str] = (), +) -> list[str]: + """ + Build the ``opencode serve`` argv tail (after the executable). + + Always passes explicit ``--hostname``/``--port`` so config can't + override them (the source default port is ``0``). + + :param hostname: Bind hostname, e.g. ``"127.0.0.1"``. + :param port: Bind port. + :param opencode_args: Extra pass-through args. + :returns: Argv tail, e.g. ``["serve", "--hostname", "127.0.0.1", + "--port", "49231"]``. + """ + return ["serve", "--hostname", hostname, "--port", str(port), *opencode_args] + + +def build_opencode_attach_args( + *, + server_url: str, + workspace: str, + session_id: str | None, + opencode_args: Sequence[str] = (), +) -> list[str]: + """ + Build the ``opencode attach`` argv for a terminal takeover. + + Mirrors codex's ``--remote`` attach: the TUI attaches to the + already-running server so the terminal, forwarder, and web-UI bridge + all drive the same OpenCode session. + + :param server_url: The server URL, e.g. ``"http://127.0.0.1:49231"``. + :param workspace: Directory the TUI runs in (``--dir``). + :param session_id: OpenCode session id to attach (``--session``), or + ``None`` to let the TUI choose. + :param opencode_args: Extra pass-through args appended last. + :returns: Argv tail after the executable. + """ + args = ["attach", server_url, "--dir", workspace] + if session_id: + args.extend(["--session", session_id]) + args.extend(opencode_args) + return args + + +def filtered_server_env( + *, + bridge_dir: Path, + auth_secret: str, + extra_env: Mapping[str, str] | None = None, +) -> dict[str, str]: + """ + Build the launch environment for ``opencode serve``. + + Per-session XDG dirs isolate OpenCode's state from the user's global + config; ``OPENCODE_SERVER_PASSWORD`` secures the loopback server. Only + provider/proxy env from the parent is passed through. + + :param bridge_dir: Native OpenCode bridge directory. + :param auth_secret: Server password for basic auth. + :param extra_env: Additional provider env (e.g. from Omnigent setup). + :returns: The environment mapping for the server subprocess. + """ + env: dict[str, str] = {} + for key, value in os.environ.items(): + if key in _ENV_OPENCODE_CONFIG_DENYLIST: + # Never inherit the parent's global OpenCode config — the + # per-session XDG dirs are the only config source. + continue + if key in _ENV_PASSTHROUGH_KEYS or key.startswith(_ENV_PASSTHROUGH_PREFIXES): + env[key] = value + env.update(extra_env or {}) + env["XDG_DATA_HOME"] = str(xdg_data_home_for_bridge_dir(bridge_dir)) + env["XDG_CONFIG_HOME"] = str(xdg_config_home_for_bridge_dir(bridge_dir)) + env[OPENCODE_SERVER_PASSWORD_ENV_VAR] = auth_secret + env[OPENCODE_SERVER_USERNAME_ENV_VAR] = OPENCODE_DEFAULT_USERNAME + return env + + +def opencode_terminal_env(server: OpenCodeNativeServer) -> dict[str, str]: + """ + Build terminal-process env for the native OpenCode TUI (``attach``). + + Keeping the password in the environment avoids leaking it on argv + (``--password`` defaults to ``OPENCODE_SERVER_PASSWORD``). + + :param server: The running server wrapper. + :returns: Environment variables for the attach terminal process. + """ + return { + OPENCODE_SERVER_PASSWORD_ENV_VAR: server.auth_secret, + OPENCODE_SERVER_USERNAME_ENV_VAR: OPENCODE_DEFAULT_USERNAME, + "XDG_DATA_HOME": str(server.xdg_data_home), + "XDG_CONFIG_HOME": str(server.xdg_config_home), + } + + +class OpenCodeNativeServer: + """ + A managed ``opencode serve`` subprocess bound to one conversation. + + :param bridge_dir: Native OpenCode bridge directory. + :param workspace: Working directory for the server. + :param opencode_path: Path to the ``opencode`` binary; ``None`` + searches ``PATH``. + :param hostname: Bind hostname (always loopback). + :param port: Explicit port; ``None`` allocates an ephemeral one. + :param extra_env: Provider env merged into the launch environment. + :param opencode_args: Extra ``serve`` pass-through args. + :param verify_version: Whether to version-check the CLI on start. + """ + + def __init__( + self, + *, + bridge_dir: Path, + workspace: Path, + opencode_path: str | None = None, + hostname: str = "127.0.0.1", + port: int | None = None, + extra_env: Mapping[str, str] | None = None, + opencode_args: Sequence[str] = (), + verify_version: bool = True, + ) -> None: + self.bridge_dir = bridge_dir + self.workspace = workspace + self.hostname = hostname + self._explicit_port = port + self._extra_env = dict(extra_env or {}) + self._opencode_args = tuple(opencode_args) + self._verify_version = verify_version + self.opencode_path = find_opencode_cli(opencode_path) + self.auth_secret = ensure_auth_secret(bridge_dir) + self.xdg_data_home = xdg_data_home_for_bridge_dir(bridge_dir) + self.xdg_config_home = xdg_config_home_for_bridge_dir(bridge_dir) + self.port: int | None = port + self.process: subprocess.Popen[bytes] | None = None + self.version: str | None = None + + @property + def base_url(self) -> str: + """:returns: The server base URL once a port is bound.""" + if self.port is None: + raise RuntimeError("OpenCode server has no port yet; call start() first") + return f"http://{self.hostname}:{self.port}" + + @property + def auth_headers(self) -> dict[str, str]: + """:returns: Basic-auth headers for the server.""" + return auth_headers_for_secret(self.auth_secret) + + @property + def env(self) -> dict[str, str]: + """:returns: The launch environment for the server process.""" + return filtered_server_env( + bridge_dir=self.bridge_dir, + auth_secret=self.auth_secret, + extra_env=self._extra_env, + ) + + def build_argv(self) -> list[str]: + """ + Build the full server argv for the resolved port. + + :returns: ``[opencode, serve, --hostname, ..., --port, ...]``. + :raises RuntimeError: When no port has been allocated. + """ + if self.port is None: + raise RuntimeError("OpenCode server port not allocated") + return [ + self.opencode_path, + *build_opencode_serve_args( + hostname=self.hostname, + port=self.port, + opencode_args=self._opencode_args, + ), + ] + + async def start(self) -> None: + """ + Launch the server subprocess and wait until it is ready. + + :raises OpenCodeVersionError: When the CLI version is unsupported. + :raises RuntimeError: When the server does not become ready. + """ + if self._verify_version: + self.version = resolve_opencode_version(self.opencode_path) + check_opencode_version(self.version) + if self.port is None: + self.port = self._explicit_port or allocate_loopback_port() + argv = self.build_argv() + _logger.info( + "Launching opencode serve: port=%s workspace=%s xdg_data=%s", + self.port, + self.workspace, + self.xdg_data_home, + ) + self.process = subprocess.Popen( + argv, + cwd=str(self.workspace), + env=self.env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + await self._wait_until_ready() + + async def _wait_until_ready(self, *, attempts: int = 60, delay: float = 0.5) -> None: + """ + Poll the HTTP API until the server answers or attempts run out. + + :param attempts: Maximum readiness polls. + :param delay: Seconds between polls. + :raises RuntimeError: When the server never becomes ready (or the + process died early). + """ + last_error: Exception | None = None + async with httpx.AsyncClient( + base_url=self.base_url, + headers=self.auth_headers, + timeout=httpx.Timeout(5.0, connect=2.0), + ) as client: + for _ in range(attempts): + if self.process is not None and self.process.poll() is not None: + raise RuntimeError( + f"opencode serve exited early with code {self.process.returncode}" + ) + try: + response = await client.get("/session") + if response.status_code < 500: + return + except httpx.HTTPError as exc: + last_error = exc + await asyncio.sleep(delay) + raise RuntimeError(f"opencode serve did not become ready: {last_error!r}") + + def client(self, *, directory: str | None = None) -> OpenCodeClient: + """ + Build an :class:`OpenCodeClient` bound to this server. + + :param directory: Optional workspace directory routing header. + :returns: A new client (caller owns closing it). + """ + return OpenCodeClient( + self.base_url, + headers=self.auth_headers, + directory=directory or str(self.workspace), + ) + + async def close(self) -> None: # pragma: no cover + """Terminate the server subprocess if running.""" + process = self.process + if process is None: + return + if process.poll() is None: + process.terminate() + try: + await asyncio.to_thread(process.wait, 10) + except subprocess.TimeoutExpired: + process.kill() + await asyncio.to_thread(process.wait) + self.process = None + + +def client_for_state( + *, + base_url: str, + auth_secret: str | None, + directory: str | None = None, +) -> OpenCodeClient: + """ + Build an :class:`OpenCodeClient` from persisted bridge state. + + Used by the harness-side executor, which never owns the server process + — it only has the URL + auth secret from bridge state. + + :param base_url: Server base URL. + :param auth_secret: Server password, or ``None``. + :param directory: Optional workspace routing header. + :returns: A new client. + """ + return OpenCodeClient( + base_url, + headers=auth_headers_for_secret(auth_secret), + directory=directory, + ) diff --git a/omnigent/opencode_native_bridge.py b/omnigent/opencode_native_bridge.py new file mode 100644 index 00000000..4641007e --- /dev/null +++ b/omnigent/opencode_native_bridge.py @@ -0,0 +1,443 @@ +"""Bridge state for native OpenCode (``opencode serve``) sessions. + +The OpenCode native harness mirrors the Codex native bridge, but the +transport is HTTP + SSE instead of WebSocket JSON-RPC. The runner owns +the ``opencode serve`` process and the SSE forwarder; the harness-side +executor (spawned as a separate FastAPI process) reads this bridge state +to learn the loopback server URL, auth secret, and OpenCode session id so +it can inject web turns over REST. + +Layout (per bridge id): + + ~/.omnigent/opencode-native// + state.json # runtime state (mutates each turn) + auth.secret # OPENCODE_SERVER_PASSWORD for this server + xdg-data/ # XDG_DATA_HOME for the per-session opencode + xdg-config/ # XDG_CONFIG_HOME for the per-session opencode + +State (server URL, opencode session id, active message) is written by the +runner-owned server manager / forwarder and read by the harness executor; +the XDG dirs are preserved across runner restarts so a local resume keeps +OpenCode's persisted session history. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import secrets +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path + +# Env var the runner stamps on the harness process so the executor can +# locate its bridge directory. Mirrors ``HARNESS_CODEX_NATIVE_BRIDGE_DIR``. +OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR = "HARNESS_OPENCODE_NATIVE_BRIDGE_DIR" +OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR = "HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID" +# Label key recording the bridge id on the conversation, mirroring the +# codex-native ``omnigent.codex_native.bridge_id`` label. +OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY = "omnigent.opencode_native.bridge_id" + +# OpenCode server basic-auth env vars (see opencode ``attach``/``serve``). +OPENCODE_SERVER_PASSWORD_ENV_VAR = "OPENCODE_SERVER_PASSWORD" +OPENCODE_SERVER_USERNAME_ENV_VAR = "OPENCODE_SERVER_USERNAME" +# Default basic-auth username opencode falls back to when unset. +OPENCODE_DEFAULT_USERNAME = "opencode" + +_STATE_FILE = "state.json" +_AUTH_SECRET_FILE = "auth.secret" +_XDG_DATA_DIR = "xdg-data" +_XDG_CONFIG_DIR = "xdg-config" +_STATE_VERSION = 1 +_BRIDGE_ROOT = Path.home() / ".omnigent" / "opencode-native" +_ID_HASH_CHARS = 32 + + +def bridge_root() -> Path: + """ + Return the configured OpenCode-native bridge root. + + Tests may monkeypatch :data:`_BRIDGE_ROOT` to isolate bridge files. + + :returns: Absolute root for OpenCode-native bridge directories, e.g. + ``Path("~/.omnigent/opencode-native")``. + """ + return _BRIDGE_ROOT + + +@dataclass(frozen=True) +class OpenCodeNativeBridgeState: + """ + Runtime state shared by the native OpenCode wrapper and harness. + + :param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``. + :param server_base_url: Loopback base URL of ``opencode serve``, e.g. + ``"http://127.0.0.1:49231"``. + :param opencode_session_id: OpenCode session id, e.g. ``"ses_abc123"``. + :param auth_secret: ``OPENCODE_SERVER_PASSWORD`` for basic auth, or + ``None`` when the server runs without auth. + :param xdg_data_home: ``XDG_DATA_HOME`` the server runs with. + :param xdg_config_home: ``XDG_CONFIG_HOME`` the server runs with. + :param active_message_id: OpenCode assistant message id of the active + turn, or ``None`` when idle. + :param status: Coarse status, ``"idle"`` or ``"busy"``. + :param model_override: Persisted model override, e.g. + ``"anthropic/claude-opus-4"``, or ``None``. + :param workspace: Workspace cwd the session runs in. + :param last_event_id: Last SSE event id seen, for resume/debug. + """ + + session_id: str + server_base_url: str + opencode_session_id: str + auth_secret: str | None = None + xdg_data_home: str | None = None + xdg_config_home: str | None = None + active_message_id: str | None = None + status: str = "idle" + model_override: str | None = None + workspace: str | None = None + last_event_id: str | None = None + + def auth_headers(self) -> dict[str, str]: + """ + Build basic-auth headers for the OpenCode server. + + :returns: ``{"Authorization": "Basic ..."}`` when an auth secret + is set, otherwise an empty dict. + """ + return auth_headers_for_secret(self.auth_secret) + + +def auth_headers_for_secret(secret: str | None) -> dict[str, str]: + """ + Build OpenCode basic-auth headers for a server password. + + :param secret: The ``OPENCODE_SERVER_PASSWORD`` value, or ``None``. + :returns: ``{"Authorization": "Basic "}`` or ``{}``. + """ + if not secret: + return {} + raw = f"{OPENCODE_DEFAULT_USERNAME}:{secret}".encode() + token = base64.b64encode(raw).decode("ascii") + return {"Authorization": f"Basic {token}"} + + +def bridge_dir_for_bridge_id(bridge_id: str) -> Path: + """ + Return the bridge directory for an OpenCode-native bridge id. + + :param bridge_id: Opaque bridge id, e.g. ``"conv_abc123"``. + :returns: Absolute bridge directory under + ``~/.omnigent/opencode-native``. + """ + digest = hashlib.sha256(bridge_id.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS] + return _BRIDGE_ROOT / digest + + +def build_opencode_native_spawn_env( + conversation_id: str, + *, + bridge_id: str | None = None, +) -> dict[str, str]: + """ + Build spawn env for the ``opencode-native`` harness process. + + :param conversation_id: Omnigent conversation id, e.g. + ``"conv_abc123"``. + :param bridge_id: Opaque bridge id; ``None`` uses *conversation_id*. + :returns: Environment variables the OpenCode-native executor needs. + """ + resolved_bridge_id = bridge_id or conversation_id + return { + OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR: str(bridge_dir_for_bridge_id(resolved_bridge_id)), + OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR: conversation_id, + } + + +def prepare_bridge_dir(bridge_id: str) -> Path: + """ + Create the bridge directory (and XDG roots) for *bridge_id*. + + :param bridge_id: Opaque bridge id, e.g. ``"conv_abc123"``. + :returns: Prepared absolute bridge directory. + """ + bridge_dir = bridge_dir_for_bridge_id(bridge_id) + bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(bridge_dir, 0o700) + xdg_data_home_for_bridge_dir(bridge_dir).mkdir(mode=0o700, parents=True, exist_ok=True) + xdg_config_home_for_bridge_dir(bridge_dir).mkdir(mode=0o700, parents=True, exist_ok=True) + return bridge_dir + + +def xdg_data_home_for_bridge_dir(bridge_dir: Path) -> Path: + """ + Return the per-session ``XDG_DATA_HOME`` for *bridge_dir*. + + :param bridge_dir: Native OpenCode bridge directory. + :returns: Absolute ``XDG_DATA_HOME`` directory. + """ + return bridge_dir / _XDG_DATA_DIR + + +def xdg_config_home_for_bridge_dir(bridge_dir: Path) -> Path: + """ + Return the per-session ``XDG_CONFIG_HOME`` for *bridge_dir*. + + :param bridge_dir: Native OpenCode bridge directory. + :returns: Absolute ``XDG_CONFIG_HOME`` directory. + """ + return bridge_dir / _XDG_CONFIG_DIR + + +def user_opencode_auth_path() -> Path: + """ + Return the user's real OpenCode ``auth.json`` path (not the per-session one). + + Honors ``XDG_DATA_HOME`` (the runner's own env, which is the user's real + data home — the per-session override is set only on the spawned server), + defaulting to ``~/.local/share/opencode/auth.json``. + """ + xdg = os.environ.get("XDG_DATA_HOME", "").strip() + base = Path(xdg) if xdg else Path.home() / ".local" / "share" + return base / "opencode" / "auth.json" + + +def seed_opencode_auth(bridge_dir: Path) -> Path | None: + """ + Copy the user's OpenCode ``auth.json`` into the per-session ``XDG_DATA_HOME``. + + The runner spawns ``opencode serve`` with a per-session ``XDG_DATA_HOME`` + that isolates session state — but it also hides the user's + ``opencode auth login`` credentials (in their real + ``~/.local/share/opencode/auth.json``). Without those, the server can only + reach OpenCode's no-auth default model (``opencode/big-pickle``), so a + user-selected provider/model never takes effect. Copy the credentials in + (best-effort, ``0600``) so the user's providers — and any pinned model that + needs them — work. Refreshed on every spawn so re-logins propagate. + + :param bridge_dir: Native OpenCode bridge directory. + :returns: The destination path written, or ``None`` when there is no + source ``auth.json`` or the copy fails. + """ + src = user_opencode_auth_path() + if not src.is_file(): + return None + dest_dir = xdg_data_home_for_bridge_dir(bridge_dir) / "opencode" + try: + dest_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + dest = dest_dir / "auth.json" + shutil.copyfile(src, dest) + os.chmod(dest, 0o600) + except OSError: + return None + return dest + + +def auth_secret_path(bridge_dir: Path) -> Path: + """ + Return the auth-secret file path for *bridge_dir*. + + :param bridge_dir: Native OpenCode bridge directory. + :returns: Absolute path of the ``auth.secret`` file. + """ + return bridge_dir / _AUTH_SECRET_FILE + + +def ensure_auth_secret(bridge_dir: Path) -> str: + """ + Read or mint the per-session OpenCode server password. + + The secret is reused across server restarts for one bridge dir so a + resumed server keeps the same basic-auth credential the TUI/executor + were configured with. Written ``0600``. + + :param bridge_dir: Native OpenCode bridge directory. + :returns: The server password (``OPENCODE_SERVER_PASSWORD``). + """ + path = auth_secret_path(bridge_dir) + try: + existing = path.read_text(encoding="utf-8").strip() + if existing: + return existing + except FileNotFoundError: + # No secret on disk yet: fall through to mint a fresh one below. + pass + except OSError: + # Secret exists but is unreadable: ignore and regenerate it below. + pass + bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + secret = secrets.token_urlsafe(32) + fd, tmp_name = tempfile.mkstemp(prefix=f"{_AUTH_SECRET_FILE}.", dir=str(bridge_dir)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(secret) + handle.write("\n") + os.chmod(tmp_name, 0o600) + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + return secret + + +def state_path(bridge_dir: Path) -> Path: + """ + Return the bridge state file path for *bridge_dir*. + + :param bridge_dir: Native OpenCode bridge directory. + :returns: Absolute path of the ``state.json`` file. + """ + return bridge_dir / _STATE_FILE + + +def write_bridge_state(bridge_dir: Path, state: OpenCodeNativeBridgeState) -> None: + """ + Persist shared native OpenCode state atomically. + + :param bridge_dir: Native OpenCode bridge directory. + :param state: State payload to persist. + :returns: None. + """ + bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + path = state_path(bridge_dir) + fd, tmp_name = tempfile.mkstemp(prefix=f"{_STATE_FILE}.", dir=str(bridge_dir)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump( + { + "version": _STATE_VERSION, + "session_id": state.session_id, + "server_base_url": state.server_base_url, + "opencode_session_id": state.opencode_session_id, + "auth_secret": state.auth_secret, + "xdg_data_home": state.xdg_data_home, + "xdg_config_home": state.xdg_config_home, + "active_message_id": state.active_message_id, + "status": state.status, + "model_override": state.model_override, + "workspace": state.workspace, + "last_event_id": state.last_event_id, + }, + handle, + sort_keys=True, + ) + handle.write("\n") + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +def clear_bridge_state(bridge_dir: Path) -> None: + """ + Remove stale native OpenCode runtime state for a bridge directory. + + New server launches reuse the same bridge directory for a conversation + id, but the old ``state.json`` may point at a server URL from a + previous process. Clear it before starting the new server so web + message forwarding waits for the new launch to publish its current URL + and session instead of injecting into stale state. + + :param bridge_dir: Native OpenCode bridge directory. + :returns: None. + """ + try: + state_path(bridge_dir).unlink() + except FileNotFoundError: + return + + +def read_bridge_state(bridge_dir: Path) -> OpenCodeNativeBridgeState | None: + """ + Read shared native OpenCode bridge state. + + Corrupt / partial JSON is treated as absent (returns ``None``) so a + half-written file never crashes a turn. + + :param bridge_dir: Native OpenCode bridge directory. + :returns: Parsed state, or ``None`` when no valid state exists. + """ + path = state_path(bridge_dir) + if not path.is_file(): + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(raw, dict): + return None + session_id = raw.get("session_id") + server_base_url = raw.get("server_base_url") + opencode_session_id = raw.get("opencode_session_id") + required = (session_id, server_base_url, opencode_session_id) + if not all(isinstance(value, str) and value for value in required): + return None + + def _opt_str(key: str) -> str | None: + value = raw.get(key) + return value if isinstance(value, str) and value else None + + status = raw.get("status") + return OpenCodeNativeBridgeState( + session_id=session_id, + server_base_url=server_base_url, + opencode_session_id=opencode_session_id, + auth_secret=_opt_str("auth_secret"), + xdg_data_home=_opt_str("xdg_data_home"), + xdg_config_home=_opt_str("xdg_config_home"), + active_message_id=_opt_str("active_message_id"), + status=status if isinstance(status, str) and status else "idle", + model_override=_opt_str("model_override"), + workspace=_opt_str("workspace"), + last_event_id=_opt_str("last_event_id"), + ) + + +def update_active_message_id( + bridge_dir: Path, + active_message_id: str | None, + *, + status: str | None = None, +) -> None: + """ + Update the active OpenCode message id (and optionally status). + + :param bridge_dir: Native OpenCode bridge directory. + :param active_message_id: Active assistant message id, or ``None``. + :param status: New coarse status (``"idle"`` / ``"busy"``); ``None`` + leaves the existing status untouched. + :returns: None. + """ + state = read_bridge_state(bridge_dir) + if state is None: + return + import dataclasses + + write_bridge_state( + bridge_dir, + dataclasses.replace( + state, + active_message_id=active_message_id, + status=status if status is not None else state.status, + ), + ) + + +def update_last_event_id(bridge_dir: Path, last_event_id: str) -> None: + """ + Record the last SSE event id seen by the forwarder. + + :param bridge_dir: Native OpenCode bridge directory. + :param last_event_id: Last SSE event id, e.g. ``"evt_..."``. + :returns: None. + """ + state = read_bridge_state(bridge_dir) + if state is None: + return + import dataclasses + + write_bridge_state(bridge_dir, dataclasses.replace(state, last_event_id=last_event_id)) diff --git a/omnigent/opencode_native_client.py b/omnigent/opencode_native_client.py new file mode 100644 index 00000000..100ddc84 --- /dev/null +++ b/omnigent/opencode_native_client.py @@ -0,0 +1,434 @@ +"""Typed HTTP + SSE client for an ``opencode serve`` native server. + +Shaped from the pinned OpenCode OpenAPI (``opencode`` 1.17.x, +``packages/sdk/openapi.json``). This is a thin typed wrapper over the v1 +REST endpoints the Omnigent OpenCode-native harness needs plus the SSE +``GET /event`` stream — not a full generated SDK. Unknown response fields +are preserved under ``raw`` for forward-compatible logging and fixtures. + +Transport notes: + +- REST + SSE over ``httpx.AsyncClient``; the server binds loopback only. +- Basic auth headers (``OPENCODE_SERVER_PASSWORD``) are attached per + request when provided. +- SSE is parsed with standard ``event:`` / ``data:`` framing; each event + payload is OpenCode's ``{id?, type, properties}`` envelope. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import AsyncIterator, Mapping +from dataclasses import dataclass, field +from typing import Any + +import httpx + +_logger = logging.getLogger(__name__) + +# Pinned OpenCode CLI/API version range. The source monorepo reports +# 1.17.7; we accept 1.17.x and refuse 1.18+ until validated. +OPENCODE_MIN_VERSION = "1.17.7" +OPENCODE_MAX_VERSION_EXCLUSIVE = "1.18.0" + +_DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0) + + +@dataclass(frozen=True) +class OpenCodeSession: + """ + An OpenCode session as returned by the REST API. + + :param id: OpenCode session id, e.g. ``"ses_abc123"``. + :param title: Optional human-readable title. + :param parent_id: Parent session id for forked/child sessions. + :param directory: Session working directory, when reported. + :param raw: The full server payload for forward-compatibility. + """ + + id: str + title: str | None = None + parent_id: str | None = None + directory: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_payload(cls, payload: Mapping[str, Any]) -> OpenCodeSession: + """ + Build an :class:`OpenCodeSession` from a raw server payload. + + :param payload: Decoded JSON object from ``/session`` endpoints. + :returns: Parsed session. + :raises ValueError: When the payload has no string ``id``. + """ + session_id = payload.get("id") + if not isinstance(session_id, str) or not session_id: + raise ValueError("OpenCode session payload missing string 'id'") + title = payload.get("title") + parent_id = payload.get("parentID") + directory = payload.get("directory") + return cls( + id=session_id, + title=title if isinstance(title, str) else None, + parent_id=parent_id if isinstance(parent_id, str) else None, + directory=directory if isinstance(directory, str) else None, + raw=dict(payload), + ) + + +@dataclass(frozen=True) +class OpenCodeEvent: + """ + One decoded OpenCode SSE event. + + :param id: Optional SSE event id. + :param type: Event discriminator, e.g. ``"message.part.updated"`` or + ``"session.next.text.delta"``. + :param properties: The event's ``properties`` object. + :param raw: The full decoded envelope for debugging/forward-compat. + """ + + id: str | None + type: str + properties: dict[str, Any] + raw: dict[str, Any] + + @classmethod + def from_envelope( + cls, envelope: Mapping[str, Any], *, event_id: str | None = None + ) -> OpenCodeEvent: + """ + Build an :class:`OpenCodeEvent` from a decoded SSE data object. + + :param envelope: Decoded JSON, e.g. + ``{"type": "message.part.updated", "properties": {...}}``. + :param event_id: Optional SSE ``id:`` framing value. + :returns: Parsed event; unknown shapes get ``type=""``. + """ + type_value = envelope.get("type") + props = envelope.get("properties") + return cls( + id=envelope.get("id") if isinstance(envelope.get("id"), str) else event_id, + type=type_value if isinstance(type_value, str) else "", + properties=props if isinstance(props, dict) else {}, + raw=dict(envelope), + ) + + +class OpenCodeClientError(RuntimeError): + """Raised when an OpenCode REST call returns a non-2xx response.""" + + +class OpenCodeClient: + """ + Async HTTP + SSE client for one ``opencode serve`` server. + + :param base_url: Server base URL, e.g. ``"http://127.0.0.1:49231"``. + :param headers: Optional default headers (e.g. basic auth). + :param directory: Optional workspace directory; sent as the + ``x-opencode-directory`` header so ``serve`` routes per-request + instances to the right workspace. + :param client: Optional injected ``httpx.AsyncClient`` (tests pass a + client backed by ``httpx.MockTransport``). + """ + + def __init__( + self, + base_url: str, + *, + headers: Mapping[str, str] | None = None, + directory: str | None = None, + client: httpx.AsyncClient | None = None, + ) -> None: + self._base_url = base_url.rstrip("/") + default_headers: dict[str, str] = dict(headers or {}) + if directory: + default_headers.setdefault("x-opencode-directory", directory) + self._directory = directory + self._owns_client = client is None + self._client = client or httpx.AsyncClient( + base_url=self._base_url, + headers=default_headers, + timeout=_DEFAULT_TIMEOUT, + ) + # When a client is injected (tests), still apply our headers so + # auth/directory routing is exercised. + if client is not None: + for key, value in default_headers.items(): + self._client.headers.setdefault(key, value) + + @property + def base_url(self) -> str: + """:returns: The server base URL this client targets.""" + return self._base_url + + async def aclose(self) -> None: + """Close the underlying client when this wrapper owns it.""" + if self._owns_client: + await self._client.aclose() + + async def __aenter__(self) -> OpenCodeClient: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() + + # --- helpers --------------------------------------------------------- + + async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any: + """ + Issue a request and return decoded JSON, raising on HTTP errors. + + :param method: HTTP method, e.g. ``"POST"``. + :param path: Path relative to ``base_url``, e.g. ``"/session"``. + :returns: Decoded JSON (object/array/scalar), or ``None`` for an + empty body. + :raises OpenCodeClientError: On a non-2xx status. + """ + response = await self._client.request(method, path, **kwargs) + if response.status_code >= 400: + raise OpenCodeClientError( + f"OpenCode {method} {path} failed: {response.status_code} {response.text[:500]}" + ) + if not response.content: + return None + try: + return response.json() + except json.JSONDecodeError: + return None + + # --- sessions -------------------------------------------------------- + + async def create_session(self, payload: Mapping[str, Any] | None = None) -> OpenCodeSession: + """ + Create an OpenCode session (``POST /session``). + + :param payload: Optional create body, e.g. ``{"title": "..."}``. + Note: OpenCode's create body only accepts ``title`` / ``parentID`` + — it does NOT accept a model (the model is a per-prompt field on + ``POST /session/{id}/message``). Pin the model per prompt via + :func:`omnigent.opencode_http_transport.build_prompt_payload`. + :returns: The created session. + """ + data = await self._request_json("POST", "/session", json=dict(payload or {})) + if not isinstance(data, Mapping): + raise OpenCodeClientError("OpenCode create_session returned a non-object body") + return OpenCodeSession.from_payload(data) + + async def get_session(self, session_id: str) -> OpenCodeSession | None: + """ + Fetch one session (``GET /session/{id}``). + + :param session_id: OpenCode session id. + :returns: The session, or ``None`` when it does not exist. + """ + response = await self._client.request("GET", f"/session/{session_id}") + if response.status_code == 404: + return None + if response.status_code >= 400: + raise OpenCodeClientError( + f"OpenCode get_session failed: {response.status_code} {response.text[:500]}" + ) + data = response.json() + if not isinstance(data, Mapping): + return None + return OpenCodeSession.from_payload(data) + + async def list_messages(self, session_id: str) -> list[dict[str, Any]]: + """ + List a session's messages (``GET /session/{id}/message``). + + :param session_id: OpenCode session id. + :returns: A list of message objects (each typically + ``{"info": {...}, "parts": [...]}``). + """ + data = await self._request_json("GET", f"/session/{session_id}/message") + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + return [] + + async def get_message(self, session_id: str, message_id: str) -> dict[str, Any]: + """ + Fetch one message (``GET /session/{id}/message/{messageID}``). + + :param session_id: OpenCode session id. + :param message_id: OpenCode message id. + :returns: The message object, or ``{}`` when absent. + """ + data = await self._request_json("GET", f"/session/{session_id}/message/{message_id}") + return data if isinstance(data, dict) else {} + + async def prompt(self, session_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: + """ + Send a (blocking) prompt (``POST /session/{id}/message``). + + :param session_id: OpenCode session id. + :param payload: Prompt body, e.g. ``{"parts": [...]}``. + :returns: The server response object (often the assistant message). + """ + data = await self._request_json( + "POST", f"/session/{session_id}/message", json=dict(payload) + ) + return data if isinstance(data, dict) else {} + + async def prompt_async(self, session_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: + """ + Admit a prompt without blocking (``POST /session/{id}/prompt_async``). + + Preferred for native-server parity: the call returns once the + prompt is admitted; the assistant output streams over SSE. + + :param session_id: OpenCode session id. + :param payload: Prompt body, e.g. ``{"parts": [...]}``. + :returns: The server response object (may be empty). + """ + data = await self._request_json( + "POST", f"/session/{session_id}/prompt_async", json=dict(payload) + ) + return data if isinstance(data, dict) else {} + + async def abort(self, session_id: str) -> bool: + """ + Abort active work (``POST /session/{id}/abort``). + + :param session_id: OpenCode session id. + :returns: ``True`` when the server reports an abort happened. + """ + data = await self._request_json("POST", f"/session/{session_id}/abort") + return bool(data) + + async def fork( + self, session_id: str, payload: Mapping[str, Any] | None = None + ) -> OpenCodeSession: + """ + Fork a session (``POST /session/{id}/fork``). + + :param session_id: Source OpenCode session id. + :param payload: Optional fork body, e.g. ``{"messageID": "msg_..."}``. + :returns: The new forked session. + """ + data = await self._request_json( + "POST", f"/session/{session_id}/fork", json=dict(payload or {}) + ) + if not isinstance(data, Mapping): + raise OpenCodeClientError("OpenCode fork returned a non-object body") + return OpenCodeSession.from_payload(data) + + # --- permissions ----------------------------------------------------- + + async def list_permissions(self) -> list[dict[str, Any]]: + """ + List pending permission requests (``GET /permission``). + + :returns: A list of permission request objects. + """ + data = await self._request_json("GET", "/permission") + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + return [] + + async def reply_permission(self, request_id: str, reply: Mapping[str, Any]) -> bool: + """ + Reply to a permission request (``POST /permission/{id}/reply``). + + :param request_id: OpenCode permission request id. + :param reply: Reply body, e.g. ``{"reply": "once"}`` where reply is + one of ``once`` / ``always`` / ``reject``. + :returns: ``True`` on a 2xx response. + """ + response = await self._client.request( + "POST", f"/permission/{request_id}/reply", json=dict(reply) + ) + if response.status_code >= 400: + raise OpenCodeClientError( + f"OpenCode reply_permission failed: {response.status_code} {response.text[:500]}" + ) + return True + + # --- events ---------------------------------------------------------- + + async def events(self) -> AsyncIterator[OpenCodeEvent]: + """ + Stream server events over SSE (``GET /event``). + + Yields one :class:`OpenCodeEvent` per parsed SSE event. The + iterator ends when the server closes the stream; callers own + reconnect/backoff. + + :returns: Async iterator of decoded events. + """ + async with self._client.stream("GET", "/event", timeout=None) as response: + if response.status_code >= 400: + body = await response.aread() + raise OpenCodeClientError( + f"OpenCode /event failed: {response.status_code} {body[:200]!r}" + ) + async for event in _parse_sse(response.aiter_lines()): + yield event + + +async def _parse_sse(lines: AsyncIterator[str]) -> AsyncIterator[OpenCodeEvent]: + """ + Parse a stream of SSE lines into :class:`OpenCodeEvent` objects. + + Implements the subset of the SSE spec OpenCode uses: ``id:``, + ``event:`` and (possibly multi-line) ``data:`` fields, with a blank + line dispatching the accumulated event. ``data`` payloads are decoded + as JSON; non-JSON data blocks are skipped (logged at debug). + + :param lines: Async iterator of decoded SSE text lines. + :returns: Async iterator of parsed events. + """ + event_id: str | None = None + data_lines: list[str] = [] + async for raw_line in lines: + line = raw_line.rstrip("\n").rstrip("\r") + if line == "": + if data_lines: + payload = "\n".join(data_lines) + data_lines = [] + current_id = event_id + event_id = None + parsed = _decode_event(payload, current_id) + if parsed is not None: + yield parsed + else: + event_id = None + continue + if line.startswith(":"): + # SSE comment / heartbeat. + continue + field_name, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + if field_name == "data": + data_lines.append(value) + elif field_name == "id": + event_id = value + # ``event:`` and ``retry:`` are accepted but unused; OpenCode + # encodes the discriminator inside the JSON ``type`` field. + # Flush a trailing event with no terminating blank line. + if data_lines: + parsed = _decode_event("\n".join(data_lines), event_id) + if parsed is not None: + yield parsed + + +def _decode_event(payload: str, event_id: str | None) -> OpenCodeEvent | None: + """ + Decode one SSE ``data`` payload into an :class:`OpenCodeEvent`. + + :param payload: Raw JSON text from one or more ``data:`` lines. + :param event_id: Optional SSE ``id:`` value for the event. + :returns: Parsed event, or ``None`` when the payload is not a JSON + object. + """ + try: + decoded = json.loads(payload) + except json.JSONDecodeError: + _logger.debug("Skipping non-JSON OpenCode SSE data: %s", payload[:200]) + return None + if not isinstance(decoded, dict): + return None + return OpenCodeEvent.from_envelope(decoded, event_id=event_id) diff --git a/omnigent/opencode_native_forwarder.py b/omnigent/opencode_native_forwarder.py new file mode 100644 index 00000000..bb0e61c2 --- /dev/null +++ b/omnigent/opencode_native_forwarder.py @@ -0,0 +1,636 @@ +"""SSE consumer that mirrors OpenCode events into an Omnigent session. + +The runner owns this forwarder (parallel to the codex-native forwarder). +It connects to the per-session ``opencode serve`` SSE stream (``GET +/event``), filters to the session's OpenCode session id, and translates +OpenCode events into Omnigent session-stream events posted to +``/v1/sessions/{id}/events`` — the same envelope the codex forwarder uses +(``external_conversation_item`` / ``external_session_status`` / +``external_output_text_delta``). + +Design references: the SSE-event → Omnigent-event translation table in +``designs/opencode-harness-and-unified-interface.md`` §A.9. The forwarder +is tolerant of unknown events (logged, never fatal) and dedupes by stable +OpenCode message / part / tool-call ids so web and TUI driving the same +session never double-post. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections import OrderedDict +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import quote + +import httpx + +from omnigent.opencode_native_bridge import update_active_message_id, update_last_event_id +from omnigent.opencode_native_client import OpenCodeClient, OpenCodeEvent +from omnigent.opencode_native_permissions import ( + PolicyDecision, + decision_to_reply, + map_verdict_to_decision, + normalize_for_policy, + parse_permission_request, + reply_body, +) + +_logger = logging.getLogger(__name__) + +_AGENT_NAME = "opencode" +# Omnigent session-event types (must match the server's ingestion route; +# shared with the codex-native forwarder). +_EXTERNAL_ITEM = "external_conversation_item" +_EXTERNAL_STATUS = "external_session_status" + +_STATUS_RUNNING = "running" +_STATUS_IDLE = "idle" + +# Bound the dedupe set so a long-lived session can't grow it without limit. +_MAX_DEDUPE_KEYS = 8192 + +# Policy verdict resolver: receives a normalized policy input and returns a +# verdict mapping (or None when no policy is configured / reachable). +PolicyEvaluator = Callable[[Mapping[str, Any]], Awaitable[Mapping[str, Any] | None]] + + +@dataclass +class OpenCodeForwarderState: + """ + Mutable per-run forwarder state. + + :param seen: Bounded set of dedupe keys already posted. + :param turn_active: Whether a turn is currently streaming. + """ + + seen: OrderedDict[str, None] = field(default_factory=OrderedDict) + turn_active: bool = False + + def mark(self, key: str) -> bool: + """ + Record *key*; return ``True`` the first time it is seen. + + :param key: Stable dedupe key, e.g. ``"opencode:ses:msg:prt"``. + :returns: ``True`` when newly seen, ``False`` for a duplicate. + """ + if key in self.seen: + return False + self.seen[key] = None + while len(self.seen) > _MAX_DEDUPE_KEYS: + self.seen.popitem(last=False) + return True + + +class OpenCodeNativeForwarder: + """ + Translate one OpenCode session's SSE stream into Omnigent events. + + :param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``. + :param opencode_session_id: OpenCode session id to filter on. + :param opencode_client: Client connected to the ``opencode serve`` + server (for SSE + permission replies). + :param server_client: HTTP client for the Omnigent server (event posts). + :param bridge_dir: Native OpenCode bridge directory (status/active-id + persistence). ``None`` disables bridge writes (tests). + :param workspace: Session workspace, used for permission normalization. + :param policy_evaluator: Optional async policy resolver. Production + wires one that POSTs each request to + ``/v1/sessions/{id}/policies/evaluate`` (see + ``omnigent.runner.app._build_opencode_policy_evaluator``) — the SAME + server gate codex-native's policy hook uses, where an ``ask`` verdict + is parked as a human approval card and blocks until a human resolves + it. ``None`` uses *default_decision* for every request. + :param default_decision: Decision used when no evaluator is provided or + it returns ``None`` (evaluator unreachable / no verdict). Defaults to + ``reject`` so an unconfigured or unreachable policy FAILS CLOSED — a + headless OpenCode turn must NEVER silently auto-approve a sensitive + operation. Only an explicit policy ``allow`` reaches + ``once``/``always``. + """ + + def __init__( + self, + *, + session_id: str, + opencode_session_id: str, + opencode_client: OpenCodeClient, + server_client: httpx.AsyncClient, + bridge_dir: Path | None = None, + workspace: str | None = None, + policy_evaluator: PolicyEvaluator | None = None, + default_decision: PolicyDecision = "reject", + ) -> None: + self._session_id = session_id + self._opencode_session_id = opencode_session_id + self._opencode = opencode_client + self._server = server_client + self._bridge_dir = bridge_dir + self._workspace = workspace + self._policy_evaluator = policy_evaluator + self._default_decision = default_decision + self.state = OpenCodeForwarderState() + # messageID -> role ("user"/"assistant"), learned from + # ``message.updated``. Only assistant text parts become durable chat + # items (a user part is already echoed by the client). + self._msg_role: dict[str, str] = {} + # partID -> (assistant messageID, latest full-text snapshot) for + # in-flight assistant text parts, finalized (posted once) on + # ``step-finish`` / ``session.idle``. The messageID becomes the item's + # per-turn ``response_id``. + self._pending_text: dict[str, tuple[str | None, str]] = {} + + async def seed_dedupe_from_history(self) -> None: + """ + Pre-seed dedupe state from existing OpenCode messages. + + Prevents re-posting prior history on a resume/reconnect. Best + effort: a failure leaves the dedupe set empty (at worst a few + re-posts on resume). + """ + try: + messages = await self._opencode.list_messages(self._opencode_session_id) + except Exception: # noqa: BLE001 - seeding is best effort. + _logger.debug("OpenCode forwarder could not seed dedupe from history", exc_info=True) + return + for message in messages: + info = message.get("info") if isinstance(message, Mapping) else None + message_id = info.get("id") if isinstance(info, Mapping) else None + role = info.get("role") if isinstance(info, Mapping) else None + if isinstance(message_id, str) and isinstance(role, str): + self._msg_role[message_id] = role + parts = message.get("parts") if isinstance(message, Mapping) else None + if isinstance(parts, list): + for part in parts: + if not isinstance(part, Mapping): + continue + part_id = part.get("id") + if isinstance(part_id, str): + self.state.mark(self._key("part", part_id)) + # Pre-mark the keys the live handlers check so a resume + # never re-posts already-finalized text / tool parts. + if part.get("type") == "text" and isinstance(part_id, str): + # Pre-mark both the assistant-finalize and user-message + # keys so a resume re-posts neither. + self.state.mark(self._key("text-final", part_id)) + self.state.mark(self._key("user-text", part_id)) + if part.get("type") == "tool": + call_id = part.get("callID") + if isinstance(call_id, str): + self.state.mark(self._key("tool-call", call_id)) + self.state.mark(self._key("tool-out", call_id)) + if isinstance(message_id, str): + self.state.mark(self._key("message", message_id)) + + async def run(self, *, max_reconnects: int | None = None) -> None: + """ + Run the SSE consume loop with reconnect/backoff. + + :param max_reconnects: Reconnect cap (``None`` = unbounded); used + by tests to bound the loop. + """ + await self.seed_dedupe_from_history() + attempt = 0 + backoff = 0.5 + while True: + try: + await self._consume_once() + # Clean stream end (server closed): reconnect. + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - reconnect on any transient SSE failure. + _logger.warning( + "OpenCode forwarder SSE error for session=%s; reconnecting", + self._session_id, + exc_info=True, + ) + attempt += 1 + if max_reconnects is not None and attempt > max_reconnects: + return + await asyncio.sleep(min(backoff, 5.0)) + backoff = min(backoff * 2, 5.0) + + async def _consume_once(self) -> None: + """Consume the SSE stream once, dispatching each event.""" + async for event in self._opencode.events(): + await self.handle_event(event) + + async def handle_event(self, event: OpenCodeEvent) -> None: + """ + Translate one OpenCode event into Omnigent session events. + + :param event: A decoded OpenCode SSE event. + """ + if not self._event_targets_session(event): + return + if event.id and self._bridge_dir is not None: + update_last_event_id(self._bridge_dir, event.id) + handler = _HANDLERS.get(event.type) + if handler is None: + _logger.debug( + "OpenCode forwarder ignoring event type=%s for session=%s", + event.type, + self._session_id, + ) + return + await handler(self, event) + + # --- filtering ------------------------------------------------------- + + def _event_targets_session(self, event: OpenCodeEvent) -> bool: + """ + Return whether *event* belongs to this forwarder's session. + + Events without a session id (e.g. ``server.connected``) pass + through so readiness/global signals are not dropped. + + :param event: A decoded OpenCode event. + :returns: ``True`` when the event should be handled. + """ + props = event.properties + session_id = props.get("sessionID") or props.get("session_id") + info = props.get("info") + if session_id is None and isinstance(info, Mapping): + session_id = info.get("id") + if session_id is None: + return True + return bool(session_id == self._opencode_session_id) + + # --- dedupe / keys --------------------------------------------------- + + def _key(self, *parts: str) -> str: + """ + Build a session-scoped dedupe key. + + :param parts: Key segments, e.g. ``("text", "prt_1")``. + :returns: ``"opencode:::..."``. + """ + return "opencode:" + ":".join((self._opencode_session_id, *parts)) + + # --- posting helpers ------------------------------------------------- + + async def _post_event(self, event_type: str, data: dict[str, Any]) -> httpx.Response | None: + """ + POST one Omnigent session event with a single retry. + + :param event_type: Omnigent event type, e.g. + ``"external_session_status"``. + :param data: Event data payload. + :returns: The HTTP response, or ``None`` on transport failure. + """ + url = f"/v1/sessions/{quote(self._session_id, safe='')}/events" + payload = {"type": event_type, "data": data} + try: + return await self._server.post(url, json=payload) + except httpx.HTTPError: + _logger.warning( + "OpenCode forwarder failed to post %s for session=%s", + event_type, + self._session_id, + exc_info=True, + ) + return None + + async def _post_status(self, status: str) -> None: + """Publish a coarse session status edge.""" + await self._post_event(_EXTERNAL_STATUS, {"status": status}) + + def _response_id(self, message_id: str | None) -> str: + """Map an opencode assistant messageID to a per-turn ``response_id``. + + Items are grouped into a chat "response" by ``response_id``; a constant + value clusters every turn's assistant items into one block (breaking + ordering against the user messages). opencode's per-assistant-message id + is the natural per-turn key — fall back to the session id only when the + message id is unknown. + """ + return message_id or self._opencode_session_id + + async def _post_assistant_text(self, text: str, *, message_id: str | None) -> None: + """Persist a finalized assistant message under its per-turn response.""" + await self._post_event( + _EXTERNAL_ITEM, + { + "item_type": "message", + "item_data": { + "role": "assistant", + "agent": _AGENT_NAME, + "content": [{"type": "output_text", "text": text}], + }, + "response_id": self._response_id(message_id), + }, + ) + + async def _post_user_text(self, text: str, *, message_id: str | None) -> None: + """Persist a user message mirrored from the native transcript.""" + await self._post_event( + _EXTERNAL_ITEM, + { + "item_type": "message", + "item_data": { + "role": "user", + "content": [{"type": "input_text", "text": text}], + }, + "response_id": self._response_id(message_id), + }, + ) + + async def _post_tool_call( + self, call_id: str, tool: str, arguments: dict[str, Any], *, message_id: str | None + ) -> None: + """Mirror a tool invocation as a function_call item.""" + await self._post_event( + _EXTERNAL_ITEM, + { + "item_type": "function_call", + "item_data": { + "agent": _AGENT_NAME, + "name": tool, + "arguments": json.dumps(arguments, ensure_ascii=True), + "call_id": call_id, + }, + "response_id": self._response_id(message_id), + }, + ) + + async def _post_tool_output( + self, call_id: str, output: str, *, message_id: str | None + ) -> None: + """Mirror a tool result as a function_call_output item.""" + await self._post_event( + _EXTERNAL_ITEM, + { + "item_type": "function_call_output", + "item_data": {"call_id": call_id, "output": output}, + "response_id": self._response_id(message_id), + }, + ) + + async def _begin_turn_if_needed(self) -> None: + """Post a single ``running`` status at the start of a turn.""" + if not self.state.turn_active: + self.state.turn_active = True + await self._post_status(_STATUS_RUNNING) + + async def _end_turn(self) -> None: + """Post ``idle`` and clear active state at turn end.""" + self.state.turn_active = False + if self._bridge_dir is not None: + update_active_message_id(self._bridge_dir, None, status="idle") + await self._post_status(_STATUS_IDLE) + + # --- per-event handlers ---------------------------------------------- + + async def _on_message_updated(self, event: OpenCodeEvent) -> None: + """Handle ``message.updated`` — learn role; begin a turn for assistant. + + opencode attaches text/tool parts to a message id; the role lives on + the message, not the part, so we cache it here to route parts. + """ + info = event.properties.get("info") + if not isinstance(info, Mapping): + return + message_id = info.get("id") + role = info.get("role") + if not isinstance(message_id, str) or not isinstance(role, str): + return + self._msg_role[message_id] = role + if role == "assistant": + if self._bridge_dir is not None: + update_active_message_id(self._bridge_dir, message_id, status="busy") + await self._begin_turn_if_needed() + + async def _on_part_updated(self, event: OpenCodeEvent) -> None: + """Handle ``message.part.updated`` — text / tool / step-boundary parts.""" + part = event.properties.get("part") + if not isinstance(part, Mapping): + return + part_type = part.get("type") + if part_type == "text": + # A native-server forwarder is the SOLE source of the conversation + # transcript (omnigent persists no separate user item for these + # harnesses — mirrors codex-native). So the USER message must be + # posted here, BEFORE its assistant reply, or the chat shows the + # assistant turns with no/late user messages. Assistant text is + # accumulated and finalized on step/turn end. + if self._msg_role.get(str(part.get("messageID"))) == "user": + await self._post_user_text_part(part) + else: + self._accumulate_text_part(part) + elif part_type == "tool": + await self._handle_tool_part(part) + elif part_type == "step-start": + await self._begin_turn_if_needed() + elif part_type == "step-finish": + # A step's assistant text is complete once the step closes; flush + # it so text and tool items land in the chat in step order. + await self._flush_pending_text() + + def _accumulate_text_part(self, part: Mapping[str, Any]) -> None: + """Record the latest full-text snapshot for an assistant text part. + + ``message.part.updated`` carries the cumulative text each time, so we + keep the latest snapshot and finalize it once on step/turn end. + """ + part_id = part.get("id") + text = part.get("text") + message_id = part.get("messageID") + if not isinstance(part_id, str) or not isinstance(text, str): + return + # User-message text is echoed by the client; only assistant text + # becomes a durable chat item. + if self._msg_role.get(str(message_id)) != "assistant": + return + # Keep the owning messageID so the finalized item lands under the right + # per-turn response group (ordering vs the user messages). + self._pending_text[part_id] = ( + message_id if isinstance(message_id, str) else None, + text, + ) + + async def _post_user_text_part(self, part: Mapping[str, Any]) -> None: + """Post a user message immediately so it precedes its assistant reply. + + Unlike assistant text (accumulated + flushed on step end), a user part + is complete on arrival and must land at its own earlier position, so we + post it eagerly, deduped by part id. + """ + part_id = part.get("id") + text = part.get("text") + message_id = part.get("messageID") + if not isinstance(part_id, str) or not isinstance(text, str) or not text: + return + if not self.state.mark(self._key("user-text", part_id)): + return + await self._post_user_text( + text, message_id=message_id if isinstance(message_id, str) else None + ) + + async def _flush_pending_text(self) -> None: + """Finalize accumulated assistant text parts as durable chat items.""" + for part_id, (message_id, text) in list(self._pending_text.items()): + self._pending_text.pop(part_id, None) + if not text: + continue + if not self.state.mark(self._key("text-final", part_id)): + continue + await self._post_assistant_text(text, message_id=message_id) + + async def _handle_tool_part(self, part: Mapping[str, Any]) -> None: + """Mirror an opencode tool part (call + result) as chat items. + + opencode reports a tool as a single part whose ``state`` advances + ``pending`` → ``running`` → ``completed`` / ``error`` with ``input`` + then ``output``; we post the call once its input is populated and the + output once it completes (deduped by ``callID``). + """ + call_id = part.get("callID") + tool = part.get("tool") + state = part.get("state") + if not isinstance(call_id, str) or not isinstance(tool, str): + return + if not isinstance(state, Mapping): + return + message_id = part.get("messageID") + response_message_id = message_id if isinstance(message_id, str) else None + raw_input = state.get("input") + arguments = raw_input if isinstance(raw_input, dict) else {} + if arguments and self.state.mark(self._key("tool-call", call_id)): + await self._begin_turn_if_needed() + await self._post_tool_call(call_id, tool, arguments, message_id=response_message_id) + status = state.get("status") + if status == "completed" and self.state.mark(self._key("tool-out", call_id)): + await self._post_tool_output( + call_id, _tool_output_text(state), message_id=response_message_id + ) + elif status == "error" and self.state.mark(self._key("tool-out", call_id)): + error = state.get("error") + await self._post_tool_output( + call_id, f"[error] {error}" if error else "[error]", message_id=response_message_id + ) + + async def _on_session_status(self, event: OpenCodeEvent) -> None: + """Handle ``session.status`` — surface the running edge.""" + status = event.properties.get("status") + status_type = status.get("type") if isinstance(status, Mapping) else status + if status_type == "busy": + await self._begin_turn_if_needed() + + async def _on_session_idle(self, event: OpenCodeEvent) -> None: + """Handle ``session.idle`` — finalize text and end the turn.""" + del event + await self._flush_pending_text() + await self._end_turn() + + async def _on_session_error(self, event: OpenCodeEvent) -> None: + """Handle ``session.error`` — log, finalize, end turn.""" + _logger.warning( + "OpenCode session error for session=%s: %s", + self._session_id, + event.properties.get("error"), + ) + await self._flush_pending_text() + await self._end_turn() + + async def _on_permission_asked(self, event: OpenCodeEvent) -> None: + """Handle ``permission.v2.asked`` — evaluate policy and reply.""" + request = parse_permission_request(event.properties) + if request is None: + return + if not self.state.mark(self._key("perm", request.request_id)): + return + decision = await self._resolve_permission(request_dict=request) + reply = decision_to_reply(decision) + if reply is None: + # Fail closed. ``decision_to_reply`` returns ``None`` only for + # ``ask``. The genuine human approval for an ``ask`` happens + # UPSTREAM inside the policy evaluator (the server parks an + # approval card on ``/policies/evaluate`` and returns a hard + # allow/deny). So an ``ask`` still reaching here means no human + # resolution was obtained — which must DENY, never auto-approve. + reply = "reject" + try: + await self._opencode.reply_permission( + request.request_id, reply_body(reply, message="omnigent-policy") + ) + except Exception: # noqa: BLE001 - reply is best effort; log and move on. + _logger.warning( + "OpenCode permission reply failed for request=%s", + request.request_id, + exc_info=True, + ) + + async def _resolve_permission(self, *, request_dict: Any) -> PolicyDecision: + """ + Resolve a permission request to a normalized decision. + + :param request_dict: The parsed permission request. + :returns: The normalized policy decision. + """ + if self._policy_evaluator is None: + # No policy gate wired → fail closed (default ``reject``). A + # forwarder with no evaluator must never auto-approve. + return self._default_decision + normalized = normalize_for_policy( + request_dict, + omnigent_session_id=self._session_id, + workspace=self._workspace, + ) + try: + verdict = await self._policy_evaluator(normalized) + except Exception: # noqa: BLE001 - policy errors fail closed. + _logger.warning("OpenCode policy evaluation failed", exc_info=True) + return "ask" + if verdict is None: + return self._default_decision + return map_verdict_to_decision(verdict) + + +def _tool_output_text(state: Mapping[str, Any]) -> str: + """ + Extract a string tool output from a completed tool part's ``state``. + + :param state: The opencode tool part ``state`` (``output`` / + ``metadata.output``). + :returns: A string suitable for ``function_call_output``. + """ + output = state.get("output") + if isinstance(output, str) and output: + return output + metadata = state.get("metadata") + if isinstance(metadata, Mapping): + meta_out = metadata.get("output") + if isinstance(meta_out, str) and meta_out: + return meta_out + if output is not None and not isinstance(output, str): + return json.dumps(output, ensure_ascii=True) + return "" + + +# Event type → bound handler-name lookup. Built once; ``handle_event`` +# resolves the method on the instance. Keys are OpenCode event ``type`` +# discriminators (see openapi.json Event* schemas). +_HANDLERS: dict[str, Callable[[OpenCodeNativeForwarder, OpenCodeEvent], Awaitable[None]]] = { + # opencode 1.17.x is part-based: text/tool live on message PARTS, lifecycle + # on the message + session. (Verified against a real ``opencode serve``.) + "message.updated": OpenCodeNativeForwarder._on_message_updated, + "message.part.updated": OpenCodeNativeForwarder._on_part_updated, + # NB: ``message.part.delta`` (live token stream) is intentionally NOT + # forwarded. The web chat view reconciles live ``text_delta`` previews with + # the committed item via a finalize/retire protocol; emitting deltas without + # that handshake left an unreconciled streaming preview alongside the + # committed message (duplicated/garbled chat). We post only the durable + # assistant item (the codex-native finalized-message path) so the chat is + # correct; live token-streaming is a separate follow-up. + "session.status": OpenCodeNativeForwarder._on_session_status, + "session.idle": OpenCodeNativeForwarder._on_session_idle, + "session.error": OpenCodeNativeForwarder._on_session_error, + # Permission ask: 1.17.x emits ``permission.asked``; keep the ``v2`` spelling + # too so a point-release rename still routes through the policy gate. + "permission.asked": OpenCodeNativeForwarder._on_permission_asked, + "permission.v2.asked": OpenCodeNativeForwarder._on_permission_asked, +} diff --git a/omnigent/opencode_native_permissions.py b/omnigent/opencode_native_permissions.py new file mode 100644 index 00000000..15e72cfe --- /dev/null +++ b/omnigent/opencode_native_permissions.py @@ -0,0 +1,204 @@ +"""OpenCode permission normalization and policy/approval mapping. + +OpenCode requests approval for sensitive actions via permission events +(``permission.v2.asked`` over SSE / ``GET /permission``) and accepts a +reply of ``once`` / ``always`` / ``reject`` (``POST +/permission/{requestID}/reply``). This module is the seam between +OpenCode's permission surface and Omnigent's policy/approval model: + +1. Normalize a raw permission request into a flat policy-evaluation input. +2. Map an Omnigent policy verdict (allow / allow-always / deny / ask) onto + an OpenCode reply. +3. Fail closed: an unmapped verdict yields no auto-reply, so the caller + must obtain a human decision before answering. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Literal + +OPENCODE_NATIVE_HARNESS = "opencode-native" + +# OpenCode's accepted reply tokens. +OpenCodeReply = Literal["once", "always", "reject"] + +# Omnigent-side normalized decisions used by the forwarder. +PolicyDecision = Literal["allow_once", "allow_always", "reject", "ask"] + + +@dataclass(frozen=True) +class OpenCodePermissionRequest: + """ + A normalized OpenCode permission request. + + :param request_id: OpenCode permission request id, e.g. ``"per_..."``. + :param session_id: OpenCode session id the request belongs to. + :param action: The action/tool name needing approval, e.g. + ``"bash"`` or ``"edit"``. + :param resources: Resource descriptors (command/path/url), as given. + :param metadata: Extra metadata supplied by OpenCode. + :param source: Where the request originated, when reported. + :param raw: The full raw payload for forward-compatibility. + """ + + request_id: str + session_id: str | None + action: str | None + resources: list[Any] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + source: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +def parse_permission_request(payload: Mapping[str, Any]) -> OpenCodePermissionRequest | None: + """ + Parse a raw permission payload into :class:`OpenCodePermissionRequest`. + + Accepts both the ``permission.v2.asked`` event ``properties`` object + (keys ``id`` / ``sessionID`` / ``action`` / ``resources`` / ``metadata`` + / ``source``) and entries from ``GET /permission`` (which may use + ``requestID`` / ``sessionID``). + + :param payload: Raw permission object. + :returns: Parsed request, or ``None`` when no request id is present. + """ + request_id = payload.get("id") or payload.get("requestID") or payload.get("request_id") + if not isinstance(request_id, str) or not request_id: + return None + session_id = payload.get("sessionID") or payload.get("session_id") + action = payload.get("action") or payload.get("type") + resources = payload.get("resources") + metadata = payload.get("metadata") + source = payload.get("source") + return OpenCodePermissionRequest( + request_id=request_id, + session_id=session_id if isinstance(session_id, str) else None, + action=action if isinstance(action, str) else None, + resources=list(resources) if isinstance(resources, list) else [], + metadata=dict(metadata) if isinstance(metadata, Mapping) else {}, + source=source if isinstance(source, str) else None, + raw=dict(payload), + ) + + +def normalize_for_policy( + request: OpenCodePermissionRequest, + *, + omnigent_session_id: str, + workspace: str | None, +) -> dict[str, Any]: + """ + Build an Omnigent policy-evaluation input from a permission request. + + The shape mirrors what the codex-native policy hook posts to + ``/v1/sessions/{id}/policies/evaluate`` — an action name plus the + concrete command / path / url being acted on, so configured policies + can reason about the operation. + + :param request: The normalized OpenCode permission request. + :param omnigent_session_id: Owning Omnigent conversation id. + :param workspace: Session working directory, when known. + :returns: A flat dict suitable for policy evaluation. + """ + command, path, url = _extract_resource_fields(request) + return { + "harness": OPENCODE_NATIVE_HARNESS, + "action": request.action, + "command": command, + "path": path, + "url": url, + "working_directory": workspace, + "opencode_session_id": request.session_id, + "omnigent_session_id": omnigent_session_id, + "request_id": request.request_id, + "metadata": request.metadata, + } + + +def _extract_resource_fields( + request: OpenCodePermissionRequest, +) -> tuple[str | None, str | None, str | None]: + """ + Pull command / path / url out of a permission request's resources. + + :param request: The normalized permission request. + :returns: ``(command, path, url)``; each ``None`` when not present. + """ + command: str | None = None + path: str | None = None + url: str | None = None + candidates: list[Mapping[str, Any]] = [] + if isinstance(request.metadata, Mapping): + candidates.append(request.metadata) + for resource in request.resources: + if isinstance(resource, Mapping): + candidates.append(resource) + for source in candidates: + if command is None: + value = source.get("command") + command = value if isinstance(value, str) and value else command + if path is None: + value = source.get("path") or source.get("filePath") or source.get("file") + path = value if isinstance(value, str) and value else path + if url is None: + value = source.get("url") + url = value if isinstance(value, str) and value else url + return command, path, url + + +def map_verdict_to_decision(verdict: Mapping[str, Any] | None) -> PolicyDecision: + """ + Map an Omnigent policy verdict onto a normalized decision. + + Recognizes both ``{"decision": "..."}`` and ``{"action": "..."}`` + verdict shapes. Anything unrecognized maps to ``"ask"`` (fail closed: + the caller must obtain a human decision before replying). + + :param verdict: The policy verdict object, or ``None``. + :returns: One of ``allow_once`` / ``allow_always`` / ``reject`` / ``ask``. + """ + if not isinstance(verdict, Mapping): + return "ask" + raw = verdict.get("decision") or verdict.get("action") or verdict.get("verdict") + token = str(raw).strip().lower() if raw is not None else "" + if token in {"allow_always", "always", "allow-always"}: + return "allow_always" + if token in {"allow", "allow_once", "approve", "allowed", "accept"}: + return "allow_once" + if token in {"deny", "reject", "block", "blocked", "denied"}: + return "reject" + return "ask" + + +def decision_to_reply(decision: PolicyDecision) -> OpenCodeReply | None: + """ + Map a normalized decision onto an OpenCode reply token. + + :param decision: One of ``allow_once`` / ``allow_always`` / ``reject`` + / ``ask``. + :returns: ``"once"`` / ``"always"`` / ``"reject"``, or ``None`` for + ``ask`` (no automatic reply — needs a human). + """ + if decision == "allow_once": + return "once" + if decision == "allow_always": + return "always" + if decision == "reject": + return "reject" + return None + + +def reply_body(reply: OpenCodeReply, *, message: str | None = None) -> dict[str, Any]: + """ + Build the JSON body for ``POST /permission/{requestID}/reply``. + + :param reply: ``once`` / ``always`` / ``reject``. + :param message: Optional human-readable note attached to the reply. + :returns: The reply request body. + """ + body: dict[str, Any] = {"reply": reply} + if message is not None: + body["message"] = message + return body diff --git a/omnigent/opencode_native_provider.py b/omnigent/opencode_native_provider.py new file mode 100644 index 00000000..31f22c7f --- /dev/null +++ b/omnigent/opencode_native_provider.py @@ -0,0 +1,194 @@ +"""Synthesize OpenCode provider config for the native-server harness. + +Unlike codex/claude/pi — which consume ``HARNESS_*_GATEWAY_*`` env vars that +their CLIs translate into provider config — OpenCode reads its provider/auth +from its own config file under the per-session ``XDG_CONFIG_HOME``. So routing +opencode-native through the Databricks AI gateway (or any OpenAI-compatible +endpoint) means writing an ``opencode.json`` into the runner-owned +``opencode serve``'s config dir at spawn, declaring a custom +``@ai-sdk/openai-compatible`` provider pointed at ``{host}/serving-endpoints``. + +The model is then referenced as ``/`` per prompt. + +Security: the file carries a bearer token, so it is written ``0600`` into the +per-session XDG dir (never the user's global ``~/.config/opencode``). The token +is resolved at spawn; a resumed session re-spawns the server and re-resolves, so +short-lived gateway tokens refresh on resume (documented limitation: a token +that expires mid-session is not refreshed in place). +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +_logger = logging.getLogger(__name__) + +# Provider id used in the synthesized opencode.json for the Databricks gateway. +# The per-prompt model is pinned as ``{DATABRICKS_GATEWAY_PROVIDER_ID}/``. +DATABRICKS_GATEWAY_PROVIDER_ID = "databricks-gateway" +DATABRICKS_GATEWAY_PROVIDER_NAME = "Databricks AI Gateway" +# Endpoint that exposes the workspace's OpenAI-compatible chat completions. +_SERVING_ENDPOINTS_PATH = "serving-endpoints" +# Fallback chat model when neither the spec nor config names one. +DEFAULT_DATABRICKS_GATEWAY_MODEL = "databricks-claude-sonnet-4-6" + + +@dataclass(frozen=True) +class OpenCodeGatewayResolution: + """A resolved OpenAI-compatible gateway for the opencode-native harness. + + :param base_url: OpenAI-compatible base URL, e.g. + ``"https://ws.cloud.databricks.com/serving-endpoints"``. + :param api_key: Bearer token / API key for the gateway. + :param model_id: The endpoint/model id, e.g. ``"databricks-claude-sonnet-4-6"``. + :param provider_id: opencode provider id, e.g. ``"databricks-gateway"``. + :param provider_name: Human label for the opencode provider block. + """ + + base_url: str + api_key: str + model_id: str + provider_id: str = DATABRICKS_GATEWAY_PROVIDER_ID + provider_name: str = DATABRICKS_GATEWAY_PROVIDER_NAME + + @property + def qualified_model(self) -> str: + """:returns: The per-prompt ``provider/model`` id opencode expects.""" + return f"{self.provider_id}/{self.model_id}" + + +def build_opencode_model_default_config(model: str) -> dict[str, object]: + """ + Build a minimal ``opencode.json`` that only pins the default model. + + Used when the user's own provider auth (``opencode auth login`` / + provider env keys) already supplies credentials, but a default model has + been chosen — via ``omni opencode --model`` or the ``omni setup`` OpenCode + default — so the per-session TUI (and the first turn) launch on that model + instead of OpenCode's built-in default (``opencode/big-pickle``). No + provider block: OpenCode resolves the provider from the model id's prefix + against its own ``auth.json``. + + :param model: A ``provider/model`` id, e.g. ``"anthropic/claude-sonnet-4-5"``. + :returns: A config dict ready to serialize to ``opencode.json``. + """ + return {"$schema": "https://opencode.ai/config.json", "model": model} + + +def build_opencode_provider_config(resolution: OpenCodeGatewayResolution) -> dict[str, object]: + """ + Build the ``opencode.json`` declaring a custom OpenAI-compatible provider. + + :param resolution: The resolved gateway (base URL + key + model). + :returns: A config dict ready to serialize to ``opencode.json``. + """ + return { + "$schema": "https://opencode.ai/config.json", + "provider": { + resolution.provider_id: { + "npm": "@ai-sdk/openai-compatible", + "name": resolution.provider_name, + "options": { + "baseURL": resolution.base_url, + "apiKey": resolution.api_key, + }, + "models": {resolution.model_id: {"name": resolution.model_id}}, + } + }, + } + + +def write_opencode_provider_config(xdg_config_home: Path, config: Mapping[str, object]) -> Path: + """ + Atomically write ``/opencode/opencode.json`` (``0600``). + + :param xdg_config_home: The per-session ``XDG_CONFIG_HOME`` the server uses. + :param config: The provider config dict (see + :func:`build_opencode_provider_config`). + :returns: The path written. + """ + cfg_dir = xdg_config_home / "opencode" + cfg_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + path = cfg_dir / "opencode.json" + payload = json.dumps(config, indent=2, sort_keys=True) + "\n" + fd, tmp_name = tempfile.mkstemp(prefix="opencode.json.", dir=str(cfg_dir)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + os.chmod(tmp_name, 0o600) + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + return path + + +def resolve_databricks_gateway( + profile: str | None, + *, + model_id: str | None = None, +) -> OpenCodeGatewayResolution | None: + """ + Resolve a Databricks AI gateway for opencode from a ``~/.databrickscfg`` profile. + + Uses ``databricks-sdk`` (the ``databricks`` extra) to obtain the workspace + host + a bearer token for *profile*, then targets the workspace's + OpenAI-compatible ``/serving-endpoints``. Best-effort: returns ``None`` when + the SDK is absent, the profile is unknown, or auth fails — the caller then + leaves opencode on its ambient provider config. + + :param profile: A ``~/.databrickscfg`` profile name, e.g. ``"oss"``; + ``None`` short-circuits. + :param model_id: Endpoint/model id to pin; defaults to + :data:`DEFAULT_DATABRICKS_GATEWAY_MODEL` (a ``databricks-*`` chat + endpoint the gateway routes). + :returns: A resolution, or ``None`` when the gateway can't be resolved. + """ + if not profile: + return None + try: + from databricks.sdk.core import Config + + config = Config(profile=profile) + host = (config.host or "").rstrip("/") + if not host: + return None + headers = config.authenticate() or {} + authz = headers.get("Authorization", "") + token = authz.split(" ", 1)[1] if authz.lower().startswith("bearer ") else "" + if not token: + return None + except Exception as exc: # noqa: BLE001 - SDK absent / auth failure / bad profile. + _logger.info("opencode Databricks gateway resolve failed for %r: %r", profile, exc) + return None + + resolved_model = _gateway_endpoint_for_model(model_id) or DEFAULT_DATABRICKS_GATEWAY_MODEL + return OpenCodeGatewayResolution( + base_url=f"{host}/{_SERVING_ENDPOINTS_PATH}", + api_key=token, + model_id=resolved_model, + ) + + +def _gateway_endpoint_for_model(model_id: str | None) -> str | None: + """ + Normalize a spec model id to a Databricks serving-endpoint name. + + Accepts ``"databricks-claude-..."`` and ``"databricks/claude-..."`` spellings + and strips a leading ``databricks/`` provider prefix; anything that does not + look like a ``databricks-*`` endpoint is ignored (the gateway only routes + its own endpoint names), so the default applies. + + :param model_id: The spec/override model id, or ``None``. + :returns: A bare endpoint name, or ``None``. + """ + if not model_id: + return None + candidate = model_id.split("/", 1)[1] if model_id.startswith("databricks/") else model_id + return candidate if candidate.startswith("databricks-") else None diff --git a/omnigent/opencode_native_state.py b/omnigent/opencode_native_state.py new file mode 100644 index 00000000..8f7805fe --- /dev/null +++ b/omnigent/opencode_native_state.py @@ -0,0 +1,144 @@ +"""Persistent client-side state for ``omnigent opencode`` sessions. + +The native OpenCode wrapper records the cwd used to create a session so a +later ``omnigent opencode --resume `` can launch OpenCode from +the same workspace. This state is intentionally client-side: local +filesystem paths belong to the user's machine and should not be stored on +the shared Omnigent server. Mirrors :mod:`omnigent.codex_native_state`. + +Layout (per conversation): + + ~/.omnigent/opencode-native//launch.json +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +from dataclasses import dataclass +from pathlib import Path + +_STATE_ROOT_ENV_VAR = "OMNIGENT_OPENCODE_NATIVE_STATE_DIR" +_logger = logging.getLogger(__name__) +_LAUNCH_FILE = "launch.json" +_ID_HASH_CHARS = 32 + + +@dataclass(frozen=True) +class OpenCodeNativeLaunchState: + """ + Persisted state about how an opencode-native session was launched. + + :param working_directory: Absolute filesystem path the wrapper was + invoked from when the session was created, e.g. ``"/home/me/repo"``. + """ + + working_directory: str + + +def _opencode_native_state_root() -> Path: + """ + Return the root directory for persistent opencode-native state. + + Honors :data:`_STATE_ROOT_ENV_VAR` for tests and advanced local setups. + Production defaults to ``~/.omnigent/opencode-native``. + + :returns: Absolute path to the state root. + """ + override = os.environ.get(_STATE_ROOT_ENV_VAR) + if override: + return Path(override) + return Path.home() / ".omnigent" / "opencode-native" + + +def _state_dir_for_conversation_id(conversation_id: str) -> Path: + """ + Return the per-conversation persistent state directory. + + Hashing the conversation id prevents path traversal if a server ever + returned an attacker-controlled id such as ``"../etc"``. + + :param conversation_id: Omnigent conversation id, e.g. ``"conv_abc123"``. + :returns: Absolute directory path; not guaranteed to exist. + """ + digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS] + return _opencode_native_state_root() / digest + + +def write_launch_state(conversation_id: str, working_directory: str) -> None: + """ + Persist a session's launch state at creation time. + + Same-value writes are idempotent. Different-value writes are refused + and logged because changing the recorded cwd for an existing session + would make future resume checks incorrect. + + :param conversation_id: Omnigent conversation id, e.g. ``"conv_abc123"``. + :param working_directory: Absolute launch cwd, e.g. ``"/home/me/repo"``. + :returns: None. + :raises ValueError: If *working_directory* is empty or relative. + """ + if not working_directory: + raise ValueError("working_directory must be a non-empty absolute path") + if not Path(working_directory).is_absolute(): + raise ValueError("working_directory must be a non-empty absolute path") + state_dir = _state_dir_for_conversation_id(conversation_id) + existing = read_launch_state(conversation_id) + if existing is not None and existing.working_directory != working_directory: + _logger.warning( + "opencode-native launch state mismatch for %s: existing=%r new=%r; " + "keeping existing value", + conversation_id, + existing.working_directory, + working_directory, + ) + return + state_dir.mkdir(parents=True, exist_ok=True) + target = state_dir / _LAUNCH_FILE + payload = { + "conversation_id": conversation_id, + "working_directory": working_directory, + } + tmp = target.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8") + os.replace(tmp, target) + + +def read_launch_state(conversation_id: str) -> OpenCodeNativeLaunchState | None: + """ + Load a session's launch state, or ``None`` if not recorded. + + Missing, unreadable, or malformed state is treated as absent so legacy + and cross-machine resumes continue to behave as before. + + :param conversation_id: Omnigent conversation id, e.g. ``"conv_abc123"``. + :returns: Parsed state, or ``None`` if missing / malformed. + """ + target = _state_dir_for_conversation_id(conversation_id) / _LAUNCH_FILE + try: + raw = target.read_text(encoding="utf-8") + except FileNotFoundError: + return None + except OSError: + _logger.warning( + "opencode-native launch state read failed for %s", + conversation_id, + exc_info=True, + ) + return None + try: + payload = json.loads(raw) + except json.JSONDecodeError: + _logger.warning( + "opencode-native launch state JSON is malformed for %s; ignoring", + conversation_id, + ) + return None + if not isinstance(payload, dict): + return None + working_directory = payload.get("working_directory") + if not isinstance(working_directory, str) or not working_directory: + return None + return OpenCodeNativeLaunchState(working_directory=working_directory) diff --git a/omnigent/runner/app.py b/omnigent/runner/app.py index d6260498..a3171004 100644 --- a/omnigent/runner/app.py +++ b/omnigent/runner/app.py @@ -20,7 +20,7 @@ import tempfile import time import urllib.parse import uuid -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from pathlib import Path from typing import TYPE_CHECKING, Any @@ -59,6 +59,7 @@ from omnigent.runner.resource_registry import ( CURSOR_NATIVE_TERMINAL_ROLE, GOOSE_NATIVE_TERMINAL_ROLE, OMNIGENT_REPL_TERMINAL_ROLE, + OPENCODE_NATIVE_TERMINAL_ROLE, PI_NATIVE_TERMINAL_ROLE, SessionResourceRegistry, TerminalExitEvent, @@ -351,6 +352,11 @@ _COST_POPUP_REPOP_TASKS: set[asyncio.Task[Any]] = set() # runners, kept referenced so they aren't garbage-collected mid-run. _AUTO_CODEX_APP_SERVERS: dict[str, Any] = {} +# Background OpenCode ``opencode serve`` instances for host-spawned +# opencode-native runners, kept referenced so they aren't garbage-collected +# mid-run (mirrors ``_AUTO_CODEX_APP_SERVERS``). +_AUTO_OPENCODE_SERVERS: dict[str, Any] = {} + # Bound repeated terminal GET miss logs from tight client poll loops. _TERMINAL_LOOKUP_MISS_LOG_INTERVAL_S = 10.0 _terminal_lookup_miss_log_state: dict[tuple[str, str, str], float] = {} @@ -721,6 +727,475 @@ async def _codex_native_launch_config( ) +@dataclasses.dataclass(frozen=True) +class _OpenCodeNativeLaunchConfig: + """ + Persisted launch config for runner-owned OpenCode terminals. + + :param workspace: Workspace cwd for ``opencode serve`` and the TUI. + :param policy_server_url: Omnigent server URL for the forwarder. + :param terminal_launch_args: User pass-through OpenCode CLI args. + :param model_override: Persisted model override, or ``None``. + :param external_session_id: Existing OpenCode session id to resume. + """ + + workspace: Path + policy_server_url: str + terminal_launch_args: list[str] | None + model_override: str | None + external_session_id: str | None + + +async def _opencode_native_launch_config( + *, + session_id: str, + server_client: httpx.AsyncClient | None, +) -> _OpenCodeNativeLaunchConfig: + """ + Fetch and validate persisted OpenCode launch config for a session. + + :param session_id: Session/conversation id, e.g. ``"conv_abc123"``. + :param server_client: Runner Omnigent server client. + :returns: Parsed launch config. + :raises RuntimeError: If the snapshot or required runner env is missing. + """ + if server_client is None: + raise RuntimeError("server_client is required for runner-owned OpenCode terminals.") + try: + resp = await server_client.get( + f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}", + timeout=10.0, + ) + except httpx.HTTPError as exc: + raise RuntimeError(f"Could not fetch OpenCode launch config for {session_id!r}.") from exc + if resp.status_code != 200: + raise RuntimeError( + f"Could not fetch OpenCode launch config for {session_id!r}: " + f"GET /v1/sessions returned {resp.status_code}." + ) + try: + snapshot = resp.json() + except ValueError as exc: + raise RuntimeError( + f"Could not fetch OpenCode launch config for {session_id!r}: invalid JSON." + ) from exc + if not isinstance(snapshot, dict): + raise RuntimeError( + f"Could not fetch OpenCode launch config for {session_id!r}: " + "snapshot was not a JSON object." + ) + terminal_launch_args = snapshot.get("terminal_launch_args") + if terminal_launch_args is not None and not ( + isinstance(terminal_launch_args, list) + and all(isinstance(arg, str) for arg in terminal_launch_args) + ): + raise RuntimeError(f"Invalid terminal_launch_args for OpenCode session {session_id!r}.") + model_override = snapshot.get("model_override") + if model_override is not None: + if not isinstance(model_override, str) or not model_override: + raise RuntimeError(f"Invalid model_override for OpenCode session {session_id!r}.") + try: + validate_model_override(model_override) + except ValueError as exc: + raise RuntimeError( + f"Invalid model_override for OpenCode session {session_id!r}: {exc}" + ) from exc + external_session_id = snapshot.get("external_session_id") + if external_session_id is not None and ( + not isinstance(external_session_id, str) or not external_session_id + ): + raise RuntimeError(f"Invalid external_session_id for OpenCode session {session_id!r}.") + session_workspace = snapshot.get("workspace") + if session_workspace is not None and ( + not isinstance(session_workspace, str) or not session_workspace + ): + raise RuntimeError(f"Invalid workspace for OpenCode session {session_id!r}.") + return _OpenCodeNativeLaunchConfig( + workspace=_codex_session_workspace(session_workspace), + policy_server_url=_required_runner_env("RUNNER_SERVER_URL"), + terminal_launch_args=terminal_launch_args, + model_override=model_override, + external_session_id=external_session_id, + ) + + +async def _auto_create_opencode_terminal( + session_id: str, + resource_registry: SessionResourceRegistry, + publish_event: Callable[[str, dict[str, Any]], None], + *, + agent_spec: Any | None = None, + server_client: httpx.AsyncClient | None = None, +) -> SessionResourceView: + """ + Auto-create an OpenCode terminal for an opencode-native session. + + Mirrors :func:`_auto_create_codex_terminal`, substituting ``opencode + serve`` / ``opencode attach`` for Codex's app-server/remote transport: + boots a per-session ``opencode serve`` process, resumes-or-creates the + OpenCode session, persists bridge state + ``external_session_id``, + starts the SSE forwarder, then registers the ``opencode attach`` TUI as + a streamable terminal resource attached to that server. + + :param session_id: Session/conversation id, e.g. ``"conv_abc123"``. + :param resource_registry: Registry used to launch the terminal. + :param publish_event: Per-session SSE emitter for the new terminal. + :param agent_spec: Optional resolved agent spec (os_env + model). + :param server_client: Runner Omnigent server HTTP client. + :returns: The created terminal resource view. + """ + from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec + from omnigent.opencode_native_app_server import ( + OpenCodeNativeServer, + build_opencode_attach_args, + opencode_terminal_env, + ) + from omnigent.opencode_native_bridge import ( + OpenCodeNativeBridgeState, + clear_bridge_state, + prepare_bridge_dir, + seed_opencode_auth, + write_bridge_state, + ) + from omnigent.opencode_native_forwarder import OpenCodeNativeForwarder + + launch_config = await _opencode_native_launch_config( + session_id=session_id, + server_client=server_client, + ) + workspace = str(launch_config.workspace) + bridge_dir = prepare_bridge_dir(session_id) + # Cancel any surviving forwarder first so its teardown closes the OLD + # server, then clear stale bridge state so web injection waits for the + # new launch's URL/session instead of a dead one. + await _cancel_auto_forwarder_task(session_id) + leftover = _AUTO_OPENCODE_SERVERS.pop(session_id, None) + if leftover is not None: + with contextlib.suppress(Exception): + await leftover.close() + clear_bridge_state(bridge_dir) + + model_override = launch_config.model_override or _opencode_native_model_from_spec(agent_spec) + # Route opencode through the Databricks AI gateway when the spec names a + # profile. Unlike codex/claude/pi (which consume HARNESS_*_GATEWAY_* env the + # CLI translates), opencode reads provider/auth from its own config file, so + # synthesize an opencode.json into the per-session XDG config dir BEFORE the + # server boots. Best-effort: if the gateway can't be resolved (no profile, + # databricks-sdk absent, auth failure), opencode falls back to whatever + # provider config the ambient env/global config already gives it. + from omnigent.opencode_native_bridge import xdg_config_home_for_bridge_dir + from omnigent.opencode_native_provider import ( + build_opencode_model_default_config, + build_opencode_provider_config, + resolve_databricks_gateway, + write_opencode_provider_config, + ) + + gateway = resolve_databricks_gateway( + _opencode_native_profile_from_spec(agent_spec), model_id=model_override + ) + if gateway is not None: + # Pin the per-prompt model to the synthesized provider/endpoint id, and + # write it as opencode's default model too so the TUI launches on it. + model_override = gateway.qualified_model + config = build_opencode_provider_config(gateway) + config["model"] = model_override + write_opencode_provider_config(xdg_config_home_for_bridge_dir(bridge_dir), config) + elif model_override: + # No custom provider, but a model is pinned (``omni opencode --model`` or + # the ``omni setup`` OpenCode default): write opencode's default model so + # the native TUI and the first turn use it instead of ``opencode/big-pickle``. + # OpenCode resolves the provider from the model-id prefix against its own + # auth.json, so no provider block is needed. + write_opencode_provider_config( + xdg_config_home_for_bridge_dir(bridge_dir), + build_opencode_model_default_config(model_override), + ) + + # The server runs with a per-session XDG_DATA_HOME, so copy the user's + # `opencode auth login` credentials in — otherwise it can't authenticate + # their providers and falls back to the no-auth default model. No-op on a + # remote runner (no local auth.json) / Databricks-gateway path. + seed_opencode_auth(bridge_dir) + + server = OpenCodeNativeServer(bridge_dir=bridge_dir, workspace=launch_config.workspace) + await server.start() + _AUTO_OPENCODE_SERVERS[session_id] = server + + try: + client = server.client() + try: + opencode_session_id: str | None = None + if launch_config.external_session_id is not None: + existing = await client.get_session(launch_config.external_session_id) + if existing is not None: + opencode_session_id = existing.id + if opencode_session_id is None: + created = await client.create_session({"title": f"omnigent:{session_id}"}) + opencode_session_id = created.id + # Persist the OpenCode session id so a later relaunch resumes + # it (best effort, like codex-native). + if server_client is not None: + with contextlib.suppress(httpx.HTTPError): + await server_client.patch( + f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}", + json={"external_session_id": opencode_session_id}, + timeout=10.0, + ) + finally: + await client.aclose() + + write_bridge_state( + bridge_dir, + OpenCodeNativeBridgeState( + session_id=session_id, + server_base_url=server.base_url, + opencode_session_id=opencode_session_id, + auth_secret=server.auth_secret, + xdg_data_home=str(server.xdg_data_home), + xdg_config_home=str(server.xdg_config_home), + model_override=model_override, + workspace=workspace, + ), + ) + except Exception: + await server.close() + _AUTO_OPENCODE_SERVERS.pop(session_id, None) + raise + + # Start the SSE forwarder in the background so session creation never + # blocks on it. The forwarder owns its OpenCode client for the stream + # lifetime; ``server_client`` is the runner's Omnigent client. The + # supervisor closes the ``opencode serve`` subprocess when forwarding + # ends (cancelled on session teardown), mirroring the codex forwarder's + # ``finally`` — else one server orphans per session. + if server_client is not None: + forwarder = OpenCodeNativeForwarder( + session_id=session_id, + opencode_session_id=opencode_session_id, + opencode_client=server.client(), + server_client=server_client, + bridge_dir=bridge_dir, + workspace=workspace, + # Route OpenCode permission requests through the SAME server-side + # policy/approval gate codex-native uses. Without this the + # forwarder would fall back to its fail-closed ``reject`` default + # and deny every tool; with it, policy decides and an ``ask`` + # parks a human approval card server-side. + policy_evaluator=_build_opencode_policy_evaluator( + server_client=server_client, + conversation_id=session_id, + ), + ) + forwarder_task = asyncio.create_task( + _supervise_opencode_forwarder(session_id, server, forwarder), + name=f"opencode-forwarder-{session_id}", + ) + _register_auto_forwarder_task(session_id, forwarder_task) + + agent_os_env = _agent_os_env_from_spec(agent_spec) + try: + terminal_view = await resource_registry.launch_auxiliary_terminal( + session_id=session_id, + terminal_name="opencode", + session_key="main", + resource_role=OPENCODE_NATIVE_TERMINAL_ROLE, + parent_os_env=agent_os_env, + spec=TerminalEnvSpec( + os_env=OSEnvSpec( + type="caller_process", + cwd=workspace, + sandbox=(agent_os_env.sandbox if agent_os_env is not None else None), + ), + command=server.opencode_path, + args=build_opencode_attach_args( + server_url=server.base_url, + workspace=workspace, + session_id=opencode_session_id, + opencode_args=tuple(launch_config.terminal_launch_args or ()), + ), + env=opencode_terminal_env(server), + scrollback=100_000, + tmux_allow_passthrough=True, + tmux_start_on_attach=False, + ), + ) + publish_event( + session_id, + { + "type": "session.resource.created", + "resource": session_resource_view_to_dict(terminal_view), + }, + ) + except Exception: + await _cancel_auto_forwarder_task(session_id) + await server.close() + _AUTO_OPENCODE_SERVERS.pop(session_id, None) + raise + + _logger.info("Auto-created opencode terminal + forwarder for session %s", session_id) + return terminal_view + + +async def _supervise_opencode_forwarder( + session_id: str, + server: Any, + forwarder: Any, +) -> None: + """ + Run the OpenCode SSE forwarder, closing the server when it ends. + + Mirrors the codex forwarder task's ``finally``: when forwarding stops + (the SSE connection dropped or the task was cancelled on session + teardown) the per-session ``opencode serve`` subprocess is ours to + stop, else it orphans one process per session. + + :param session_id: Session/conversation id, e.g. ``"conv_abc123"``. + :param server: The :class:`OpenCodeNativeServer` to close on exit. + :param forwarder: The :class:`OpenCodeNativeForwarder` to run. + :returns: None. + """ + try: + await forwarder.run() + finally: + leftover = _AUTO_OPENCODE_SERVERS.pop(session_id, None) + if leftover is not None: + with contextlib.suppress(Exception): + await leftover.close() + elif server is not None: + with contextlib.suppress(Exception): + await server.close() + + +# Permission decisions can park a human approval card server-side +# (``POLICY_ACTION_ASK``), so the evaluate POST may block until a human +# resolves it. Match the codex-native policy hook's day-long budget; the +# server caps the real wait via the deciding policy's ``ask_timeout``. +_OPENCODE_POLICY_EVALUATE_TIMEOUT_S = 86400.0 +# Map the server's proto verdict onto the forwarder's verdict vocabulary +# (``map_verdict_to_decision`` reads ``decision``). Anything unknown is +# treated as ``ask`` → the forwarder fails it closed to ``reject``. +_OPENCODE_POLICY_ACTION_TO_DECISION = { + "POLICY_ACTION_ALLOW": "allow", + "POLICY_ACTION_DENY": "deny", + "POLICY_ACTION_ASK": "ask", +} + + +def _build_opencode_policy_evaluator( + *, + server_client: httpx.AsyncClient, + conversation_id: str, +) -> Callable[[Mapping[str, Any]], Awaitable[Mapping[str, Any] | None]]: + """ + Build the policy evaluator the OpenCode permission forwarder consults. + + Mirrors codex-native's policy hook exactly: every OpenCode + ``permission.v2.asked`` request is POSTed to this session's + ``/v1/sessions/{id}/policies/evaluate`` endpoint as a + ``PHASE_TOOL_CALL`` event. The server evaluates configured policies and + — for an ``ASK`` verdict — parks a human approval card and blocks until + it is resolved, returning a hard ``ALLOW``/``DENY``. The forwarder turns + that into an OpenCode ``once``/``always``/``reject`` reply. + + Fails CLOSED: an unreachable server, a non-200, a malformed body, or an + unresolved ``ASK`` all yield a ``deny``/``ask`` verdict the forwarder + rejects — never a silent approve. Only an explicit ``ALLOW`` permits the + operation. + + :param server_client: Runner's Omnigent server HTTP client. + :param conversation_id: Owning Omnigent session id, e.g. ``"conv_abc"``. + :returns: An async evaluator returning a verdict mapping, or a deny + verdict on failure. + """ + from omnigent.opencode_native_permissions import OPENCODE_NATIVE_HARNESS + + session_component = urllib.parse.quote(conversation_id, safe="") + url = f"/v1/sessions/{session_component}/policies/evaluate" + + async def _evaluate(normalized: Mapping[str, Any]) -> Mapping[str, Any] | None: + arguments: dict[str, Any] = { + key: normalized[key] + for key in ("command", "path", "url") + if normalized.get(key) is not None + } + metadata = normalized.get("metadata") + if isinstance(metadata, Mapping) and metadata: + arguments.setdefault("metadata", dict(metadata)) + body = { + "event": { + "type": "PHASE_TOOL_CALL", + "target": "", + "data": { + "name": normalized.get("action") or "permission", + "arguments": arguments, + }, + "context": {"harness": OPENCODE_NATIVE_HARNESS}, + }, + } + try: + resp = await server_client.post( + url, json=body, timeout=_OPENCODE_POLICY_EVALUATE_TIMEOUT_S + ) + except httpx.HTTPError: + _logger.warning( + "OpenCode policy evaluate POST failed for %s; failing closed", + conversation_id, + exc_info=True, + ) + return {"decision": "deny"} + if resp.status_code != 200 or not resp.content: + _logger.warning( + "OpenCode policy evaluate returned %s for %s; failing closed", + resp.status_code, + conversation_id, + ) + return {"decision": "deny"} + try: + result = resp.json() + except ValueError: + _logger.warning("OpenCode policy evaluate returned non-JSON; failing closed") + return {"decision": "deny"} + action = result.get("result") if isinstance(result, Mapping) else None + return {"decision": _OPENCODE_POLICY_ACTION_TO_DECISION.get(str(action), "ask")} + + return _evaluate + + +def _opencode_native_model_from_spec(agent_spec: Any | None) -> str | None: + """ + Resolve the OpenCode default model from a resolved agent spec. + + :param agent_spec: Optional resolved agent spec. + :returns: The spec's executor model, or ``None``. + """ + if agent_spec is None: + return None + try: + from omnigent.runtime.workflow import _resolve_spec_model + + return _resolve_spec_model(getattr(agent_spec, "spec", agent_spec)) + except Exception: # noqa: BLE001 - model resolution is best effort. + return None + + +def _opencode_native_profile_from_spec(agent_spec: Any | None) -> str | None: + """ + Resolve the Databricks profile from a resolved agent spec, if any. + + :param agent_spec: Optional resolved agent spec. + :returns: The spec's ``executor.config.profile``, or ``None``. + """ + if agent_spec is None: + return None + try: + spec = getattr(agent_spec, "spec", agent_spec) + profile = spec.executor.config.get("profile") + return str(profile) if profile else None + except Exception: # noqa: BLE001 - profile resolution is best effort. + return None + + def _pi_args_have_session_control(args: list[str]) -> bool: """ Return whether user Pi args already specify session behavior. @@ -4816,6 +5291,7 @@ def create_runner_app( _session_comment_relays: dict[str, Any] = {} _codex_terminal_ensure_locks: dict[str, asyncio.Lock] = {} _pi_terminal_ensure_locks: dict[str, asyncio.Lock] = {} + _opencode_terminal_ensure_locks: dict[str, asyncio.Lock] = {} _cursor_terminal_ensure_locks: dict[str, asyncio.Lock] = {} _goose_terminal_ensure_locks: dict[str, asyncio.Lock] = {} # Per-session lock guarding the claude-native terminal auto-create in @@ -5676,6 +6152,18 @@ def create_runner_app( from omnigent.pi_native_bridge import build_pi_native_spawn_env spawn_env = build_pi_native_spawn_env(session_id) + if harness_name == "opencode-native" and spawn_env is None: + from omnigent.opencode_native_bridge import ( + OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY, + build_opencode_native_spawn_env, + ) + + labels = await _session_labels_for_runner_spawn( + server_client=server_client, + session_id=session_id, + ) + bridge_id = labels.get(OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY) + spawn_env = build_opencode_native_spawn_env(session_id, bridge_id=bridge_id) if harness_name == "cursor-native" and spawn_env is None: from omnigent.cursor_native_bridge import build_cursor_native_spawn_env @@ -6041,6 +6529,49 @@ def create_runner_app( finally: _publish_terminal_pending(_publish_event, session_id, False) + if harness_name == "opencode-native": + # Host/web-UI session-creation path: boot the runner-owned + # ``opencode serve`` + SSE forwarder + ``opencode attach`` terminal + # so the web UI has a terminal+chat view to embed — the native-server + # sibling of the codex-native branch above. (The on-demand + # ``ensure_native_terminal`` message path also creates it; the + # per-session lock makes the two idempotent.) + _opencode_ensure_lock = _opencode_terminal_ensure_locks.setdefault( + session_id, asyncio.Lock() + ) + async with _opencode_ensure_lock: + _tr = resource_registry.terminal_registry + _has_opencode_terminal = ( + _tr is not None and _tr.get(session_id, "opencode", "main") is not None + ) + if not _has_opencode_terminal: + _publish_terminal_pending(_publish_event, session_id, True) + try: + try: + _opencode_spec = await _resolve_session_agent_spec(session_id) + except OmnigentError: + _opencode_spec = None + await _auto_create_opencode_terminal( + session_id, + resource_registry, + _publish_event, + agent_spec=_opencode_spec, + server_client=server_client, + ) + except Exception as exc: + _logger.exception( + "Failed to auto-create opencode terminal for %s", + session_id, + ) + _publish_native_terminal_start_error( + _publish_event, + session_id, + "OpenCode", + exc, + ) + finally: + _publish_terminal_pending(_publish_event, session_id, False) + if harness_name == "goose-native": _goose_ensure_lock = _goose_terminal_ensure_locks.setdefault( session_id, asyncio.Lock() @@ -9892,6 +10423,18 @@ def create_runner_app( from omnigent.pi_native_bridge import build_pi_native_spawn_env spawn_env = build_pi_native_spawn_env(conv_id) + if harness_name == "opencode-native" and spawn_env is None: + from omnigent.opencode_native_bridge import ( + OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY, + build_opencode_native_spawn_env, + ) + + labels = await _session_labels_for_runner_spawn( + server_client=server_client, + session_id=conv_id, + ) + bridge_id = labels.get(OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY) + spawn_env = build_opencode_native_spawn_env(conv_id, bridge_id=bridge_id) if harness_name == "cursor-native" and spawn_env is None: from omnigent.cursor_native_bridge import build_cursor_native_spawn_env @@ -11484,6 +12027,42 @@ def create_runner_app( content=session_resource_view_to_dict(terminal_view), ) + if ( + body.get("ensure_native_terminal") + and terminal_name == "opencode" + and session_key == "main" + ): + opencode_terminal_id = terminal_resource_id("opencode", "main") + ensure_lock = _opencode_terminal_ensure_locks.setdefault(session_id, asyncio.Lock()) + async with ensure_lock: + existing = await resource_registry.get_terminal_resource( + session_id, opencode_terminal_id + ) + if existing is not None: + return JSONResponse( + status_code=200, + content=session_resource_view_to_dict(existing), + ) + try: + opencode_agent_spec = await _resolve_session_agent_spec(session_id) + terminal_view = await _auto_create_opencode_terminal( + session_id, + resource_registry, + _publish_event, + agent_spec=opencode_agent_spec, + server_client=server_client, + ) + except Exception as exc: + _logger.exception( + "OpenCode terminal ensure failed for session=%s", + session_id, + ) + return _native_terminal_start_error_response(exc, "OpenCode") + return JSONResponse( + status_code=200, + content=session_resource_view_to_dict(terminal_view), + ) + if ( body.get("ensure_native_terminal") and terminal_name == "cursor" diff --git a/omnigent/runner/resource_registry.py b/omnigent/runner/resource_registry.py index a61f7cbc..50044203 100644 --- a/omnigent/runner/resource_registry.py +++ b/omnigent/runner/resource_registry.py @@ -50,6 +50,7 @@ _DEFAULT_WORKSPACE_ROOT = os.path.join( CODEX_NATIVE_TERMINAL_ROLE = "codex-native" CLAUDE_NATIVE_TERMINAL_ROLE = "claude-native" PI_NATIVE_TERMINAL_ROLE = "pi-native" +OPENCODE_NATIVE_TERMINAL_ROLE = "opencode-native" CURSOR_NATIVE_TERMINAL_ROLE = "cursor-native" GOOSE_NATIVE_TERMINAL_ROLE = "goose-native" # Role marker for the embedded Omnigent REPL terminal auto-created for diff --git a/omnigent/runner/tool_dispatch.py b/omnigent/runner/tool_dispatch.py index 72bfd277..a3e5b35c 100644 --- a/omnigent/runner/tool_dispatch.py +++ b/omnigent/runner/tool_dispatch.py @@ -40,6 +40,7 @@ from omnigent._wrapper_labels import ( CLAUDE_NATIVE_WRAPPER_VALUE, CODEX_NATIVE_WRAPPER_VALUE, ) +from omnigent.harness_aliases import canonicalize_harness from omnigent.model_override import ( harness_supports_model_override, model_family_mismatch, @@ -829,6 +830,61 @@ def _subagent_harness(sub_agent_name: str, agent_spec: Any | None) -> str | None return spec_harness(sub_spec) if sub_spec is not None else None +def _subagent_harness_override_from_args(args: dict[str, Any]) -> str | None: + """ + Extract a per-dispatch harness override from ``sys_session_send`` args. + + The optional ``harness`` field lives in the object form of ``args`` + (``{"input": ..., "harness": "opencode-native"}``). Returned raw (not + yet canonicalized) so the caller can validate it against the sub-agent + allowlist and quote the original spelling in errors. + + :param args: Parsed ``sys_session_send`` arguments. + :returns: The raw harness override, or ``None`` when absent. + :raises ValueError: If ``harness`` is present but not a string. + """ + raw_message = args.get("args") + if not isinstance(raw_message, dict): + return None + raw_harness = raw_message.get("harness") + if raw_harness is None: + return None + if not isinstance(raw_harness, str) or not raw_harness: + raise ValueError("'harness' must be a non-empty string when provided") + return raw_harness + + +def _subagent_allowed_harnesses(sub_agent_name: str, agent_spec: Any | None) -> frozenset[str]: + """ + Resolve the canonical harness allowlist a sub-agent opts into. + + Reads ``executor.config.allowed_harnesses`` from the named sub-agent's + spec — the explicit opt-in that gates ``args.harness``. Each entry is + canonicalized so a user-facing alias still matches. + + :param sub_agent_name: Name of the sub-agent, e.g. ``"opencode"``. + :param agent_spec: Parent agent's spec. + :returns: Canonical allowlisted harness ids (empty when none declared). + """ + sub_spec = _find_subagent_spec(sub_agent_name, agent_spec) + if sub_spec is None: + return frozenset() + executor = getattr(sub_spec, "executor", None) + config = getattr(executor, "config", None) + raw_allowed: Any = None + if isinstance(config, dict): + raw_allowed = config.get("allowed_harnesses") + elif config is not None: + raw_allowed = getattr(config, "allowed_harnesses", None) + if not isinstance(raw_allowed, (list, tuple, set, frozenset)): + return frozenset() + return frozenset( + canonicalize_harness(str(entry)) or str(entry) + for entry in raw_allowed + if isinstance(entry, str) and entry + ) + + def _normalize_subagent_model( model: str, *, @@ -952,6 +1008,11 @@ async def _execute_subagent_tool( except ValueError as exc: return f"Error: sys_session_send invalid 'model': {exc}" + try: + harness_override = _subagent_harness_override_from_args(args) + except ValueError as exc: + return f"Error: sys_session_send invalid 'harness': {exc}" + # By-session-id mode: post to an existing direct child instead of # spawning/continuing a named (agent, title) sub-agent. target_session_id = args.get("session_id") @@ -971,6 +1032,13 @@ async def _execute_subagent_tool( "existing session. Re-send without 'model' to continue " f"session {target_session_id!r}." ) + if harness_override is not None: + return ( + "Error: sys_session_send 'harness' applies only when a " + "sub-agent session is first created; it cannot change an " + "existing session. Re-send without 'harness' to continue " + f"session {target_session_id!r}." + ) return await _send_to_existing_session( target_session_id, message, @@ -1053,6 +1121,42 @@ async def _execute_subagent_tool( ) else: child_harness = _subagent_harness(str(sub_agent_name), agent_spec) + # Apply an allowlisted per-dispatch harness override. The sub-agent + # spec must explicitly opt in via executor.config.allowed_harnesses, + # and the requested harness must canonicalize into OMNIGENT_HARNESSES. + # NOTE: the server create route (``_validated_harness_override`` in + # server/routes/sessions.py) independently re-validates a session-create + # override against the GLOBAL ``OMNIGENT_HARNESSES`` (plus the omnigent + # executor-type rule), but it does NOT re-check the per-spec + # ``allowed_harnesses`` allowlist. So this orchestrator-dispatch check is + # the sole enforcement of that per-spec allowlist; a direct + # ``POST /v1/sessions`` harness_override is bounded only by the global + # allowlist. + harness_override_canonical: str | None = None + if harness_override is not None: + from omnigent.spec._omnigent_compat import OMNIGENT_HARNESSES + + canonical = canonicalize_harness(harness_override) or harness_override + allowed = _subagent_allowed_harnesses(str(sub_agent_name), agent_spec) + if not allowed: + return ( + f"Error: sys_session_send 'harness' override is not " + f"permitted for sub-agent {sub_agent_name!r}: its spec " + "declares no executor.config.allowed_harnesses allowlist." + ) + if canonical not in allowed: + return ( + f"Error: sys_session_send 'harness' {harness_override!r} is " + f"not allowlisted for sub-agent {sub_agent_name!r}: allowed " + f"harnesses are {sorted(allowed)}." + ) + if canonical not in OMNIGENT_HARNESSES: + return ( + f"Error: sys_session_send 'harness' {harness_override!r} is " + f"not a known harness; must be one of {sorted(OMNIGENT_HARNESSES)}." + ) + harness_override_canonical = canonical + child_harness = canonical # Fail loud at dispatch when the child's harness needs a CLI binary # that isn't on PATH. Otherwise a missing CLI surfaces only as a lazy # first-turn failure (e.g. the pi harness raises ImportError, which the @@ -1089,6 +1193,8 @@ async def _execute_subagent_tool( "title": f"{sub_agent_name}:{session_name}", "sub_agent_name": sub_agent_name, } + if harness_override_canonical is not None: + create_body["harness_override"] = harness_override_canonical if model is not None: # Reject up front when the child harness would silently # ignore the persisted override — no silent drops. diff --git a/omnigent/runtime/harnesses/__init__.py b/omnigent/runtime/harnesses/__init__.py index fbe0eb3a..fc8fb6f2 100644 --- a/omnigent/runtime/harnesses/__init__.py +++ b/omnigent/runtime/harnesses/__init__.py @@ -88,6 +88,16 @@ _HARNESS_MODULES: dict[str, str] = { # counterpart to the terminal-first ``goose-native`` TUI harness. Tool # approvals surface as web elicitation cards via session/request_permission. "goose": "omnigent.inner.goose_harness", + # Native OpenCode server bridge used by ``omnigent opencode``. The runner + # owns ``opencode serve`` + an SSE forwarder and this harness injects each + # web-UI turn over loopback HTTP — a native-server harness like + # codex-native, so both ``opencode-native`` and its ``native-opencode`` + # alias are in ``NATIVE_HARNESSES``. See + # omnigent/inner/opencode_native_harness.py. + "opencode-native": "omnigent.inner.opencode_native_harness", + # ``opencode`` is accepted as a friendly alias for the canonical + # ``opencode-native`` (there is no separate SDK ``opencode`` harness). + "opencode": "omnigent.inner.opencode_native_harness", } __all__ = ["_HARNESS_MODULES"] diff --git a/omnigent/server/app.py b/omnigent/server/app.py index 16306542..e36f2fde 100644 --- a/omnigent/server/app.py +++ b/omnigent/server/app.py @@ -23,6 +23,7 @@ from omnigent.native_coding_agents import ( CLAUDE_NATIVE_CODING_AGENT, CODEX_NATIVE_CODING_AGENT, CURSOR_NATIVE_CODING_AGENT, + OPENCODE_NATIVE_CODING_AGENT, PI_NATIVE_CODING_AGENT, ) from omnigent.resources import examples as _examples_resources @@ -77,6 +78,7 @@ _WEB_UI_GZIP_MINIMUM_SIZE = 1024 _CLAUDE_NATIVE_AGENT_NAME = CLAUDE_NATIVE_CODING_AGENT.agent_name _CODEX_NATIVE_AGENT_NAME = CODEX_NATIVE_CODING_AGENT.agent_name _PI_NATIVE_AGENT_NAME = PI_NATIVE_CODING_AGENT.agent_name +_OPENCODE_NATIVE_AGENT_NAME = OPENCODE_NATIVE_CODING_AGENT.agent_name _CURSOR_NATIVE_AGENT_NAME = CURSOR_NATIVE_CODING_AGENT.agent_name _DEBBY_AGENT_NAME = "debby" _POLLY_AGENT_NAME = "polly" @@ -352,6 +354,7 @@ def _ensure_default_agents( _ensure_default_claude_agent(agent_store, artifact_store, agent_cache) _ensure_default_codex_agent(agent_store, artifact_store, agent_cache) _ensure_default_pi_agent(agent_store, artifact_store, agent_cache) + _ensure_default_opencode_agent(agent_store, artifact_store, agent_cache) _ensure_default_cursor_agent(agent_store, artifact_store, agent_cache) _ensure_default_debby_agent(agent_store, artifact_store, agent_cache) _ensure_default_polly_agent(agent_store, artifact_store, agent_cache) @@ -514,6 +517,49 @@ def _ensure_default_codex_agent( ) +def _build_opencode_native_bundle() -> bytes: + """ + Build a gzipped tarball of the opencode-native-ui agent spec. + + :returns: Gzipped tarball bytes suitable for the artifact store. + """ + import tempfile + + from omnigent.opencode_native import _materialize_opencode_agent_spec + from omnigent.spec import materialize_bundle + + with tempfile.TemporaryDirectory() as tmpdir: + spec_path = _materialize_opencode_agent_spec(Path(tmpdir), model=None) + bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle") + return _tar_gz_dir(bundle_dir) + + +def _ensure_default_opencode_agent( + agent_store: AgentStore, + artifact_store: ArtifactStore, + agent_cache: Any, +) -> None: + """ + Register or refresh the opencode-native-ui agent. + + Called during server lifespan startup so the Web UI can offer OpenCode + as a built-in agent alongside Claude / Codex / Pi. Content-aware via + :func:`_ensure_builtin_agent`: a new wheel with a changed spec refreshes + the row in place rather than being ignored. + + :param agent_store: Store for agent metadata. + :param artifact_store: Store for agent bundles. + :param agent_cache: Cache for loaded agent specs. + """ + _ensure_builtin_agent( + agent_store, + artifact_store, + agent_cache, + name=_OPENCODE_NATIVE_AGENT_NAME, + bundle_bytes=_build_opencode_native_bundle(), + ) + + def _build_pi_native_bundle() -> bytes: """ Build a gzipped tarball of the pi-native-ui agent spec. diff --git a/omnigent/spec/_omnigent_compat.py b/omnigent/spec/_omnigent_compat.py index 6c4958b6..90fe6331 100644 --- a/omnigent/spec/_omnigent_compat.py +++ b/omnigent/spec/_omnigent_compat.py @@ -73,6 +73,10 @@ OMNIGENT_EXECUTOR_TYPE = "omnigent" # made ``examples/terminal_workers.yaml`` # fail at spec-load with a "must be one of [...], got # 'open-responses'" error. +# +# ``opencode-native`` is the native OpenCode server bridge (runner-owned +# ``opencode serve`` + SSE forwarder); its ``opencode`` / ``native-opencode`` +# spellings are accepted aliases below. OMNIGENT_HARNESSES = frozenset( { "antigravity", @@ -86,6 +90,7 @@ OMNIGENT_HARNESSES = frozenset( "goose-native", "openai-agents", "open-responses", + "opencode-native", "pi", "pi-native", "qwen", @@ -101,6 +106,8 @@ OMNIGENT_HARNESS_ALIASES = frozenset( "agy", "google-antigravity", "qwen-code", + "opencode", + "native-opencode", } ) _OMNIGENT_ACCEPTED_HARNESSES = OMNIGENT_HARNESSES | OMNIGENT_HARNESS_ALIASES diff --git a/omnigent/tools/builtins/spawn.py b/omnigent/tools/builtins/spawn.py index 55b8a7ed..c6aa5911 100644 --- a/omnigent/tools/builtins/spawn.py +++ b/omnigent/tools/builtins/spawn.py @@ -154,6 +154,34 @@ class SysSessionSendTool(Tool): return _build_sys_session_send_schema(self._sub_specs) +def _spec_opts_into_harness_override(spec: Any) -> bool: + """ + Return ``True`` if a sub-agent spec opts into the ``args.harness`` override. + + The override is allowlist-gated (design D.4): a sub-agent advertises it + only when its ``executor.config.allowed_harnesses`` declares a non-empty + allowlist. This mirrors the dispatch-side opt-in read in + ``omnigent/runner/tool_dispatch.py`` (``_subagent_allowed_harnesses``) so + the schema gate and the runtime guard agree on what "opted in" means. + Specs without the opt-in keep the base ``{input, purpose, model}`` args + contract. + + :param spec: A sub-agent :class:`AgentSpec` (or structural equivalent). + :returns: ``True`` when the spec declares a non-empty allowlist. + """ + executor = getattr(spec, "executor", None) + config = getattr(executor, "config", None) + if isinstance(config, dict): + raw_allowed: Any = config.get("allowed_harnesses") + elif config is not None: + raw_allowed = getattr(config, "allowed_harnesses", None) + else: + raw_allowed = None + if not isinstance(raw_allowed, (list, tuple, set, frozenset)): + return False + return any(isinstance(entry, str) and entry for entry in raw_allowed) + + def _build_sys_session_send_schema( sub_specs: dict[str, AgentSpec], ) -> dict[str, Any]: @@ -220,6 +248,51 @@ def _build_sys_session_send_schema( "the same response — they dispatch concurrently." ) ) + # ``args.harness`` is allowlist-gated (design D.4): advertise it only when + # at least one declared sub-agent opts in via + # ``executor.config.allowed_harnesses``. Specs without the opt-in keep the + # base {input, purpose, model} args object, so the orchestrator never sees a + # harness knob it can't use. The dispatch-side guard in tool_dispatch.py + # re-enforces the opt-in per child (and the server create route does too). + harness_opt_in = any(_spec_opts_into_harness_override(spec) for spec in sub_specs.values()) + harness_property: dict[str, Any] = ( + { + "harness": { + "type": "string", + "description": ( + "Optional harness override for " + "this sub-agent session, e.g. " + "'opencode-native'. Applies only " + "when this send CREATES the " + "session AND the sub-agent spec " + "allowlists it via " + "executor.config.allowed_harnesses; " + "otherwise rejected. Omitted = the " + "sub-agent's declared harness." + ), + } + } + if harness_opt_in + else {} + ) + args_description = ( + ( + "The user-input message to send to the sub-agent. The sub-agent " + "treats this as the first user turn in its conversation. Pass a " + "plain string for the normal contract, or pass " + "{input, purpose, model, harness} when a spec-level policy " + "requires explicit dispatch metadata, a per-dispatch model " + "override, or an allowlisted harness override." + ) + if harness_opt_in + else ( + "The user-input message to send to the sub-agent. The sub-agent " + "treats this as the first user turn in its conversation. Pass a " + "plain string for the normal contract, or pass " + "{input, purpose, model} when a spec-level policy requires " + "explicit dispatch metadata or a per-dispatch model override." + ) + ) return { "type": "function", "function": { @@ -274,22 +347,13 @@ def _build_sys_session_send_schema( "omitted = the harness default." ), }, + **harness_property, }, "required": ["input"], "additionalProperties": False, }, ], - "description": ( - "The user-input message to send " - "to the sub-agent. The sub-agent " - "treats this as the first user " - "turn in its conversation. Pass a " - "plain string for the normal contract, " - "or pass {input, purpose, model} when " - "a spec-level policy requires explicit " - "dispatch metadata or a per-dispatch " - "model override." - ), + "description": args_description, }, }, # Only ``args`` is universally required; the diff --git a/tests/cli/test_chat.py b/tests/cli/test_chat.py index f5d4649c..9760ffa2 100644 --- a/tests/cli/test_chat.py +++ b/tests/cli/test_chat.py @@ -1935,8 +1935,16 @@ def test_materialize_directory_bundle_with_override_keeps_nested_harness_unpinne @pytest.mark.parametrize( ("bundle_name", "expected_workers"), [ - ("polly", {"claude_code": "claude-native", "codex": "codex-native", "pi": "pi"}), - ("debby", {"claude": "claude-sdk", "gpt": "codex"}), + ( + "polly", + { + "claude_code": "claude-native", + "codex": "codex-native", + "pi": "pi", + "opencode": "opencode-native", + }, + ), + ("debby", {"claude": "claude-sdk", "gpt": "codex", "opencode": "opencode-native"}), ], ) def test_materialize_bundle_overrides_brain_harness( diff --git a/tests/cli/test_opencode_setup.py b/tests/cli/test_opencode_setup.py new file mode 100644 index 00000000..c426770e --- /dev/null +++ b/tests/cli/test_opencode_setup.py @@ -0,0 +1,111 @@ +"""Tests for the OpenCode ``omni setup`` default-model picker helpers.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import omnigent.cli as cli +from omnigent.cli import _list_opencode_models, _load_global_config, _set_opencode_default_model + + +@pytest.fixture +def _isolated_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Point the global config at a tmp file so saves don't touch ``~/.omnigent``.""" + path = tmp_path / "config.yaml" + monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", path) + return path + + +def _fake_spec() -> SimpleNamespace: + return SimpleNamespace(binary="opencode") + + +# ── _list_opencode_models ─────────────────────────────────────────────────── + + +def test_list_models_parses_nonblank_lines(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "omnigent.onboarding.harness_install.harness_install_spec", lambda _key: _fake_spec() + ) + monkeypatch.setattr( + cli.subprocess, + "run", + lambda *a, **k: subprocess.CompletedProcess( + a, 0, stdout="anthropic/claude-sonnet-4-5\nopenai/gpt-5.5\n\n \n", stderr="" + ), + ) + assert _list_opencode_models() == ["anthropic/claude-sonnet-4-5", "openai/gpt-5.5"] + + +def test_list_models_empty_when_cli_absent(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "omnigent.onboarding.harness_install.harness_install_spec", lambda _key: None + ) + assert _list_opencode_models() == [] + + +def test_list_models_empty_on_subprocess_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "omnigent.onboarding.harness_install.harness_install_spec", lambda _key: _fake_spec() + ) + + def _boom(*_a: object, **_k: object) -> object: + raise OSError("no binary") + + monkeypatch.setattr(cli.subprocess, "run", _boom) + assert _list_opencode_models() == [] + + +# ── _set_opencode_default_model ───────────────────────────────────────────── + + +def test_set_default_model_persists_choice( + monkeypatch: pytest.MonkeyPatch, _isolated_config: Path +) -> None: + monkeypatch.setattr( + cli, "_list_opencode_models", lambda: ["anthropic/claude-sonnet-4-5", "x/y"] + ) + monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: 0) + status = _set_opencode_default_model(current=None) + assert status == "✓ default model: anthropic/claude-sonnet-4-5" + assert _load_global_config()["opencode_model"] == "anthropic/claude-sonnet-4-5" + + +def test_set_default_model_clear_unsets( + monkeypatch: pytest.MonkeyPatch, _isolated_config: Path +) -> None: + cli._save_global_config({"opencode_model": "x/y"}) + monkeypatch.setattr(cli, "_list_opencode_models", lambda: ["a/b"]) + # options == ["a/b", "Clear default ..."]; index 1 is the clear row. + monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: 1) + status = _set_opencode_default_model(current="x/y") + assert status == "✓ default model cleared" + assert "opencode_model" not in _load_global_config() + + +def test_set_default_model_cancel_is_noop( + monkeypatch: pytest.MonkeyPatch, _isolated_config: Path +) -> None: + monkeypatch.setattr(cli, "_list_opencode_models", lambda: ["a/b"]) + monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: -1) + assert _set_opencode_default_model(current=None) is None + assert _load_global_config() == {} + + +def test_set_default_model_no_models_short_circuits(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli, "_list_opencode_models", list) + called = False + + def _select(*_a: object, **_k: object) -> int: + nonlocal called + called = True + return 0 + + monkeypatch.setattr("omnigent.onboarding.interactive.select", _select) + status = _set_opencode_default_model(current=None) + assert status is not None and status.startswith("✗") + assert called is False # never prompts when there's nothing to pick diff --git a/tests/e2e/omnigent/test_example_debby.py b/tests/e2e/omnigent/test_example_debby.py index 59bf93a2..4f2a04f6 100644 --- a/tests/e2e/omnigent/test_example_debby.py +++ b/tests/e2e/omnigent/test_example_debby.py @@ -44,11 +44,14 @@ def test_debby_is_two_headed_cross_vendor(debby_spec: AgentSpec) -> None: """ assert debby_spec.name == "debby" fam = {a.name: a.executor.config.get("harness") for a in debby_spec.sub_agents} - assert sorted(debby_spec.tools.agents) == ["claude", "gpt"] + # claude + gpt are the two default heads; opencode is the optional third + # perspective (default fanout stays claude + gpt — see the prompt). + assert sorted(debby_spec.tools.agents) == ["claude", "gpt", "opencode"] assert fam["claude"] == "claude-sdk" assert fam["gpt"] == "codex" - # Two distinct vendors → the heads always disagree across providers. - assert len(set(fam.values())) == 2 + assert fam["opencode"] == "opencode-native" + # Three distinct vendors → the heads always disagree across providers. + assert len(set(fam.values())) == 3 def test_debby_heads_are_unpinned(debby_spec: AgentSpec) -> None: diff --git a/tests/e2e/omnigent/test_example_polly.py b/tests/e2e/omnigent/test_example_polly.py index d7048a03..e5dc000d 100644 --- a/tests/e2e/omnigent/test_example_polly.py +++ b/tests/e2e/omnigent/test_example_polly.py @@ -79,13 +79,15 @@ def test_coding_subagents(polly_spec: AgentSpec) -> None: would break cross-vendor review — polly's differentiator. """ fam = {a.name: a.executor.config.get("harness") for a in polly_spec.sub_agents} - assert sorted(polly_spec.tools.agents) == ["claude_code", "codex", "pi"] + assert sorted(polly_spec.tools.agents) == ["claude_code", "codex", "opencode", "pi"] assert fam["claude_code"] == "claude-native" assert fam["codex"] == "codex-native" assert fam["pi"] == "pi" - # Three distinct vendors → any diff is always reviewable by another. - assert len(set(fam.values())) == 3 - for name in ("claude_code", "codex", "pi"): + assert fam["opencode"] == "opencode-native" + # Four distinct vendors (opencode is the optional fourth) → any diff is + # always reviewable by another. + assert len(set(fam.values())) == 4 + for name in ("claude_code", "codex", "pi", "opencode"): prompt = (_POLLY_BUNDLE / "agents" / name / "config.yaml").read_text(encoding="utf-8") assert "IMPLEMENT — write real product code" in prompt assert "REVIEW — verify another agent's diff" in prompt @@ -388,6 +390,6 @@ def test_function_policies_have_nonempty_arguments(polly_spec: AgentSpec) -> Non ) checked += 1 # orchestrator: blast_radius + spawn_bounds + headless_subagent_purpose_guard - # = 3; sub-agents: blast_radius x3 (claude_code, codex, pi) = 3 -> 6 total. - # Fewer = a policy dropped. - assert checked == 6, f"expected 6 function policies in the bundle, inspected {checked}" + # = 3; sub-agents: blast_radius x4 (claude_code, codex, pi, opencode) = 4 + # -> 7 total. Fewer = a policy dropped. + assert checked == 7, f"expected 7 function policies in the bundle, inspected {checked}" diff --git a/tests/e2e/omnigent/test_run_harness_without_agent_e2e.py b/tests/e2e/omnigent/test_run_harness_without_agent_e2e.py index f2e4763a..bde96c1c 100644 --- a/tests/e2e/omnigent/test_run_harness_without_agent_e2e.py +++ b/tests/e2e/omnigent/test_run_harness_without_agent_e2e.py @@ -137,10 +137,15 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None: ``_HARNESS_MODULES``, this file must gain a live round-trip row for it. - ``claude-native``, ``codex-native``, and ``pi-native`` are excluded - because their inner executors require bridge directories plus - runner-managed terminal panes to inject keys into — both set up by - their native launchers, not by ``omnigent run --harness ``. + ``claude-native``, ``codex-native``, ``pi-native``, and + ``opencode-native`` are excluded because their inner executors require + bridge directories plus runner-managed terminal panes to inject keys + into — both set up by their native launchers, not by + ``omnigent run --harness ``. (``opencode-native`` is a + terminal-takeover ``native-server`` harness, the same shape as the + other natives.) Running them through this matrix would hang or crash. + Their e2e coverage is via native launcher smoke tests (tracked + separately as native-launcher PTY/REPL smoke tests). ``cursor`` is excluded because this matrix authenticates through the Databricks gateway/profile, while cursor-agent talks only to @@ -173,6 +178,7 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None: "claude-native", "codex-native", "pi-native", + "opencode-native", "cursor", "cursor-native", "antigravity", diff --git a/tests/e2e/test_host_opencode_native_e2e.py b/tests/e2e/test_host_opencode_native_e2e.py new file mode 100644 index 00000000..1b2211bb --- /dev/null +++ b/tests/e2e/test_host_opencode_native_e2e.py @@ -0,0 +1,283 @@ +"""End-to-end test for the ``opencode-native-ui`` built-in agent (full stack). + +The runner-orchestration sibling of ``test_opencode_native_wire_contract_e2e.py`` +(which drives ``OpenCodeNativeServer`` directly). This exercises the WHOLE +product path: list built-in agents -> find ``opencode-native-ui`` -> connect a +host daemon -> create a host-bound session -> the runner auto-creates the +``opencode serve`` + SSE forwarder + ``opencode attach`` terminal resource -> +send a user message -> poll session items until the assistant echoes a marker. + +Opt-in (needs a pinned ``opencode`` binary + LLM credentials):: + + OMNIGENT_E2E_OPENCODE_NATIVE=1 \ + HOME=/tmp/omni-isolated DATABRICKS_CONFIG_FILE=$REAL_HOME/.databrickscfg \ + .venv/bin/python -m pytest tests/e2e/test_host_opencode_native_e2e.py \ + --profile ai-devtools-prod \ + --llm-api-key "$(databricks auth token -p ai-devtools-prod \ + | python -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')" \ + -v + +Running under an isolated ``$HOME`` keeps the runner-owned ``opencode serve`` +bridge dirs (``~/.omnigent/opencode-native``) and the daemon registry off the +developer's real ones, so a co-resident daemon is never disturbed. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time +from pathlib import Path + +import httpx +import pytest + +from omnigent.entities.session_resources import terminal_resource_id +from tests._helpers.compat import apply_runner_env, compat_runner_cwd, runner_executable +from tests.e2e.helpers import POLL_INTERVAL_S + +_OPENCODE_NATIVE_AGENT_NAME = "opencode-native-ui" + +pytestmark = pytest.mark.skipif( + os.environ.get("OMNIGENT_E2E_OPENCODE_NATIVE") != "1" or shutil.which("opencode") is None, + reason=( + "opencode-native host e2e needs a pinned `opencode` binary + LLM creds; " + "set OMNIGENT_E2E_OPENCODE_NATIVE=1 (and pass --profile/--llm-api-key) to run" + ), +) + + +def _spawn_host_daemon(*, tmp_path: Path, live_server: str) -> subprocess.Popen[bytes]: + """Spawn an ``omnigent host`` daemon pointed at the test server.""" + repo_root = Path(__file__).resolve().parents[2] + env = os.environ.copy() + env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{env.get('PYTHONPATH', '')}" + daemon_log = tmp_path / "host-daemon.log" + with open(daemon_log, "w") as log_fh: + return subprocess.Popen( + [runner_executable(), "-m", "omnigent.host._daemon_entry", "--server", live_server], + env=apply_runner_env(env), + cwd=compat_runner_cwd(), + stdout=subprocess.DEVNULL, + stderr=log_fh, + ) + + +def _online_host_id(client: httpx.Client, timeout: float = 30.0) -> str: + """Poll ``GET /v1/hosts`` until at least one host is online.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resp = client.get("/v1/hosts") + if resp.status_code == 200: + online = [h for h in resp.json().get("hosts", []) if h["status"] == "online"] + if online: + return str(online[0]["host_id"]) + time.sleep(POLL_INTERVAL_S) + raise AssertionError(f"No host came online within {timeout}s") + + +def _poll_for_terminal( + client: httpx.Client, *, session_id: str, resource_id: str, timeout: float +) -> None: + """Poll resources until the runner registers the opencode terminal.""" + deadline = time.monotonic() + timeout + last: list[object] = [] + while time.monotonic() < deadline: + resp = client.get(f"/v1/sessions/{session_id}/resources") + if resp.status_code == 200: + data = resp.json().get("data", []) + last = [r.get("id") for r in data] + if any(r.get("id") == resource_id and r.get("type") == "terminal" for r in data): + return + time.sleep(POLL_INTERVAL_S) + raise AssertionError( + f"Terminal {resource_id!r} never appeared for {session_id} within {timeout}s; saw {last!r}" + ) + + +def test_opencode_native_multiturn_item_order( + http_client: httpx.Client, + tmp_path: Path, + live_server: str, +) -> None: + """DIAGNOSTIC: dump /items for a 3-turn conversation to inspect ordering. + + Reproduces the reported web-chat bug (assistant messages clustered, user + messages out of order/missing). Sends 3 user turns through a real + host-bound opencode session and prints every persisted item's + role/position/response_id so we can see exactly how user vs assistant items + land. Asserts strict user/assistant interleaving. + """ + resp = http_client.get("/v1/agents") + resp.raise_for_status() + agent_id = next( + (a["id"] for a in resp.json()["data"] if a["name"] == _OPENCODE_NATIVE_AGENT_NAME), None + ) + assert agent_id is not None + workspace = tmp_path / "ws" + workspace.mkdir() + + daemon = _spawn_host_daemon(tmp_path=tmp_path, live_server=live_server) + try: + host_id = _online_host_id(http_client) + create = http_client.post( + "/v1/sessions", + json={ + "agent_id": agent_id, + "host_id": host_id, + "workspace": str(workspace), + # gateway-valid model via opencode's openai provider (the daemon + # has OPENAI_BASE_URL/OPENAI_API_KEY pointed at the gateway). + "model_override": "openai/databricks-claude-sonnet-4-6", + }, + timeout=60.0, + ) + create.raise_for_status() + session_id = create.json()["id"] + _poll_for_terminal( + http_client, + session_id=session_id, + resource_id=terminal_resource_id("opencode", "main"), + timeout=90.0, + ) + + prompts = ["say ONE", "say TWO", "say THREE"] + for i, prompt in enumerate(prompts): + http_client.post( + f"/v1/sessions/{session_id}/events", + json={ + "type": "message", + "data": {"role": "user", "content": [{"type": "input_text", "text": prompt}]}, + }, + timeout=30.0, + ).raise_for_status() + # Wait until at least i+1 assistant message items exist. + deadline = time.monotonic() + 120.0 + while time.monotonic() < deadline: + data = ( + http_client.get( + f"/v1/sessions/{session_id}/items", params={"limit": 100, "order": "asc"} + ) + .json() + .get("data", []) + ) + n_asst = sum( + 1 + for it in data + if it.get("type") == "message" and it.get("role") == "assistant" + ) + if n_asst >= i + 1: + break + time.sleep(POLL_INTERVAL_S) + + data = ( + http_client.get( + f"/v1/sessions/{session_id}/items", params={"limit": 100, "order": "asc"} + ) + .json() + .get("data", []) + ) + print("\n===ITEMS_DUMP_START===") + for it in data: + text = "" + for blk in it.get("content", []) or []: + if isinstance(blk, dict) and isinstance(blk.get("text"), str): + text += blk["text"] + print( + f"pos={it.get('position')!s:>4} type={it.get('type')!s:18} " + f"role={it.get('role')!s:10} rid={str(it.get('response_id'))[:24]:24} " + f"text={text[:40]!r}" + ) + print("===ITEMS_DUMP_END===\n") + + roles = [ + it.get("role") + for it in data + if it.get("type") == "message" and it.get("role") in ("user", "assistant") + ] + expected = ["user", "assistant", "user", "assistant", "user", "assistant"] + assert roles == expected, f"messages not interleaved by turn: {roles}" + finally: + daemon.terminate() + try: + daemon.wait(timeout=10) + except subprocess.TimeoutExpired: + daemon.kill() + + +def test_opencode_native_builtin_registered_at_startup(http_client: httpx.Client) -> None: + """The server auto-registers ``opencode-native-ui`` as a built-in agent.""" + resp = http_client.get("/v1/agents") + resp.raise_for_status() + names = {a["name"] for a in resp.json()["data"]} + assert _OPENCODE_NATIVE_AGENT_NAME in names, ( + f"Expected {_OPENCODE_NATIVE_AGENT_NAME!r} in built-ins {names}; " + "_ensure_default_opencode_agent did not run." + ) + + +def test_opencode_native_host_session_auto_creates_terminal( + http_client: httpx.Client, + tmp_path: Path, + live_server: str, +) -> None: + """A host-bound opencode-native session auto-creates the opencode terminal. + + Exercises the runner-orchestration path end-to-end: an online host daemon + runs the session, and the runner's session-creation dispatch must call + :func:`_auto_create_opencode_terminal` (boot ``opencode serve`` + SSE + forwarder + ``opencode attach``) and register ``terminal_opencode_main`` as + a streamable resource — so the Web UI has a terminal+chat view to embed, + exactly as it does for claude/codex/pi/cursor. + + (The LLM turn itself is covered by ``test_opencode_native_wire_contract_e2e`` + and the standalone gateway round-trip; out-of-box turns through the built-in + agent additionally need its default model gateway-wired — tracked + separately.) + """ + resp = http_client.get("/v1/agents") + resp.raise_for_status() + agent_id = next( + (a["id"] for a in resp.json()["data"] if a["name"] == _OPENCODE_NATIVE_AGENT_NAME), None + ) + assert agent_id is not None, "opencode-native-ui agent not seeded" + + workspace = tmp_path / "ws" + workspace.mkdir() + + daemon = _spawn_host_daemon(tmp_path=tmp_path, live_server=live_server) + try: + host_id = _online_host_id(http_client) + create = http_client.post( + "/v1/sessions", + json={"agent_id": agent_id, "host_id": host_id, "workspace": str(workspace)}, + timeout=60.0, + ) + create.raise_for_status() + session_id = create.json()["id"] + + # The runner's _auto_create_opencode_terminal must register the TUI on + # session creation (the dispatch branch this PR adds alongside the other + # natives) — otherwise the Web UI would have no terminal to attach to. + terminal_id = terminal_resource_id("opencode", "main") + _poll_for_terminal( + http_client, + session_id=session_id, + resource_id=terminal_id, + timeout=90.0, + ) + # The `omnigent opencode` CLI launcher attaches this TTY directly to the + # runner-owned tmux pane, so the terminal resource must expose the tmux + # socket + target — assert that prerequisite is present. + detail = http_client.get(f"/v1/sessions/{session_id}/resources/terminals/{terminal_id}") + detail.raise_for_status() + meta = detail.json().get("metadata", {}) + assert meta.get("tmux_socket"), f"terminal has no tmux_socket: {meta}" + assert meta.get("tmux_target"), f"terminal has no tmux_target: {meta}" + finally: + daemon.terminate() + try: + daemon.wait(timeout=10) + except subprocess.TimeoutExpired: + daemon.kill() diff --git a/tests/e2e/test_opencode_native_wire_contract_e2e.py b/tests/e2e/test_opencode_native_wire_contract_e2e.py new file mode 100644 index 00000000..eb3d2216 --- /dev/null +++ b/tests/e2e/test_opencode_native_wire_contract_e2e.py @@ -0,0 +1,115 @@ +"""End-to-end test: the OpenCode-native client speaks to a REAL ``opencode serve``. + +The opencode-native harness's HTTP+SSE client (``omnigent.opencode_native_client``) +is hand-shaped from the pinned OpenCode OpenAPI (vendored at +``omnigent/opencode/openapi-1.17.7.json``), so the rest of the suite exercises it +only against in-process fakes. This test boots a real ``opencode serve`` via the +PR's own :class:`~omnigent.opencode_native_app_server.OpenCodeNativeServer` and +drives the provider-independent endpoints the harness relies on, validating the +wire contract against the actual binary — the one thing the fakes cannot prove. + +Environment requirements (why this is opt-in, not pure-CI) +---------------------------------------------------------- +* **Opt-in only**: set ``OMNIGENT_E2E_OPENCODE_NATIVE=1`` and have a pinned + ``opencode`` (>=1.17.7,<1.18.0) on ``PATH``. Unlike the codex/claude native + e2es this needs **no** interactive login or model credential — session + create/list, the SSE ``/event`` stream, permissions, fork and abort are all + provider-independent. The gate just keeps it off CI runners without the binary. +* Run it with:: + + OMNIGENT_E2E_OPENCODE_NATIVE=1 \ + .venv/bin/python -m pytest \ + tests/e2e/test_opencode_native_wire_contract_e2e.py -v +""" + +from __future__ import annotations + +import asyncio +import os +import shutil +import tempfile +from pathlib import Path + +import pytest + +from omnigent.opencode_native_app_server import ( + OpenCodeNativeServer, + OpenCodeVersionError, +) + +pytestmark = pytest.mark.skipif( + os.environ.get("OMNIGENT_E2E_OPENCODE_NATIVE") != "1" or shutil.which("opencode") is None, + reason=( + "opencode-native wire-contract e2e needs a pinned `opencode` binary on PATH; " + "set OMNIGENT_E2E_OPENCODE_NATIVE=1 (and `npm i -g opencode-ai@1.17.7`) to run" + ), +) + +# Keys the typed client parses off a created session — assert the real server +# still emits the shape OpenCodeSession.from_payload depends on. +_REQUIRED_SESSION_KEYS = {"id", "directory", "title"} + + +async def test_opencode_native_wire_contract_against_real_server() -> None: + """A real ``opencode serve`` answers every endpoint the harness drives. + + Covers: server boot + version pin, session create/get (+404), message + list, permission list, the SSE ``/event`` stream framing, fork and abort — + the provider-independent surface the SSE forwarder and executor depend on. + """ + tmp = Path(tempfile.mkdtemp(prefix="opencode-e2e-")) + bridge = tmp / "bridge" + bridge.mkdir(parents=True, exist_ok=True) + workspace = tmp / "ws" + workspace.mkdir(parents=True, exist_ok=True) + + server = OpenCodeNativeServer(bridge_dir=bridge, workspace=workspace) + try: + try: + await server.start() + except OpenCodeVersionError as exc: + pytest.skip(f"installed opencode is outside the supported pin: {exc}") + + assert server.version is not None + client = server.client() + try: + # create_session — and the parsed shape the forwarder relies on. + session = await client.create_session({"title": "omnigent-e2e"}) + assert session.id, "created session has no id" + assert set(session.raw) >= _REQUIRED_SESSION_KEYS, ( + f"server session payload missing keys the client parses: " + f"{_REQUIRED_SESSION_KEYS - set(session.raw)}" + ) + + # get_session round-trips, and a missing id is a clean None (404). + fetched = await client.get_session(session.id) + assert fetched is not None and fetched.id == session.id + assert await client.get_session("ses_does_not_exist_xyz") is None + + # message + permission listings are well-formed (empty for a fresh + # session that has run no turn). + assert await client.list_messages(session.id) == [] + assert await client.list_permissions() == [] + + # The SSE /event stream connects and frames at least the initial + # ``server.connected`` event (proves _parse_sse against the real wire). + async def _first_event() -> object | None: + async for event in client.events(): + return event + return None + + try: + event = await asyncio.wait_for(_first_event(), timeout=10.0) + except asyncio.TimeoutError: + event = None + if event is not None: + assert isinstance(event.type, str) + + # fork creates a new session; abort returns cleanly with no work. + forked = await client.fork(session.id) + assert forked.id and forked.id != session.id + assert isinstance(await client.abort(session.id), bool) + finally: + await client.aclose() + finally: + await server.close() diff --git a/tests/e2e_ui/start_session/test_start_session.py b/tests/e2e_ui/start_session/test_start_session.py index 3190e4e9..6bcedd19 100644 --- a/tests/e2e_ui/start_session/test_start_session.py +++ b/tests/e2e_ui/start_session/test_start_session.py @@ -221,6 +221,33 @@ def _pi_native_agents_body() -> str: ) +def _opencode_native_agents_body() -> str: + """Stub body for ``GET /v1/agents``: the native OpenCode agent. + + ``name: "opencode-native-ui"`` + ``harness: "opencode-native"`` is what the + frontend maps (via ``nativeCodingAgents``) to the display label + **"OpenCode"** and the opencode-native wrapper labels. As with the Pi stub, + the wire ``display_name`` is deliberately the raw ``"opencode-native-ui"`` + to prove the picker derives "OpenCode" itself (the harness→display mapping + wins) rather than echoing the server's raw value. Sole agent, so it + auto-selects and no explicit pick is needed. + """ + return json.dumps( + { + "data": [ + { + "id": "ag_opencode_e2e", + "name": "opencode-native-ui", + "display_name": "opencode-native-ui", + "description": "OpenCode coding agent", + "harness": "opencode-native", + "skills": [], + } + ] + } + ) + + def _hosts_body() -> str: """Stub body for ``GET /v1/hosts``: one online host the composer picks.""" return json.dumps( @@ -612,6 +639,98 @@ async def _drive_pi_native_start(base_url: str, session_id: str) -> None: await browser.close() +def test_start_session_opencode_native_picker_and_wrapper_labels( + seeded_session: tuple[str, str], +) -> None: + """Native OpenCode: the picker shows "OpenCode" and create carries labels. + + Covers the user-facing OpenCode native-agent flow this PR adds (mirrors + the Codex / Pi native rows): + + 1. **Picker label/icon** — the agent chip renders the harness-derived + display label **"OpenCode"** (via ``nativeCodingAgents``), NOT the raw + agent name ``"opencode-native-ui"`` the server sends. + 2. **Session-creation wrapper labels** — selecting OpenCode and sending + must POST ``/v1/sessions`` with the terminal-first wrapper labels + (``omnigent.ui: terminal`` + ``omnigent.wrapper: opencode-native-ui``) + that make the runner launch the OpenCode TUI and the web UI render the + Chat/Terminal view. + """ + base_url, session_id = seeded_session + _run_in_fresh_loop(_drive_opencode_native_start(base_url, session_id)) + + +async def _drive_opencode_native_start(base_url: str, session_id: str) -> None: + async with async_playwright() as pw: + browser = await pw.chromium.launch() + page = await browser.new_page() + try: + create_bodies: list[dict[str, Any]] = [] + await _register_common_routes( + page, + created_session_id=session_id, + create_bodies=create_bodies, + agents_body=_opencode_native_agents_body(), + ) + + # Neutralize agent discovery so the picker shows ONLY the stubbed + # built-in OpenCode. The landing picker merges `/v1/agents` with + # agents found by scanning the caller's sessions + # (`/v1/sessions?kind=any`); on the shared e2e_ui server, sessions + # other tests left behind (e.g. a claude-native fork) would + # otherwise leak in and — ranking ahead of OpenCode — auto-select, + # so the chip would read the wrong label. Registered after + # _register_common_routes so it wins for the kind=any scan; the + # bare POST /v1/sessions create still falls through to the + # capturing handler. + async def handle_agent_scan(route: Route) -> None: + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"data": []}), + ) + + await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan) + + # Seed a recent working directory so the working-directory chip + # auto-fills and Send can enable without touching the file browser. + await page.add_init_script( + f"""window.localStorage.setItem( + "omnigent:recent-workspaces", + JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }}) + );""" + ) + + await page.goto(f"{base_url}/") + await page.get_by_test_id("new-chat-landing-input").wait_for( + state="visible", timeout=30_000 + ) + + # OpenCode auto-selects (sole agent). The chip shows the derived + # label "OpenCode" — and crucially NOT "...native...": the raw + # agent name "opencode-native-ui" must never surface. + agent_chip = page.get_by_test_id("new-chat-landing-agent-select") + await expect(agent_chip).to_contain_text("OpenCode") + await expect(agent_chip).not_to_contain_text("native") + + await page.get_by_test_id("new-chat-landing-input").fill("explore the repo") + await page.get_by_test_id("new-chat-landing-submit").click() + + await _wait_until(lambda: len(create_bodies) == 1) + body = create_bodies[0] + assert body["agent_id"] == "ag_opencode_e2e", body + assert body["host_id"] == _HOST_ID, body + assert body["workspace"] == "/work/repo", body + # The terminal-first wrapper labels are the contract that drives the + # runner-owned OpenCode TUI and the web UI's Chat/Terminal view. + assert body.get("labels") == { + "omnigent.ui": "terminal", + "omnigent.wrapper": "opencode-native-ui", + }, body + finally: + await browser.close() + + def test_start_session_select_folder(seeded_session: tuple[str, str]) -> None: """Browsing into a folder sets the new session's working directory. diff --git a/tests/inner/test_opencode_native_executor.py b/tests/inner/test_opencode_native_executor.py new file mode 100644 index 00000000..64854c96 --- /dev/null +++ b/tests/inner/test_opencode_native_executor.py @@ -0,0 +1,241 @@ +"""Tests for the OpenCode native executor turn lifecycle.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from omnigent import opencode_http_transport as transport_mod +from omnigent.inner.executor import ExecutorError, TurnComplete +from omnigent.inner.opencode_native_executor import OpenCodeNativeExecutor +from omnigent.opencode_native_bridge import ( + OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR, + OpenCodeNativeBridgeState, + write_bridge_state, +) +from omnigent.opencode_native_client import OpenCodeClient + +_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" # noqa: E501 +_PNG_DATA_URI = f"data:image/png;base64,{_PNG_B64}" + + +class _FakeServer: + """Records the requests a fake OpenCode HTTP server receives.""" + + def __init__(self) -> None: + self.requests: list[tuple[str, str, dict[str, Any]]] = [] + + def handler(self, request: httpx.Request) -> httpx.Response: + body: dict[str, Any] = {} + if request.content: + try: + body = json.loads(request.content) + except json.JSONDecodeError: + body = {} + self.requests.append((request.method, request.url.path, body)) + if request.url.path.endswith("/abort"): + return httpx.Response(200, json=True) + return httpx.Response(200, json={}) + + +@pytest.fixture +def fake_server(monkeypatch: pytest.MonkeyPatch) -> _FakeServer: + """Patch the transport's client factory to talk to a fake server.""" + server = _FakeServer() + + def fake_client_for_state( + *, base_url: str, auth_secret: str | None, directory: str | None = None + ) -> OpenCodeClient: + mock = httpx.AsyncClient( + base_url="http://opencode.test", + transport=httpx.MockTransport(server.handler), + ) + return OpenCodeClient("http://opencode.test", client=mock) + + monkeypatch.setattr(transport_mod, "client_for_state", fake_client_for_state) + return server + + +def _seed_state( + bridge_dir: Path, + *, + session_id: str = "conv_1", + opencode_session_id: str = "ses_1", + model_override: str | None = None, +) -> None: + write_bridge_state( + bridge_dir, + OpenCodeNativeBridgeState( + session_id=session_id, + server_base_url="http://127.0.0.1:49231", + opencode_session_id=opencode_session_id, + auth_secret="pw", + model_override=model_override, + ), + ) + + +def _executor( + bridge_dir: Path, monkeypatch: pytest.MonkeyPatch, *, request_id: str = "conv_1" +) -> OpenCodeNativeExecutor: + monkeypatch.setenv(OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR, request_id) + executor = OpenCodeNativeExecutor(bridge_dir=bridge_dir) + executor._boot_poll_attempts = 1 + executor._boot_poll_delay = 0.0 + return executor + + +async def _run(executor: OpenCodeNativeExecutor, content: Any) -> list[Any]: + events: list[Any] = [] + async for event in executor.run_turn([{"role": "user", "content": content}], [], ""): + events.append(event) + return events + + +async def test_run_turn_injects_prompt_and_completes( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_state(tmp_path) + executor = _executor(tmp_path, monkeypatch) + events = await _run(executor, "hello") + assert [type(e) for e in events] == [TurnComplete] + prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")] + assert len(prompt_reqs) == 1 + parts = prompt_reqs[0][2]["parts"] + assert parts == [{"type": "text", "text": "hello"}] + + +async def test_run_turn_with_blocks( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_state(tmp_path) + executor = _executor(tmp_path, monkeypatch) + events = await _run( + executor, + [ + {"type": "input_text", "text": "what is this?"}, + {"type": "input_image", "image_url": _PNG_DATA_URI}, + ], + ) + assert [type(e) for e in events] == [TurnComplete] + parts = fake_server.requests[0][2]["parts"] + text_parts = [p for p in parts if p["type"] == "text"] + file_parts = [p for p in parts if p["type"] == "file"] + assert text_parts[0]["text"] == "what is this?" + assert len(file_parts) == 1 + assert file_parts[0]["url"] == _PNG_DATA_URI + assert file_parts[0]["mime"] == "image/png" + # No inline base64 in any text part. + assert all(_PNG_B64 not in p.get("text", "") for p in parts) + + +async def test_run_turn_pins_resolved_model_on_prompt( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The session's resolved model reaches the prompt body from turn one. + + OpenCode's session-create body cannot carry a model, so the override is + applied per prompt as ``model: {providerID, modelID}``. This pins the + first injected turn (which OpenCode then persists as the session + default), so the override governs the run from the start — not only a + later web turn. + """ + _seed_state(tmp_path, model_override="anthropic/claude-opus-4") + executor = _executor(tmp_path, monkeypatch) + events = await _run(executor, "hello") + assert [type(e) for e in events] == [TurnComplete] + prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")] + assert len(prompt_reqs) == 1 + body = prompt_reqs[0][2] + assert body["model"] == {"providerID": "anthropic", "modelID": "claude-opus-4"} + + +async def test_run_turn_omits_model_when_no_override( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """With no model_override the prompt carries no model (OpenCode default).""" + _seed_state(tmp_path) + executor = _executor(tmp_path, monkeypatch) + await _run(executor, "hello") + prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")] + assert "model" not in prompt_reqs[0][2] + + +async def test_run_turn_no_user_content_errors( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_state(tmp_path) + executor = _executor(tmp_path, monkeypatch) + events = await _run(executor, "") + assert [type(e) for e in events] == [ExecutorError] + assert fake_server.requests == [] + + +async def test_run_turn_missing_bridge_state_errors( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # No state written; resolve never returns a session id. + executor = _executor(tmp_path, monkeypatch) + events = await _run(executor, "hi") + assert [type(e) for e in events] == [ExecutorError] + + +async def test_run_turn_session_mismatch_errors( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_state(tmp_path, session_id="conv_OTHER") + executor = _executor(tmp_path, monkeypatch, request_id="conv_1") + events = await _run(executor, "hi") + assert [type(e) for e in events] == [ExecutorError] + + +async def test_interrupt_calls_abort( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_state(tmp_path) + executor = _executor(tmp_path, monkeypatch) + assert await executor.interrupt_session("k") is True + abort_reqs = [r for r in fake_server.requests if r[1].endswith("/abort")] + assert len(abort_reqs) == 1 + + +async def test_enqueue_message_injects_prompt( + fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_state(tmp_path) + executor = _executor(tmp_path, monkeypatch) + assert await executor.enqueue_session_message("k", "steer me") is True + prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")] + assert prompt_reqs[0][2]["parts"] == [{"type": "text", "text": "steer me"}] + + +def test_capabilities(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _seed_state(tmp_path) + executor = _executor(tmp_path, monkeypatch) + assert executor.supports_streaming() is False + assert executor.handles_tools_internally() is True + assert executor.supports_live_message_queue() is True + + +def test_harness_create_app_builds_fastapi() -> None: + """The ``opencode-native`` harness module builds a FastAPI app (lazy executor).""" + from fastapi import FastAPI + + from omnigent.inner.opencode_native_harness import create_app + + assert isinstance(create_app(), FastAPI) + + +def test_harness_executor_factory_builds_from_env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The harness executor factory constructs an executor from the spawn env.""" + from omnigent.inner.opencode_native_harness import _build_opencode_native_executor + from omnigent.opencode_native_bridge import OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR + + monkeypatch.setenv(OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR, str(tmp_path)) + assert isinstance(_build_opencode_native_executor(), OpenCodeNativeExecutor) diff --git a/tests/onboarding/test_harness_readiness.py b/tests/onboarding/test_harness_readiness.py index e031543d..28c7933a 100644 --- a/tests/onboarding/test_harness_readiness.py +++ b/tests/onboarding/test_harness_readiness.py @@ -149,6 +149,10 @@ def test_configured_harness_map_covers_all_spellings( "antigravity", "agy", "google-antigravity", + # Native OpenCode harness + its user-facing aliases. + "opencode-native", + "native-opencode", + "opencode", # Qwen harnesses "qwen", "qwen-code", diff --git a/tests/onboarding/test_interactive.py b/tests/onboarding/test_interactive.py index bc171df0..d5bd4ac3 100644 --- a/tests/onboarding/test_interactive.py +++ b/tests/onboarding/test_interactive.py @@ -358,3 +358,34 @@ def test_clear_screen_emits_clear_sequence_only_on_a_tty(monkeypatch: pytest.Mon monkeypatch.setattr(sys, "stdout", pipe) interactive.clear_screen() assert pipe.getvalue() == "" # no escape sequences leak into non-TTY output + + +def test_render_menu_windows_long_list_to_viewport() -> None: + """``max_visible`` renders only the window slice + scroll markers.""" + options = [f"item-{i}" for i in range(20)] + out = interactive._render_menu( + "Pick", + options, + 10, + descriptions=None, + width=80, + selectable=[True] * 20, + max_visible=5, + window_start=8, + ) + # Visible window is options[8:13]; rows outside it are not rendered. + for shown in ("item-8", "item-10", "item-12"): + assert shown in out + assert "item-0" not in out + assert "item-19" not in out + assert "8 more" in out and "7 more" in out # ↑/↓ scroll markers + + +def test_render_menu_without_max_visible_renders_all_rows() -> None: + """Default (no ``max_visible``) renders every row — no regression.""" + options = [f"item-{i}" for i in range(20)] + out = interactive._render_menu( + "Pick", options, 0, descriptions=None, width=80, selectable=[True] * 20 + ) + assert "item-0" in out and "item-19" in out + assert "more" not in out diff --git a/tests/onboarding/test_opencode_auth.py b/tests/onboarding/test_opencode_auth.py new file mode 100644 index 00000000..46b0d786 --- /dev/null +++ b/tests/onboarding/test_opencode_auth.py @@ -0,0 +1,89 @@ +"""Tests for opencode-native credential reporting (``opencode_auth.py``).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import omnigent.onboarding.opencode_auth as oc + + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Point XDG_DATA_HOME at a tmp dir and clear provider env keys.""" + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share")) + for _provider_id, _label, var in oc._ENV_PROVIDER_VARS: + monkeypatch.delenv(var, raising=False) + + +def _write_auth(tmp_path: Path, providers: dict[str, object]) -> None: + path = oc.opencode_auth_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(providers), encoding="utf-8") + + +def test_auth_path_honors_xdg_data_home(tmp_path: Path) -> None: + assert oc.opencode_auth_path() == tmp_path / "share" / "opencode" / "auth.json" + + +def test_stored_providers_reads_auth_json_keys(tmp_path: Path) -> None: + _write_auth(tmp_path, {"anthropic": {"type": "api", "key": "x"}, "openai": {"type": "oauth"}}) + assert set(oc._stored_providers()) == {"anthropic", "openai"} + + +def test_stored_providers_empty_when_missing_or_invalid(tmp_path: Path) -> None: + assert oc._stored_providers() == () # no file + oc.opencode_auth_path().parent.mkdir(parents=True, exist_ok=True) + oc.opencode_auth_path().write_text("not json", encoding="utf-8") + assert oc._stored_providers() == () + + +def test_env_providers_detects_present_keys(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-y") + labels = oc._env_providers() + assert "OpenAI" in labels and "Anthropic" in labels + + +def test_summary_ready_requires_installed_and_a_provider( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(oc, "harness_cli_installed", lambda _key: True) + # No provider yet → not ready. + assert oc.opencode_auth_summary().ready is False + # An env key flips it ready. + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + summary = oc.opencode_auth_summary() + assert summary.ready is True + assert summary.has_provider is True + assert "env: OpenAI" in summary.describe() + + +def test_summary_not_ready_when_cli_absent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(oc, "harness_cli_installed", lambda _key: False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + assert oc.opencode_auth_summary().ready is False # provider present but no binary + + +def test_describe_lists_stored_and_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(oc, "harness_cli_installed", lambda _key: True) + _write_auth(tmp_path, {"anthropic": {"type": "api", "key": "x"}}) + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + text = oc.opencode_auth_summary().describe() + assert "1 stored (anthropic)" in text + assert "env: OpenAI" in text + + +def test_reachable_provider_ids_merges_stored_and_env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _write_auth(tmp_path, {"anthropic": {"type": "api", "key": "x"}}) + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + ids = oc.reachable_provider_ids() + assert "anthropic" in ids # from auth.json + assert "openai" in ids # from env key + assert "groq" not in ids diff --git a/tests/runner/test_opencode_policy_evaluator.py b/tests/runner/test_opencode_policy_evaluator.py new file mode 100644 index 00000000..d226a124 --- /dev/null +++ b/tests/runner/test_opencode_policy_evaluator.py @@ -0,0 +1,102 @@ +"""Unit tests for the OpenCode permission policy evaluator wiring. + +The runner wires this evaluator into the OpenCode permission forwarder so +every ``permission.v2.asked`` request is decided by the SAME server-side +policy/approval gate codex-native uses (``POST /policies/evaluate``), not +silently auto-approved. These tests pin the request shape, the verdict +mapping, and — critically — that every failure mode fails CLOSED. +""" + +from __future__ import annotations + +import json as _json +from typing import Any + +import httpx + +from omnigent.runner.app import _build_opencode_policy_evaluator + + +class _FakeServerClient: + """httpx-shaped stub recording the policy-evaluate POST.""" + + def __init__( + self, + *, + status: int = 200, + body: dict[str, Any] | None = None, + raise_exc: Exception | None = None, + ) -> None: + self._status = status + self._body = body + self._raise_exc = raise_exc + self.calls: list[tuple[str, dict[str, Any], Any]] = [] + + async def post(self, url: str, *, json: dict[str, Any], timeout: Any = None) -> httpx.Response: + self.calls.append((url, json, timeout)) + if self._raise_exc is not None: + raise self._raise_exc + content = b"" if self._body is None else _json.dumps(self._body).encode() + return httpx.Response(self._status, content=content, request=httpx.Request("POST", url)) + + +async def test_evaluator_posts_tool_call_event_and_maps_allow() -> None: + """ALLOW maps to the ``allow`` verdict; the POST carries a tool-call event.""" + client = _FakeServerClient(body={"result": "POLICY_ACTION_ALLOW"}) + evaluate = _build_opencode_policy_evaluator( + server_client=client, # type: ignore[arg-type] + conversation_id="conv_1", + ) + verdict = await evaluate( + {"action": "bash", "command": "ls", "path": None, "url": None, "metadata": {}} + ) + assert verdict == {"decision": "allow"} + url, body, _timeout = client.calls[0] + assert url == "/v1/sessions/conv_1/policies/evaluate" + event = body["event"] + assert event["type"] == "PHASE_TOOL_CALL" + assert event["data"]["name"] == "bash" + # Only the concrete, present resources reach the policy engine. + assert event["data"]["arguments"] == {"command": "ls"} + assert event["context"]["harness"] == "opencode-native" + + +async def test_evaluator_maps_deny_and_ask() -> None: + """DENY → ``deny``; ASK → ``ask`` (the forwarder fails an unresolved ask closed).""" + for action, decision in (("POLICY_ACTION_DENY", "deny"), ("POLICY_ACTION_ASK", "ask")): + client = _FakeServerClient(body={"result": action}) + evaluate = _build_opencode_policy_evaluator( + server_client=client, # type: ignore[arg-type] + conversation_id="c", + ) + verdict = await evaluate({"action": "edit"}) + assert verdict == {"decision": decision} + + +async def test_evaluator_maps_unknown_verdict_to_ask() -> None: + """An unrecognized verdict fails closed (``ask`` → reject downstream).""" + client = _FakeServerClient(body={"result": "POLICY_ACTION_SOMETHING_NEW"}) + evaluate = _build_opencode_policy_evaluator( + server_client=client, # type: ignore[arg-type] + conversation_id="c", + ) + assert (await evaluate({"action": "bash"})) == {"decision": "ask"} + + +async def test_evaluator_fails_closed_on_transport_error() -> None: + client = _FakeServerClient(raise_exc=httpx.ConnectError("boom")) + evaluate = _build_opencode_policy_evaluator( + server_client=client, # type: ignore[arg-type] + conversation_id="c", + ) + assert (await evaluate({"action": "bash"})) == {"decision": "deny"} + + +async def test_evaluator_fails_closed_on_non_200_or_empty_body() -> None: + for status, body in ((500, {"result": "POLICY_ACTION_ALLOW"}), (200, None)): + client = _FakeServerClient(status=status, body=body) + evaluate = _build_opencode_policy_evaluator( + server_client=client, # type: ignore[arg-type] + conversation_id="c", + ) + assert (await evaluate({"action": "bash"})) == {"decision": "deny"} diff --git a/tests/runner/test_subagent_harness_override.py b/tests/runner/test_subagent_harness_override.py new file mode 100644 index 00000000..aaa3c146 --- /dev/null +++ b/tests/runner/test_subagent_harness_override.py @@ -0,0 +1,54 @@ +"""Tests for the allowlisted ``args.harness`` sub-agent override helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from omnigent.runner.tool_dispatch import ( + _subagent_allowed_harnesses, + _subagent_harness_override_from_args, +) + + +def test_harness_override_absent_returns_none() -> None: + assert _subagent_harness_override_from_args({"args": {"input": "hi"}}) is None + assert _subagent_harness_override_from_args({"args": "plain string"}) is None + + +def test_harness_override_extracted() -> None: + args = {"args": {"input": "hi", "harness": "opencode-native"}} + assert _subagent_harness_override_from_args(args) == "opencode-native" + + +def test_harness_override_non_string_raises() -> None: + with pytest.raises(ValueError, match="harness"): + _subagent_harness_override_from_args({"args": {"input": "hi", "harness": 5}}) + + +def _spec_with(allowed: object) -> SimpleNamespace: + config = {"harness": "codex-native"} + if allowed is not None: + config["allowed_harnesses"] = allowed + sub = SimpleNamespace(name="codex", executor=SimpleNamespace(config=config)) + return SimpleNamespace(sub_agents=[sub]) + + +def test_allowed_harnesses_reads_and_canonicalizes() -> None: + spec = _spec_with(["codex-native", "native-opencode"]) + allowed = _subagent_allowed_harnesses("codex", spec) + # native-opencode canonicalizes to opencode-native. + assert allowed == frozenset({"codex-native", "opencode-native"}) + + +def test_allowed_harnesses_empty_when_undeclared() -> None: + assert _subagent_allowed_harnesses("codex", _spec_with(None)) == frozenset() + + +def test_allowed_harnesses_empty_for_unknown_subagent() -> None: + assert _subagent_allowed_harnesses("nope", _spec_with(["codex-native"])) == frozenset() + + +def test_allowed_harnesses_no_spec() -> None: + assert _subagent_allowed_harnesses("codex", None) == frozenset() diff --git a/tests/test_native_server_harness.py b/tests/test_native_server_harness.py new file mode 100644 index 00000000..465c58c7 --- /dev/null +++ b/tests/test_native_server_harness.py @@ -0,0 +1,175 @@ +"""Unit tests for :class:`omnigent.native_server_harness.NativeServerHarness`. + +Drives the transport-agnostic base directly over an in-memory fake transport, +covering the run-turn / interrupt / enqueue orchestration (boot-poll, model +pinning, and the error branches) independent of any concrete harness. +""" + +from __future__ import annotations + +from typing import Any + +from omnigent.inner.executor import ExecutorConfig, ExecutorError, TurnComplete +from omnigent.native_server_harness import NativeServerHarness +from omnigent.native_server_transport import NativePrompt + + +class _FakeTransport: + """Records ``send_prompt`` / ``abort`` calls; optionally raises.""" + + def __init__(self, *, send_raises: bool = False, abort_raises: bool = False) -> None: + self.prompts: list[tuple[str, NativePrompt]] = [] + self.aborted: list[str] = [] + self._send_raises = send_raises + self._abort_raises = abort_raises + + async def send_prompt(self, session_id: str, prompt: NativePrompt) -> dict[str, Any]: + if self._send_raises: + raise RuntimeError("inject boom") + self.prompts.append((session_id, prompt)) + return {"ok": True} + + async def abort(self, session_id: str) -> bool: + if self._abort_raises: + raise RuntimeError("abort boom") + self.aborted.append(session_id) + return True + + +def _build_prompt(content: Any) -> NativePrompt | None: + return NativePrompt(text=content) if isinstance(content, str) and content else None + + +def _harness( + transport: _FakeTransport, + *, + session_id: str | None = "ses_1", + resolver: Any = None, + supports_enqueue: bool = True, +) -> NativeServerHarness: + resolve = resolver if resolver is not None else _const_resolver(session_id) + return NativeServerHarness( + harness_id="fake-native", + supports_enqueue=supports_enqueue, + transport=transport, # type: ignore[arg-type] + resolve_session_id=resolve, + build_prompt=_build_prompt, + boot_poll_attempts=2, + boot_poll_delay=0.0, + ) + + +def _const_resolver(session_id: str | None) -> Any: + async def _resolve() -> str | None: + return session_id + + return _resolve + + +async def _drive(harness: NativeServerHarness, content: Any = "hello", config: Any = None) -> list: + return [ + e async for e in harness.run_turn([{"role": "user", "content": content}], [], "", config) + ] + + +# ── capabilities ──────────────────────────────────────────────────────────── + + +def test_capabilities() -> None: + harness = _harness(_FakeTransport()) + assert harness.supports_streaming() is False + assert harness.handles_tools_internally() is True + assert harness.supports_live_message_queue() is True + assert ( + _harness(_FakeTransport(), supports_enqueue=False).supports_live_message_queue() is False + ) + + +# ── run_turn ──────────────────────────────────────────────────────────────── + + +async def test_run_turn_injects_and_completes() -> None: + transport = _FakeTransport() + events = await _drive(_harness(transport)) + assert [type(e) for e in events] == [TurnComplete] + assert transport.prompts == [("ses_1", NativePrompt(text="hello"))] + + +async def test_run_turn_pins_config_model_when_prompt_unset() -> None: + transport = _FakeTransport() + await _drive(_harness(transport), config=ExecutorConfig(model="anthropic/claude-opus-4")) + assert transport.prompts[0][1].model == "anthropic/claude-opus-4" + + +async def test_run_turn_no_user_input_errors() -> None: + events = await _drive(_harness(_FakeTransport()), content="") + assert [type(e) for e in events] == [ExecutorError] + assert "no user input" in events[0].message + + +async def test_run_turn_missing_session_errors_after_boot_poll() -> None: + # Resolver always None → boot-poll exhausts → bridge-missing error. + events = await _drive(_harness(_FakeTransport(), resolver=_const_resolver(None))) + assert [type(e) for e in events] == [ExecutorError] + assert "bridge state is missing" in events[0].message + + +async def test_run_turn_boot_poll_recovers_session() -> None: + seq = [None, "ses_late"] + + async def _resolve() -> str | None: + return seq.pop(0) + + transport = _FakeTransport() + events = await _drive(_harness(transport, resolver=_resolve)) + assert [type(e) for e in events] == [TurnComplete] + assert transport.prompts[0][0] == "ses_late" + + +async def test_run_turn_send_failure_becomes_error_event() -> None: + events = await _drive(_harness(_FakeTransport(send_raises=True))) + assert [type(e) for e in events] == [ExecutorError] + assert "executor error" in events[0].message + + +# ── interrupt_session ─────────────────────────────────────────────────────── + + +async def test_interrupt_aborts() -> None: + transport = _FakeTransport() + assert await _harness(transport).interrupt_session("k") is True + assert transport.aborted == ["ses_1"] + + +async def test_interrupt_no_session_returns_false() -> None: + assert await _harness(_FakeTransport(), session_id=None).interrupt_session("k") is False + + +async def test_interrupt_swallows_abort_error() -> None: + assert await _harness(_FakeTransport(abort_raises=True)).interrupt_session("k") is False + + +# ── enqueue_session_message ───────────────────────────────────────────────── + + +async def test_enqueue_injects_prompt() -> None: + transport = _FakeTransport() + assert await _harness(transport).enqueue_session_message("k", "steer") is True + assert transport.prompts == [("ses_1", NativePrompt(text="steer"))] + + +async def test_enqueue_empty_content_returns_false() -> None: + assert await _harness(_FakeTransport()).enqueue_session_message("k", "") is False + + +async def test_enqueue_no_session_returns_false() -> None: + assert ( + await _harness(_FakeTransport(), session_id=None).enqueue_session_message("k", "x") + is False + ) + + +async def test_enqueue_swallows_send_error() -> None: + assert ( + await _harness(_FakeTransport(send_raises=True)).enqueue_session_message("k", "x") is False + ) diff --git a/tests/test_opencode_http_transport.py b/tests/test_opencode_http_transport.py new file mode 100644 index 00000000..438bd34e --- /dev/null +++ b/tests/test_opencode_http_transport.py @@ -0,0 +1,207 @@ +"""Unit tests for :class:`omnigent.opencode_http_transport.OpenCodeHttpTransport`. + +Covers the payload builder + every transport method over an injected fake +``OpenCodeClient`` (the documented ``client_factory`` test seam), so the +opencode-native HTTP/SSE wire surface stays covered without a live server. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from omnigent.native_server_transport import ( + NativeLaunchConfig, + NativePermissionDecision, + NativePrompt, +) +from omnigent.opencode_http_transport import OpenCodeHttpTransport, build_prompt_payload + +# ── build_prompt_payload + part/model helpers ────────────────────────────── + + +def test_build_prompt_payload_text_only() -> None: + assert build_prompt_payload(NativePrompt(text="hi")) == { + "parts": [{"type": "text", "text": "hi"}] + } + + +def test_build_prompt_payload_system_and_model_split() -> None: + body = build_prompt_payload( + NativePrompt(text="hi", system_prompt="be brief", model="anthropic/claude-opus-4") + ) + assert body["system"] == "be brief" + assert body["model"] == {"providerID": "anthropic", "modelID": "claude-opus-4"} + + +def test_build_prompt_payload_bare_model_id_is_dropped() -> None: + # No ``provider/model`` slash → not a valid opencode model object → omitted. + assert "model" not in build_prompt_payload(NativePrompt(text="hi", model="just-a-name")) + + +def test_build_prompt_payload_image_and_file_attachments() -> None: + prompt = NativePrompt( + text="look", + attachments=( + {"type": "input_image", "image_url": "data:image/png;base64,AAAA"}, + { + "type": "input_file", + "file_data": "data:application/pdf;base64,BBBB", + "filename": "a.pdf", + }, + {"type": "input_file", "url": "data:text/plain;base64,CCCC"}, + {"type": "input_image"}, # no url → skipped + ), + ) + parts = build_prompt_payload(prompt)["parts"] + assert {"type": "file", "mime": "image/png", "url": "data:image/png;base64,AAAA"} in parts + pdf = next(p for p in parts if p.get("filename") == "a.pdf") + assert pdf["mime"] == "application/pdf" + # The url-only file part falls back to its data-URI mime; the empty image is dropped. + assert any(p.get("mime") == "text/plain" for p in parts) + assert sum(1 for p in parts if p["type"] == "file") == 3 + + +# ── transport methods over a fake client ──────────────────────────────────── + + +class _FakeClient: + """Records protocol calls; returns canned results for the transport.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + self.closed = False + self.existing: SimpleNamespace | None = None + + async def get_session(self, session_id: str) -> SimpleNamespace | None: + self.calls.append(("get_session", session_id)) + return self.existing + + async def create_session(self, payload: Any = None) -> SimpleNamespace: + self.calls.append(("create_session", payload)) + return SimpleNamespace(id="ses_new") + + async def prompt_async(self, session_id: str, payload: Any) -> dict[str, Any]: + self.calls.append(("prompt_async", (session_id, payload))) + return {"ok": True} + + async def abort(self, session_id: str) -> bool: + self.calls.append(("abort", session_id)) + return True + + async def list_messages(self, session_id: str) -> list[dict[str, Any]]: + self.calls.append(("list_messages", session_id)) + return [{"info": {"id": "msg_1"}}] + + async def fork(self, session_id: str, payload: Any = None) -> SimpleNamespace: + self.calls.append(("fork", (session_id, payload))) + return SimpleNamespace(id="ses_fork") + + async def reply_permission(self, request_id: str, reply: Any) -> bool: + self.calls.append(("reply_permission", (request_id, reply))) + return True + + async def events(self) -> Any: + self.calls.append(("events", None)) + yield SimpleNamespace( + id="evt_1", type="message.updated", properties={"k": "v"}, raw={"r": 1} + ) + + async def aclose(self) -> None: + self.closed = True + + +def _transport(client: _FakeClient) -> OpenCodeHttpTransport: + return OpenCodeHttpTransport(client_factory=lambda: client) + + +def _launch(**kwargs: Any) -> NativeLaunchConfig: + return NativeLaunchConfig(omnigent_session_id="conv_1", workspace="/w", **kwargs) + + +async def test_create_session_when_no_external_id() -> None: + client = _FakeClient() + sid = await _transport(client).create_or_resume_session(_launch()) + assert sid == "ses_new" + assert client.closed + + +async def test_resume_returns_existing_session() -> None: + client = _FakeClient() + client.existing = SimpleNamespace(id="ses_old") + sid = await _transport(client).create_or_resume_session(_launch(external_session_id="ses_old")) + assert sid == "ses_old" + + +async def test_resume_falls_back_to_create_when_session_gone() -> None: + client = _FakeClient() # existing is None + sid = await _transport(client).create_or_resume_session(_launch(external_session_id="gone")) + assert sid == "ses_new" + + +async def test_send_prompt_builds_payload_and_closes() -> None: + client = _FakeClient() + out = await _transport(client).send_prompt("ses_1", NativePrompt(text="hi")) + assert out == {"ok": True} + assert ("prompt_async", ("ses_1", {"parts": [{"type": "text", "text": "hi"}]})) in client.calls + assert client.closed + + +async def test_abort() -> None: + client = _FakeClient() + assert await _transport(client).abort("ses_1") is True + + +async def test_events_maps_to_native_event() -> None: + client = _FakeClient() + events = [event async for event in _transport(client).events("ses_1")] + assert len(events) == 1 + assert (events[0].id, events[0].type, events[0].payload) == ( + "evt_1", + "message.updated", + {"k": "v"}, + ) + assert client.closed + + +async def test_list_history() -> None: + client = _FakeClient() + assert await _transport(client).list_history("ses_1") == [{"info": {"id": "msg_1"}}] + + +async def test_fork_with_and_without_message_id() -> None: + client = _FakeClient() + transport = _transport(client) + assert await transport.fork("ses_1") == "ses_fork" + assert await transport.fork("ses_1", at_message_id="msg_9") == "ses_fork" + assert ("fork", ("ses_1", {"messageID": "msg_9"})) in client.calls + assert ("fork", ("ses_1", None)) in client.calls + + +async def test_reply_permission_maps_decision() -> None: + client = _FakeClient() + await _transport(client).reply_permission( + NativePermissionDecision(request_id="per_1", decision="allow_always", message="ok") + ) + assert ("reply_permission", ("per_1", {"reply": "always", "message": "ok"})) in client.calls + + +def test_build_tui_attach_command_uses_launch_server_url() -> None: + transport = OpenCodeHttpTransport(client_factory=lambda: _FakeClient()) + argv, env = transport.build_tui_attach_command( + _launch(server_url="http://127.0.0.1:1234", terminal_launch_args=("--foo",)), + "ses_1", + ) + assert argv[0] == "attach" + assert "http://127.0.0.1:1234" in argv + assert "ses_1" in argv + assert "--foo" in argv + assert env == {} # no server handle → empty terminal env + + +async def test_no_connection_coordinates_raises() -> None: + # No factory / server / bridge_dir → the client builder fails loud. + with pytest.raises(RuntimeError): + await OpenCodeHttpTransport().abort("ses_1") diff --git a/tests/test_opencode_native.py b/tests/test_opencode_native.py new file mode 100644 index 00000000..642864db --- /dev/null +++ b/tests/test_opencode_native.py @@ -0,0 +1,278 @@ +"""Unit tests for the ``omni opencode`` launcher helpers (``opencode_native.py``). + +Covers the pure spec/payload/tmux helpers plus the httpx-backed session and +terminal helpers over a fake ``AsyncClient`` — the daemon/tmux attach plumbing +itself stays for the live host e2e. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import click +import httpx +import pytest +import yaml + +from omnigent.opencode_native import ( + LaunchedOpenCodeTerminal, + PreparedOpenCodeTerminal, + _create_opencode_session, + _direct_tmux_unavailable_reason, + _ensure_opencode_terminal_on_runner, + _fetch_opencode_session, + _find_running_opencode_terminal, + _launched_opencode_terminal_from_payload, + _materialize_opencode_agent_spec, + _resolve_session_id_for_resume, + opencode_terminal_resource_id, +) + + +class _FakeClient: + """Async httpx stand-in returning one preset response per call.""" + + def __init__(self, response: httpx.Response) -> None: + self._response = response + self.requests: list[tuple[str, str, dict[str, Any]]] = [] + + async def post(self, url: str, **kwargs: Any) -> httpx.Response: + self.requests.append(("POST", url, kwargs)) + return self._response + + async def get(self, url: str, **kwargs: Any) -> httpx.Response: + self.requests.append(("GET", url, kwargs)) + return self._response + + +# ── _materialize_opencode_agent_spec ──────────────────────────────────────── + + +def test_materialize_spec_defaults_no_model(tmp_path: Path) -> None: + spec = yaml.safe_load(_materialize_opencode_agent_spec(tmp_path).read_text()) + assert spec["executor"] == {"harness": "opencode-native"} + assert spec["spawn"] is True + assert "shell" in spec["terminals"] + + +def test_materialize_spec_pins_model(tmp_path: Path) -> None: + spec = yaml.safe_load( + _materialize_opencode_agent_spec(tmp_path, model="anthropic/claude-opus-4").read_text() + ) + assert spec["executor"] == {"harness": "opencode-native", "model": "anthropic/claude-opus-4"} + + +def test_terminal_resource_id_is_deterministic() -> None: + assert opencode_terminal_resource_id() == opencode_terminal_resource_id() + + +# ── _launched_opencode_terminal_from_payload ──────────────────────────────── + + +def test_launched_terminal_parses_tmux_metadata() -> None: + launched = _launched_opencode_terminal_from_payload( + {"id": "term_1", "metadata": {"tmux_socket": "/tmp/s.sock", "tmux_target": "sess:0.0"}} + ) + assert launched.terminal_id == "term_1" + assert launched.tmux_socket == Path("/tmp/s.sock") + assert launched.tmux_target == "sess:0.0" + + +def test_launched_terminal_without_metadata_has_no_tmux() -> None: + launched = _launched_opencode_terminal_from_payload({"id": "term_1"}) + assert launched.tmux_socket is None and launched.tmux_target is None + + +def test_launched_terminal_missing_id_raises() -> None: + with pytest.raises(click.ClickException): + _launched_opencode_terminal_from_payload({"metadata": {}}) + with pytest.raises(click.ClickException): + _launched_opencode_terminal_from_payload("not-a-dict") + + +# ── _direct_tmux_unavailable_reason ───────────────────────────────────────── + + +def _prepared(socket: Path | None, target: str | None) -> PreparedOpenCodeTerminal: + return PreparedOpenCodeTerminal( + session_id="conv_1", + terminal_id="term_1", + tmux_socket=socket, + tmux_target=target, + reattached=False, + ) + + +def test_tmux_reason_missing_socket() -> None: + assert "tmux socket" in (_direct_tmux_unavailable_reason(_prepared(None, "t")) or "") + + +def test_tmux_reason_missing_target() -> None: + assert "tmux target" in (_direct_tmux_unavailable_reason(_prepared(Path("/x"), None)) or "") + + +def test_tmux_reason_socket_not_reachable(tmp_path: Path) -> None: + reason = _direct_tmux_unavailable_reason(_prepared(tmp_path / "missing.sock", "t")) + assert reason is not None and "not reachable" in reason + + +# ── _resolve_session_id_for_resume ────────────────────────────────────────── + + +def test_resolve_session_id_passthrough() -> None: + assert ( + _resolve_session_id_for_resume( + base_url="http://x", headers={}, session_id="conv_9", resume_picker=False + ) + == "conv_9" + ) + + +def test_resolve_session_id_none_without_picker() -> None: + assert ( + _resolve_session_id_for_resume( + base_url="http://x", headers={}, session_id=None, resume_picker=False + ) + is None + ) + + +# ── httpx-backed session/terminal helpers ─────────────────────────────────── + + +async def test_create_session_returns_id() -> None: + client = _FakeClient(httpx.Response(200, json={"session_id": "conv_new"})) + sid = await _create_opencode_session(client, b"bundle", terminal_launch_args=["--foo"]) # type: ignore[arg-type] + assert sid == "conv_new" + assert client.requests[0][1] == "/v1/sessions" + + +async def test_create_session_errors_on_http_failure() -> None: + client = _FakeClient(httpx.Response(500, json={"error": "boom"})) + with pytest.raises(click.ClickException): + await _create_opencode_session(client, b"bundle") # type: ignore[arg-type] + + +async def test_create_session_errors_without_session_id() -> None: + client = _FakeClient(httpx.Response(200, json={})) + with pytest.raises(click.ClickException): + await _create_opencode_session(client, b"bundle") # type: ignore[arg-type] + + +async def test_fetch_session_returns_payload() -> None: + client = _FakeClient(httpx.Response(200, json={"id": "conv_1", "title": "t"})) + assert (await _fetch_opencode_session(client, "conv_1"))["id"] == "conv_1" # type: ignore[arg-type] + + +async def test_fetch_session_404_raises() -> None: + client = _FakeClient(httpx.Response(404, json={"error": "nope"})) + with pytest.raises(click.ClickException): + await _fetch_opencode_session(client, "conv_1") # type: ignore[arg-type] + + +async def test_ensure_terminal_ok_then_error() -> None: + ok = _FakeClient(httpx.Response(200, json={})) + await _ensure_opencode_terminal_on_runner(ok, "conv_1") # type: ignore[arg-type] + assert ok.requests[0][0] == "POST" + bad = _FakeClient(httpx.Response(503, json={"error": "x"})) + with pytest.raises(click.ClickException): + await _ensure_opencode_terminal_on_runner(bad, "conv_1") # type: ignore[arg-type] + + +async def test_find_terminal_404_returns_none() -> None: + client = _FakeClient(httpx.Response(404, json={})) + assert await _find_running_opencode_terminal(client, "conv_1") is None # type: ignore[arg-type] + + +async def test_find_terminal_not_running_returns_none() -> None: + client = _FakeClient( + httpx.Response(200, json={"id": "term_1", "metadata": {"running": False}}) + ) + assert await _find_running_opencode_terminal(client, "conv_1") is None # type: ignore[arg-type] + + +async def test_find_terminal_returns_launched() -> None: + client = _FakeClient( + httpx.Response(200, json={"id": "term_1", "metadata": {"tmux_target": "s:0.0"}}) + ) + launched = await _find_running_opencode_terminal(client, "conv_1") # type: ignore[arg-type] + assert isinstance(launched, LaunchedOpenCodeTerminal) + assert launched.tmux_target == "s:0.0" + + +async def test_find_terminal_offline_runner_returns_none() -> None: + client = _FakeClient(httpx.Response(409, text="session not bound to a runner")) + assert await _find_running_opencode_terminal(client, "conv_1") is None # type: ignore[arg-type] + + +# ── launcher local-preflight / progress / tmux-reason / wait helpers ───────── + + +def test_preflight_local_tools_ok(monkeypatch: pytest.MonkeyPatch) -> None: + import omnigent.opencode_native as on + + monkeypatch.setattr(on.shutil, "which", lambda _x: "/usr/bin/tmux") + on._preflight_local_tools() # tmux present → no raise + + +def test_preflight_local_tools_missing_tmux_raises(monkeypatch: pytest.MonkeyPatch) -> None: + import omnigent.opencode_native as on + + monkeypatch.setattr(on.shutil, "which", lambda _x: None) + with pytest.raises(click.ClickException): + on._preflight_local_tools() + + +def test_update_startup_progress_handles_none_and_active() -> None: + from unittest.mock import Mock + + from omnigent.opencode_native import _update_startup_progress + + _update_startup_progress(None, "boot") # no renderer → no-op branch + _update_startup_progress(Mock(), "boot") # active renderer → update branch + + +def test_tmux_reason_tmux_not_on_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + import omnigent.opencode_native as on + + sock = tmp_path / "s.sock" + sock.write_text("") + monkeypatch.setattr(on.shutil, "which", lambda _x: None) + reason = on._direct_tmux_unavailable_reason(_prepared(sock, "t")) + assert reason is not None and "tmux is not available" in reason + + +def test_tmux_reason_none_when_socket_and_tmux_present( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + import omnigent.opencode_native as on + + sock = tmp_path / "s.sock" + sock.write_text("") + monkeypatch.setattr(on.shutil, "which", lambda _x: "/usr/bin/tmux") + assert on._direct_tmux_unavailable_reason(_prepared(sock, "t")) is None + + +async def test_wait_for_terminal_returns_when_found(monkeypatch: pytest.MonkeyPatch) -> None: + import omnigent.opencode_native as on + + term = on.LaunchedOpenCodeTerminal(terminal_id="t", tmux_socket=None, tmux_target=None) + + async def _fake_find(_client: object, _sid: str) -> on.LaunchedOpenCodeTerminal: + return term + + monkeypatch.setattr(on, "_find_running_opencode_terminal", _fake_find) + got = await on._wait_for_opencode_terminal_ready(object(), "conv_1", timeout_s=5) # type: ignore[arg-type] + assert got is term + + +async def test_wait_for_terminal_times_out(monkeypatch: pytest.MonkeyPatch) -> None: + import omnigent.opencode_native as on + + async def _never(_client: object, _sid: str) -> None: + return None + + monkeypatch.setattr(on, "_find_running_opencode_terminal", _never) + with pytest.raises(click.ClickException): + await on._wait_for_opencode_terminal_ready(object(), "conv_1", timeout_s=0) # type: ignore[arg-type] diff --git a/tests/test_opencode_native_app_server.py b/tests/test_opencode_native_app_server.py new file mode 100644 index 00000000..d155743f --- /dev/null +++ b/tests/test_opencode_native_app_server.py @@ -0,0 +1,221 @@ +"""Tests for the opencode serve process manager + arg/env builders.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from omnigent import opencode_native_app_server as appsrv +from omnigent.opencode_native_app_server import ( + OpenCodeCliNotFoundError, + OpenCodeNativeServer, + OpenCodeVersionError, + build_opencode_attach_args, + build_opencode_serve_args, + check_opencode_version, + filtered_server_env, + find_opencode_cli, + opencode_terminal_env, + parse_opencode_version, +) + + +def test_parse_opencode_version() -> None: + assert parse_opencode_version("opencode 1.17.7") == "1.17.7" + assert parse_opencode_version("1.17.7") == "1.17.7" + assert parse_opencode_version("v1.17.7-beta.1") == "1.17.7-beta.1" + assert parse_opencode_version("no version here") is None + + +def test_check_version_in_range() -> None: + check_opencode_version("1.17.7") + check_opencode_version("1.17.99") + + +@pytest.mark.parametrize("version", ["1.16.0", "1.18.0", "2.0.0"]) +def test_check_version_out_of_range_raises(version: str) -> None: + with pytest.raises(OpenCodeVersionError): + check_opencode_version(version) + + +def test_check_version_unparsable_raises() -> None: + with pytest.raises(OpenCodeVersionError): + check_opencode_version("not-a-version") + + +def test_find_opencode_cli_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(appsrv.shutil, "which", lambda _name: None) + with pytest.raises(OpenCodeCliNotFoundError): + find_opencode_cli() + + +def test_find_opencode_cli_resolved(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(appsrv.shutil, "which", lambda name: f"/usr/bin/{name}") + assert find_opencode_cli() == "/usr/bin/opencode" + + +def test_build_serve_args_has_explicit_host_port() -> None: + args = build_opencode_serve_args(hostname="127.0.0.1", port=49231) + assert args == ["serve", "--hostname", "127.0.0.1", "--port", "49231"] + + +def test_build_attach_args() -> None: + args = build_opencode_attach_args( + server_url="http://127.0.0.1:49231", + workspace="/repo", + session_id="ses_1", + ) + assert args == [ + "attach", + "http://127.0.0.1:49231", + "--dir", + "/repo", + "--session", + "ses_1", + ] + + +def test_build_attach_args_without_session() -> None: + args = build_opencode_attach_args( + server_url="http://127.0.0.1:49231", + workspace="/repo", + session_id=None, + opencode_args=("--extra",), + ) + assert "--session" not in args + assert args[-1] == "--extra" + + +def test_filtered_server_env_sets_xdg_and_password( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "secret-key") + monkeypatch.setenv("RANDOM_UNRELATED", "nope") + env = filtered_server_env(bridge_dir=tmp_path, auth_secret="pw") + assert env["XDG_DATA_HOME"] == str(tmp_path / "xdg-data") + assert env["XDG_CONFIG_HOME"] == str(tmp_path / "xdg-config") + assert env["OPENCODE_SERVER_PASSWORD"] == "pw" + assert env["OPENCODE_SERVER_USERNAME"] == "opencode" + assert env["ANTHROPIC_API_KEY"] == "secret-key" # provider env passes through + assert "RANDOM_UNRELATED" not in env # unrelated env filtered out + + +def test_filtered_server_env_drops_global_opencode_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Global OpenCode config env never leaks into the isolated session. + + ``OPENCODE_CONFIG`` / ``OPENCODE_CONFIG_CONTENT`` would re-introduce the + parent shell's config/model/permission settings, defeating the + per-session XDG isolation — so they are dropped even though they match + the ``OPENCODE_`` passthrough prefix. Other ``OPENCODE_`` vars (and the + server password we set) are unaffected. + """ + monkeypatch.setenv("OPENCODE_CONFIG", "/home/user/.config/opencode/opencode.json") + monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", '{"model": "evil/model"}') + monkeypatch.setenv("OPENCODE_DISABLE_AUTOUPDATE", "1") + env = filtered_server_env(bridge_dir=tmp_path, auth_secret="pw") + assert "OPENCODE_CONFIG" not in env + assert "OPENCODE_CONFIG_CONTENT" not in env + # An unrelated OPENCODE_ var is still passed through (not config leakage). + assert env["OPENCODE_DISABLE_AUTOUPDATE"] == "1" + # The per-session XDG dirs remain the only config source. + assert env["XDG_CONFIG_HOME"] == str(tmp_path / "xdg-config") + + +def _server(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> OpenCodeNativeServer: + monkeypatch.setattr(appsrv.shutil, "which", lambda name: f"/usr/bin/{name}") + return OpenCodeNativeServer( + bridge_dir=tmp_path, + workspace=tmp_path, + port=49231, + verify_version=False, + ) + + +def test_build_argv(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + server = _server(monkeypatch, tmp_path) + server.port = 49231 + argv = server.build_argv() + assert argv[0] == "/usr/bin/opencode" + assert argv[1:] == ["serve", "--hostname", "127.0.0.1", "--port", "49231"] + + +def test_base_url_and_auth_headers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + server = _server(monkeypatch, tmp_path) + assert server.base_url == "http://127.0.0.1:49231" + assert server.auth_headers["Authorization"].startswith("Basic ") + + +def test_terminal_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + server = _server(monkeypatch, tmp_path) + env = opencode_terminal_env(server) + assert env["OPENCODE_SERVER_PASSWORD"] == server.auth_secret + assert env["XDG_DATA_HOME"] == str(server.xdg_data_home) + + +async def test_start_polls_until_ready(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + server = _server(monkeypatch, tmp_path) + started: dict[str, object] = {} + + class _FakeProc: + pid = 4242 + + def poll(self) -> None: + return None + + def fake_popen(argv, **kwargs): # type: ignore[no-untyped-def] + started["argv"] = argv + started["env"] = kwargs.get("env") + return _FakeProc() + + async def fake_wait(self: OpenCodeNativeServer) -> None: + started["ready"] = True + + monkeypatch.setattr(appsrv.subprocess, "Popen", fake_popen) + monkeypatch.setattr(OpenCodeNativeServer, "_wait_until_ready", fake_wait) + await server.start() + assert started["ready"] is True + assert started["argv"][1] == "serve" + assert server.process is not None + assert server.process.pid == 4242 + + +def test_find_opencode_cli_absolute_executable(tmp_path: Path) -> None: + exe = tmp_path / "opencode" + exe.write_text("#!/bin/sh\n") + exe.chmod(0o755) + assert appsrv.find_opencode_cli(str(exe)) == str(exe) + + +def test_resolve_opencode_version_parses(monkeypatch: pytest.MonkeyPatch) -> None: + import subprocess + + monkeypatch.setattr( + appsrv.subprocess, + "run", + lambda *a, **k: subprocess.CompletedProcess(a, 0, stdout="opencode 1.17.7\n", stderr=""), + ) + assert appsrv.resolve_opencode_version("/x/opencode") == "1.17.7" + + +def test_resolve_opencode_version_run_error_raises(monkeypatch: pytest.MonkeyPatch) -> None: + def _boom(*_a: object, **_k: object) -> object: + raise OSError("cannot exec") + + monkeypatch.setattr(appsrv.subprocess, "run", _boom) + with pytest.raises(appsrv.OpenCodeVersionError): + appsrv.resolve_opencode_version("/x/opencode") + + +def test_resolve_opencode_version_unparseable_raises(monkeypatch: pytest.MonkeyPatch) -> None: + import subprocess + + monkeypatch.setattr( + appsrv.subprocess, + "run", + lambda *a, **k: subprocess.CompletedProcess(a, 0, stdout="no version here", stderr=""), + ) + with pytest.raises(appsrv.OpenCodeVersionError): + appsrv.resolve_opencode_version("/x/opencode") diff --git a/tests/test_opencode_native_bridge.py b/tests/test_opencode_native_bridge.py new file mode 100644 index 00000000..a1ab1079 --- /dev/null +++ b/tests/test_opencode_native_bridge.py @@ -0,0 +1,174 @@ +"""Tests for the native OpenCode bridge state helpers.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from omnigent import opencode_native_bridge as bridge +from omnigent.opencode_native_bridge import ( + OpenCodeNativeBridgeState, + auth_headers_for_secret, + bridge_dir_for_bridge_id, + build_opencode_native_spawn_env, + clear_bridge_state, + ensure_auth_secret, + prepare_bridge_dir, + read_bridge_state, + update_active_message_id, + update_last_event_id, + write_bridge_state, + xdg_config_home_for_bridge_dir, + xdg_data_home_for_bridge_dir, +) + + +@pytest.fixture +def bridge_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Isolated bridge directory rooted under a tmp path.""" + monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native") + return prepare_bridge_dir("bridge_test") + + +def _state(bridge_dir: Path, **overrides: object) -> OpenCodeNativeBridgeState: + base = { + "session_id": "conv_abc", + "server_base_url": "http://127.0.0.1:49231", + "opencode_session_id": "ses_abc", + "auth_secret": "s3cret", + "xdg_data_home": str(xdg_data_home_for_bridge_dir(bridge_dir)), + "xdg_config_home": str(xdg_config_home_for_bridge_dir(bridge_dir)), + } + base.update(overrides) + return OpenCodeNativeBridgeState(**base) # type: ignore[arg-type] + + +def test_prepare_bridge_dir_creates_xdg_roots(bridge_dir: Path) -> None: + assert bridge_dir.is_dir() + assert xdg_data_home_for_bridge_dir(bridge_dir).is_dir() + assert xdg_config_home_for_bridge_dir(bridge_dir).is_dir() + # 0700 perms on the bridge dir. + assert (os.stat(bridge_dir).st_mode & 0o777) == 0o700 + + +def test_write_read_state_round_trips(bridge_dir: Path) -> None: + write_bridge_state(bridge_dir, _state(bridge_dir, model_override="anthropic/claude-opus-4")) + loaded = read_bridge_state(bridge_dir) + assert loaded is not None + assert loaded.session_id == "conv_abc" + assert loaded.opencode_session_id == "ses_abc" + assert loaded.server_base_url == "http://127.0.0.1:49231" + assert loaded.auth_secret == "s3cret" + assert loaded.model_override == "anthropic/claude-opus-4" + assert loaded.status == "idle" + + +def test_read_missing_state_is_none(bridge_dir: Path) -> None: + assert read_bridge_state(bridge_dir) is None + + +def test_read_corrupt_state_is_none(bridge_dir: Path) -> None: + (bridge_dir / "state.json").write_text("{not json", encoding="utf-8") + assert read_bridge_state(bridge_dir) is None + + +def test_read_incomplete_state_is_none(bridge_dir: Path) -> None: + (bridge_dir / "state.json").write_text(json.dumps({"session_id": "x"}), encoding="utf-8") + assert read_bridge_state(bridge_dir) is None + + +def test_clear_state_removes_file(bridge_dir: Path) -> None: + write_bridge_state(bridge_dir, _state(bridge_dir)) + clear_bridge_state(bridge_dir) + assert read_bridge_state(bridge_dir) is None + # Idempotent. + clear_bridge_state(bridge_dir) + + +def test_update_active_message_id(bridge_dir: Path) -> None: + write_bridge_state(bridge_dir, _state(bridge_dir)) + update_active_message_id(bridge_dir, "msg_1", status="busy") + loaded = read_bridge_state(bridge_dir) + assert loaded is not None + assert loaded.active_message_id == "msg_1" + assert loaded.status == "busy" + update_active_message_id(bridge_dir, None, status="idle") + loaded = read_bridge_state(bridge_dir) + assert loaded is not None + assert loaded.active_message_id is None + assert loaded.status == "idle" + + +def test_update_last_event_id(bridge_dir: Path) -> None: + write_bridge_state(bridge_dir, _state(bridge_dir)) + update_last_event_id(bridge_dir, "evt_42") + loaded = read_bridge_state(bridge_dir) + assert loaded is not None + assert loaded.last_event_id == "evt_42" + + +def test_ensure_auth_secret_is_stable_and_0600(bridge_dir: Path) -> None: + secret = ensure_auth_secret(bridge_dir) + assert secret + # Same secret on a second call (reused across server restarts). + assert ensure_auth_secret(bridge_dir) == secret + path = bridge_dir / "auth.secret" + assert (os.stat(path).st_mode & 0o777) == 0o600 + + +def test_auth_headers_for_secret() -> None: + assert auth_headers_for_secret(None) == {} + headers = auth_headers_for_secret("pw") + assert headers["Authorization"].startswith("Basic ") + import base64 + + decoded = base64.b64decode(headers["Authorization"].split(" ", 1)[1]).decode() + assert decoded == "opencode:pw" + + +def test_state_auth_headers_method(bridge_dir: Path) -> None: + state = _state(bridge_dir, auth_secret="pw") + assert state.auth_headers()["Authorization"].startswith("Basic ") + + +def test_spawn_env_points_at_bridge_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native") + env = build_opencode_native_spawn_env("conv_abc") + assert env["HARNESS_OPENCODE_NATIVE_BRIDGE_DIR"] == str(bridge_dir_for_bridge_id("conv_abc")) + assert env["HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID"] == "conv_abc" + + +def test_spawn_env_bridge_id_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(bridge, "_BRIDGE_ROOT", tmp_path / "opencode-native") + env = build_opencode_native_spawn_env("conv_abc", bridge_id="bridge_xyz") + assert env["HARNESS_OPENCODE_NATIVE_BRIDGE_DIR"] == str(bridge_dir_for_bridge_id("bridge_xyz")) + assert env["HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID"] == "conv_abc" + + +def test_seed_opencode_auth_copies_user_auth( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The user's auth.json is copied into the per-session XDG_DATA_HOME (0600).""" + user_data = tmp_path / "user-share" + (user_data / "opencode").mkdir(parents=True) + (user_data / "opencode" / "auth.json").write_text('{"anthropic": {"type": "api"}}') + monkeypatch.setenv("XDG_DATA_HOME", str(user_data)) + + bridge_dir = bridge.prepare_bridge_dir("conv_seed") + dest = bridge.seed_opencode_auth(bridge_dir) + assert dest is not None and dest.is_file() + assert dest == bridge.xdg_data_home_for_bridge_dir(bridge_dir) / "opencode" / "auth.json" + assert "anthropic" in dest.read_text() + assert (os.stat(dest).st_mode & 0o777) == 0o600 + + +def test_seed_opencode_auth_noop_without_source( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """No user auth.json → no-op (returns None), e.g. on a remote runner.""" + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "empty-share")) + bridge_dir = bridge.prepare_bridge_dir("conv_noseed") + assert bridge.seed_opencode_auth(bridge_dir) is None diff --git a/tests/test_opencode_native_client.py b/tests/test_opencode_native_client.py new file mode 100644 index 00000000..f148b32b --- /dev/null +++ b/tests/test_opencode_native_client.py @@ -0,0 +1,249 @@ +"""Tests for the OpenCode HTTP + SSE client against a fake server.""" + +from __future__ import annotations + +import json +from collections.abc import Callable + +import httpx +import pytest + +from omnigent.opencode_native_client import ( + OpenCodeClient, + OpenCodeClientError, + OpenCodeEvent, + OpenCodeSession, +) + +Handler = Callable[[httpx.Request], httpx.Response] + + +def _client(handler: Handler, **kwargs: object) -> OpenCodeClient: + mock = httpx.AsyncClient( + base_url="http://opencode.test", + transport=httpx.MockTransport(handler), + ) + return OpenCodeClient("http://opencode.test", client=mock, **kwargs) # type: ignore[arg-type] + + +async def test_create_session() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/session" + return httpx.Response(200, json={"id": "ses_1", "title": "t"}) + + client = _client(handler) + session = await client.create_session({"title": "t"}) + assert isinstance(session, OpenCodeSession) + assert session.id == "ses_1" + assert session.title == "t" + await client.aclose() + + +async def test_get_session_404_returns_none() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(404, json={"error": "not found"}) + + client = _client(handler) + assert await client.get_session("ses_missing") is None + await client.aclose() + + +async def test_get_session_found() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": "ses_1", "parentID": "ses_0"}) + + client = _client(handler) + session = await client.get_session("ses_1") + assert session is not None + assert session.parent_id == "ses_0" + await client.aclose() + + +async def test_list_messages() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[{"info": {"id": "msg_1"}, "parts": []}]) + + client = _client(handler) + messages = await client.list_messages("ses_1") + assert messages == [{"info": {"id": "msg_1"}, "parts": []}] + await client.aclose() + + +async def test_prompt_async_posts_parts() -> None: + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/session/ses_1/prompt_async" + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={}) + + client = _client(handler) + await client.prompt_async("ses_1", {"parts": [{"type": "text", "text": "hi"}]}) + assert captured["body"] == {"parts": [{"type": "text", "text": "hi"}]} + await client.aclose() + + +async def test_abort_returns_bool() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/session/ses_1/abort" + return httpx.Response(200, json=True) + + client = _client(handler) + assert await client.abort("ses_1") is True + await client.aclose() + + +async def test_fork() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/session/ses_1/fork" + return httpx.Response(200, json={"id": "ses_2", "parentID": "ses_1"}) + + client = _client(handler) + forked = await client.fork("ses_1", {"messageID": "msg_1"}) + assert forked.id == "ses_2" + await client.aclose() + + +async def test_reply_permission() -> None: + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/permission/per_1/reply" + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={}) + + client = _client(handler) + assert await client.reply_permission("per_1", {"reply": "once"}) is True + assert captured["body"] == {"reply": "once"} + await client.aclose() + + +async def test_list_permissions() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[{"id": "per_1", "action": "bash"}]) + + client = _client(handler) + perms = await client.list_permissions() + assert perms == [{"id": "per_1", "action": "bash"}] + await client.aclose() + + +async def test_error_raises() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"error": "boom"}) + + client = _client(handler) + with pytest.raises(OpenCodeClientError): + await client.create_session() + await client.aclose() + + +async def test_auth_and_directory_headers_applied() -> None: + captured: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["auth"] = request.headers.get("authorization", "") + captured["dir"] = request.headers.get("x-opencode-directory", "") + return httpx.Response(200, json=[]) + + client = _client(handler, headers={"Authorization": "Basic abc"}, directory="/repo") + await client.list_messages("ses_1") + assert captured["auth"] == "Basic abc" + assert captured["dir"] == "/repo" + await client.aclose() + + +async def test_events_parses_sse_stream() -> None: + sse_body = ( + "event: message\n" + 'data: {"type": "session.next.text.delta", ' + '"properties": {"sessionID": "ses_1", "delta": "hel"}}\n' + "\n" + "id: evt_2\n" + 'data: {"type": "session.next.text.ended", ' + '"properties": {"sessionID": "ses_1", "text": "hello"}}\n' + "\n" + ": heartbeat comment\n" + "\n" + ) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/event" + return httpx.Response(200, text=sse_body, headers={"content-type": "text/event-stream"}) + + client = _client(handler) + events: list[OpenCodeEvent] = [] + async for event in client.events(): + events.append(event) + assert [e.type for e in events] == [ + "session.next.text.delta", + "session.next.text.ended", + ] + assert events[0].properties["delta"] == "hel" + assert events[1].id == "evt_2" + await client.aclose() + + +async def test_events_skips_non_json_data() -> None: + sse_body = 'data: not-json\n\ndata: {"type": "x", "properties": {}}\n\n' + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=sse_body) + + client = _client(handler) + events = [e async for e in client.events()] + assert [e.type for e in events] == ["x"] + await client.aclose() + + +async def test_create_session_non_object_body_raises() -> None: + client = _client(lambda _r: httpx.Response(200, json=["not", "an", "object"])) + with pytest.raises(OpenCodeClientError): + await client.create_session() + await client.aclose() + + +async def test_get_session_server_error_raises() -> None: + client = _client(lambda _r: httpx.Response(500, json={"error": "boom"})) + with pytest.raises(OpenCodeClientError): + await client.get_session("ses_1") + await client.aclose() + + +async def test_get_session_non_object_returns_none() -> None: + client = _client(lambda _r: httpx.Response(200, json=["x"])) + assert await client.get_session("ses_1") is None + await client.aclose() + + +async def test_list_messages_non_list_returns_empty() -> None: + client = _client(lambda _r: httpx.Response(200, json={"not": "a list"})) + assert await client.list_messages("ses_1") == [] + await client.aclose() + + +async def test_get_message_non_dict_returns_empty() -> None: + client = _client(lambda _r: httpx.Response(200, json=[1, 2])) + assert await client.get_message("ses_1", "msg_1") == {} + await client.aclose() + + +async def test_prompt_non_dict_returns_empty() -> None: + client = _client(lambda _r: httpx.Response(200, json=[1])) + assert await client.prompt("ses_1", {"parts": []}) == {} + await client.aclose() + + +async def test_fork_non_object_body_raises() -> None: + client = _client(lambda _r: httpx.Response(200, json="nope")) + with pytest.raises(OpenCodeClientError): + await client.fork("ses_1") + await client.aclose() + + +async def test_request_json_http_error_raises() -> None: + client = _client(lambda _r: httpx.Response(503, json={"error": "down"})) + with pytest.raises(OpenCodeClientError): + await client.list_messages("ses_1") + await client.aclose() diff --git a/tests/test_opencode_native_forwarder.py b/tests/test_opencode_native_forwarder.py new file mode 100644 index 00000000..2f4afe37 --- /dev/null +++ b/tests/test_opencode_native_forwarder.py @@ -0,0 +1,423 @@ +"""Tests for the OpenCode SSE -> Omnigent event forwarder translation.""" + +from __future__ import annotations + +from typing import Any + +import httpx + +import omnigent.opencode_native_forwarder as fwd_mod +from omnigent.opencode_native_client import OpenCodeEvent + +_SESSION = "ses_1" + + +class _RecordingServerClient: + """httpx-shaped stub recording Omnigent event POSTs.""" + + def __init__(self) -> None: + self.posts: list[tuple[str, dict[str, Any]]] = [] + + async def post(self, url: str, *, json: dict[str, Any]) -> httpx.Response: + self.posts.append((url, json)) + return httpx.Response(200, request=httpx.Request("POST", url)) + + +class _FakeOpenCodeClient: + """Fake OpenCode client recording permission replies + history.""" + + def __init__(self) -> None: + self.replies: list[tuple[str, dict[str, Any]]] = [] + self.messages: list[dict[str, Any]] = [] + + async def list_messages(self, session_id: str) -> list[dict[str, Any]]: + return self.messages + + async def reply_permission(self, request_id: str, reply: dict[str, Any]) -> bool: + self.replies.append((request_id, reply)) + return True + + +def _forwarder( + server: _RecordingServerClient, + opencode: _FakeOpenCodeClient, + **kwargs: Any, +) -> fwd_mod.OpenCodeNativeForwarder: + return fwd_mod.OpenCodeNativeForwarder( + session_id="conv_1", + opencode_session_id=_SESSION, + opencode_client=opencode, # type: ignore[arg-type] + server_client=server, # type: ignore[arg-type] + **kwargs, + ) + + +def _event(event_type: str, **props: Any) -> OpenCodeEvent: + props.setdefault("sessionID", _SESSION) + return OpenCodeEvent(id=None, type=event_type, properties=props, raw={}) + + +def _types(posts: list[tuple[str, dict[str, Any]]]) -> list[str]: + return [body["type"] for _url, body in posts] + + +async def test_part_delta_is_not_forwarded() -> None: + """Live token deltas are intentionally dropped (see the _HANDLERS note). + + The web chat view reconciles live ``text_delta`` previews with the + committed item via a finalize/retire handshake; emitting deltas without it + duplicated/garbled the chat. The forwarder posts only the durable item. + """ + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + await fwd.handle_event( + _event( + "message.part.delta", field="text", partID="prt_1", messageID="msg_1", delta="hello" + ) + ) + assert "external_output_text_delta" not in _types(server.posts) + + +async def test_assistant_text_part_finalized_on_idle_and_dedupes() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + # The role lives on the message; the text on a text part of that message. + await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"})) + await fwd.handle_event( + _event( + "message.part.updated", + part={"id": "prt_1", "messageID": "msg_1", "type": "text", "text": "full answer"}, + ) + ) + await fwd.handle_event(_event("session.idle")) + await fwd.handle_event(_event("session.idle")) # duplicate flush must not re-post + items = [b for _u, b in server.posts if b["type"] == "external_conversation_item"] + assert len(items) == 1 + assert items[0]["data"]["item_type"] == "message" + assert items[0]["data"]["item_data"]["role"] == "assistant" + assert items[0]["data"]["item_data"]["content"][0]["text"] == "full answer" + # The item groups under its assistant messageID (per-turn response), NOT a + # constant session id — that constant id was what clustered every turn's + # assistant items together and broke chat ordering. + assert items[0]["data"]["response_id"] == "msg_1" + + +async def test_each_assistant_message_gets_its_own_response_id() -> None: + """Distinct assistant messages map to distinct per-turn response groups.""" + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + for msg in ("msg_a", "msg_b"): + await fwd.handle_event(_event("message.updated", info={"id": msg, "role": "assistant"})) + await fwd.handle_event( + _event( + "message.part.updated", + part={"id": f"prt_{msg}", "messageID": msg, "type": "text", "text": f"t-{msg}"}, + ) + ) + await fwd.handle_event(_event("session.idle")) + items = [b for _u, b in server.posts if b["type"] == "external_conversation_item"] + response_ids = [it["data"]["response_id"] for it in items] + assert response_ids == ["msg_a", "msg_b"], "each turn must get its own response_id" + + +async def test_user_text_part_is_mirrored_before_the_assistant() -> None: + """The forwarder is the transcript source: it posts the user message too. + + For native-server harnesses omnigent persists no separate user item, so the + forwarder must mirror the user message (role=user) — posted eagerly so it + precedes its assistant reply (correct chat ordering). Deduped by part id. + """ + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + await fwd.handle_event(_event("message.updated", info={"id": "msg_u", "role": "user"})) + user_part = _event( + "message.part.updated", + part={"id": "prt_u", "messageID": "msg_u", "type": "text", "text": "my prompt"}, + ) + await fwd.handle_event(user_part) + await fwd.handle_event(user_part) # snapshot repeat must not double-post + # Then the assistant reply for the same turn. + await fwd.handle_event(_event("message.updated", info={"id": "msg_a", "role": "assistant"})) + await fwd.handle_event( + _event( + "message.part.updated", + part={"id": "prt_a", "messageID": "msg_a", "type": "text", "text": "hello"}, + ) + ) + await fwd.handle_event(_event("session.idle")) + + items = [b["data"] for _u, b in server.posts if b["type"] == "external_conversation_item"] + roles = [it["item_data"]["role"] for it in items if it["item_type"] == "message"] + assert roles == ["user", "assistant"], f"expected user before assistant, got {roles}" + user_item = next(it for it in items if it["item_data"]["role"] == "user") + assert user_item["item_data"]["content"][0]["text"] == "my prompt" + assert user_item["response_id"] == "msg_u" + + +async def test_tool_part_posts_function_call_and_output() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"})) + await fwd.handle_event( + _event( + "message.part.updated", + part={ + "id": "prt_t", + "messageID": "msg_1", + "type": "tool", + "callID": "call_1", + "tool": "bash", + "state": { + "status": "completed", + "input": {"command": "ls"}, + "output": "file1\nfile2", + }, + }, + ) + ) + call = next(b for _u, b in server.posts if b["data"].get("item_type") == "function_call") + assert call["data"]["item_data"]["name"] == "bash" + assert call["data"]["item_data"]["call_id"] == "call_1" + assert '"command": "ls"' in call["data"]["item_data"]["arguments"] + assert call["data"]["response_id"] == "msg_1" + out = next(b for _u, b in server.posts if b["data"].get("item_type") == "function_call_output") + assert out["data"]["item_data"]["call_id"] == "call_1" + assert out["data"]["item_data"]["output"] == "file1\nfile2" + assert out["data"]["response_id"] == "msg_1" + + +async def test_tool_part_dedupes_call_and_output_across_snapshots() -> None: + """The same tool part as running then completed posts the call/output once each.""" + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"})) + base = {"id": "prt_t", "messageID": "msg_1", "type": "tool", "callID": "c1", "tool": "bash"} + running = {"status": "running", "input": {"command": "ls"}} + completed = {"status": "completed", "input": {"command": "ls"}, "output": "ok"} + await fwd.handle_event(_event("message.part.updated", part={**base, "state": running})) + await fwd.handle_event(_event("message.part.updated", part={**base, "state": completed})) + calls = [b for _u, b in server.posts if b["data"].get("item_type") == "function_call"] + outs = [b for _u, b in server.posts if b["data"].get("item_type") == "function_call_output"] + assert len(calls) == 1 + assert len(outs) == 1 + + +async def test_tool_part_error_posts_error_output() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"})) + await fwd.handle_event( + _event( + "message.part.updated", + part={ + "id": "prt_e", + "messageID": "msg_1", + "type": "tool", + "callID": "call_2", + "tool": "bash", + "state": {"status": "error", "input": {"command": "x"}, "error": "boom"}, + }, + ) + ) + item = next( + b for _u, b in server.posts if b["data"].get("item_type") == "function_call_output" + ) + assert "boom" in item["data"]["item_data"]["output"] + + +async def test_lifecycle_emits_running_then_idle() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"})) + await fwd.handle_event(_event("session.idle")) + statuses = [ + b["data"]["status"] for _u, b in server.posts if b["type"] == "external_session_status" + ] + assert statuses == ["running", "idle"] + + +async def test_permission_asked_rejects_when_no_policy_wired() -> None: + """Absent a policy evaluator the forwarder FAILS CLOSED (no auto-approve). + + The security contract: a headless OpenCode turn must never silently + auto-approve a sensitive op just because no policy gate is wired. The + previous ``allow_once`` default did exactly that. + """ + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) # no policy_evaluator → fail closed + await fwd.handle_event( + _event("permission.v2.asked", id="per_1", action="bash", resources=[{"command": "ls"}]) + ) + assert opencode.replies == [("per_1", {"reply": "reject", "message": "omnigent-policy"})] + + +async def test_permission_asked_rejects_when_policy_denies() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + + async def deny(_normalized: Any) -> dict[str, Any]: + return {"decision": "deny"} + + fwd = _forwarder(server, opencode, policy_evaluator=deny) + await fwd.handle_event(_event("permission.v2.asked", id="per_2", action="bash")) + assert opencode.replies[0][1]["reply"] == "reject" + + +async def test_permission_asked_allows_only_on_explicit_policy_allow() -> None: + """An explicit policy ``allow`` is the only path to ``once``.""" + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + + async def allow(_normalized: Any) -> dict[str, Any]: + return {"decision": "allow"} + + fwd = _forwarder(server, opencode, policy_evaluator=allow) + await fwd.handle_event(_event("permission.v2.asked", id="per_a", action="bash")) + assert opencode.replies[0][1]["reply"] == "once" + + +async def test_permission_asked_allow_always_maps_to_always() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + + async def allow_always(_normalized: Any) -> dict[str, Any]: + return {"decision": "allow_always"} + + fwd = _forwarder(server, opencode, policy_evaluator=allow_always) + await fwd.handle_event(_event("permission.v2.asked", id="per_aa", action="bash")) + assert opencode.replies[0][1]["reply"] == "always" + + +async def test_permission_asked_rejects_when_policy_returns_ask() -> None: + """An unresolved ``ask`` reaching the forwarder FAILS CLOSED, not auto-approve. + + The genuine human approval for an ``ask`` is resolved UPSTREAM by the + policy evaluator (the server parks an approval card on + ``/policies/evaluate`` and returns a hard allow/deny). An ``ask`` that + still reaches the forwarder means no human resolution was obtained, so + it must DENY — never silently approve. + """ + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + + async def ask(_normalized: Any) -> dict[str, Any]: + return {"decision": "ask"} + + fwd = _forwarder(server, opencode, policy_evaluator=ask) + await fwd.handle_event(_event("permission.v2.asked", id="per_ask", action="bash")) + assert opencode.replies[0][1]["reply"] == "reject" + + +async def test_permission_asked_passes_normalized_input_to_evaluator() -> None: + """The forwarder routes through the policy gate with a normalized input. + + Proves the request is genuinely evaluated (harness + action + the + concrete command), not decided by a hardcoded default. + """ + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + seen: list[Any] = [] + + async def capture(normalized: Any) -> dict[str, Any]: + seen.append(normalized) + return {"decision": "deny"} + + fwd = _forwarder(server, opencode, policy_evaluator=capture, workspace="/work/repo") + await fwd.handle_event( + _event("permission.v2.asked", id="per_n", action="bash", resources=[{"command": "ls"}]) + ) + assert len(seen) == 1 + assert seen[0]["harness"] == "opencode-native" + assert seen[0]["action"] == "bash" + assert seen[0]["command"] == "ls" + assert seen[0]["working_directory"] == "/work/repo" + assert seen[0]["omnigent_session_id"] == "conv_1" + + +async def test_permission_asked_dedupes() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + ev = _event("permission.v2.asked", id="per_3", action="bash") + await fwd.handle_event(ev) + await fwd.handle_event(ev) + assert len(opencode.replies) == 1 + + +async def test_event_for_other_session_ignored() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + await fwd.handle_event( + OpenCodeEvent( + id=None, + type="message.part.updated", + properties={ + "sessionID": "ses_OTHER", + "part": {"id": "p", "messageID": "m", "type": "text", "text": "x"}, + }, + raw={}, + ) + ) + assert server.posts == [] + + +async def test_unknown_event_is_ignored() -> None: + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + await fwd.handle_event(_event("some.unknown.event", foo="bar")) + assert server.posts == [] + + +async def test_run_reconnects_until_cap() -> None: + """run() retries the SSE consume loop and stops at the reconnect cap.""" + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + fwd = _forwarder(server, opencode) + calls = {"n": 0} + + async def failing_consume() -> None: + calls["n"] += 1 + raise httpx.ReadError("dropped", request=httpx.Request("GET", "http://x/event")) + + fwd._consume_once = failing_consume # type: ignore[method-assign] + + # Patch sleep so the backoff doesn't slow the test. + async def _no_sleep(_seconds: float) -> None: + return None + + orig_sleep = fwd_mod.asyncio.sleep + fwd_mod.asyncio.sleep = _no_sleep # type: ignore[assignment] + try: + await fwd.run(max_reconnects=3) + finally: + fwd_mod.asyncio.sleep = orig_sleep # type: ignore[assignment] + assert calls["n"] == 4 # initial + 3 reconnects + + +async def test_seed_dedupe_from_history_marks_parts_and_roles() -> None: + """Resume seeding records message roles and pre-marks text/tool part keys.""" + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + opencode.messages = [ + { + "info": {"id": "msg_1", "role": "assistant"}, + "parts": [ + {"id": "prt_text", "type": "text"}, + {"id": "prt_tool", "type": "tool", "callID": "call_1"}, + "not-a-mapping", + ], + }, + {"info": {"id": "msg_2", "role": "user"}, "parts": []}, + "not-a-mapping-message", + ] + fwd = _forwarder(server, opencode) + await fwd.seed_dedupe_from_history() + assert fwd._msg_role == {"msg_1": "assistant", "msg_2": "user"} + # Seeded keys are pre-marked, so re-marking returns False (would be deduped). + assert fwd.state.mark(fwd._key("text-final", "prt_text")) is False + assert fwd.state.mark(fwd._key("tool-call", "call_1")) is False + + +async def test_seed_dedupe_from_history_swallows_errors() -> None: + """A history-fetch failure leaves the dedupe empty rather than raising.""" + server, opencode = _RecordingServerClient(), _FakeOpenCodeClient() + + async def _boom(_sid: str) -> list[dict[str, Any]]: + raise RuntimeError("history unavailable") + + opencode.list_messages = _boom # type: ignore[assignment] + fwd = _forwarder(server, opencode) + await fwd.seed_dedupe_from_history() # best-effort → no raise + assert fwd._msg_role == {} diff --git a/tests/test_opencode_native_permissions.py b/tests/test_opencode_native_permissions.py new file mode 100644 index 00000000..1e1ea93d --- /dev/null +++ b/tests/test_opencode_native_permissions.py @@ -0,0 +1,94 @@ +"""Tests for OpenCode permission normalization + policy/approval mapping.""" + +from __future__ import annotations + +from omnigent.opencode_native_permissions import ( + OPENCODE_NATIVE_HARNESS, + decision_to_reply, + map_verdict_to_decision, + normalize_for_policy, + parse_permission_request, + reply_body, +) + + +def test_parse_permission_request_from_event_properties() -> None: + req = parse_permission_request( + { + "id": "per_1", + "sessionID": "ses_1", + "action": "bash", + "resources": [{"command": "rm -rf build"}], + "metadata": {"path": "/repo/build"}, + "source": "tool", + } + ) + assert req is not None + assert req.request_id == "per_1" + assert req.session_id == "ses_1" + assert req.action == "bash" + assert req.source == "tool" + + +def test_parse_permission_request_accepts_request_id_alias() -> None: + req = parse_permission_request({"requestID": "per_2", "action": "edit"}) + assert req is not None + assert req.request_id == "per_2" + + +def test_parse_permission_request_requires_id() -> None: + assert parse_permission_request({"action": "bash"}) is None + + +def test_normalize_for_policy_extracts_command_and_path() -> None: + req = parse_permission_request( + { + "id": "per_1", + "sessionID": "ses_1", + "action": "bash", + "resources": [{"command": "ls", "path": "/repo/x"}], + } + ) + assert req is not None + normalized = normalize_for_policy(req, omnigent_session_id="conv_1", workspace="/repo") + assert normalized["harness"] == OPENCODE_NATIVE_HARNESS + assert normalized["action"] == "bash" + assert normalized["command"] == "ls" + assert normalized["path"] == "/repo/x" + assert normalized["working_directory"] == "/repo" + assert normalized["omnigent_session_id"] == "conv_1" + assert normalized["opencode_session_id"] == "ses_1" + + +def test_map_verdict_allow_variants() -> None: + assert map_verdict_to_decision({"decision": "allow"}) == "allow_once" + assert map_verdict_to_decision({"action": "approve"}) == "allow_once" + assert map_verdict_to_decision({"decision": "allow_always"}) == "allow_always" + assert map_verdict_to_decision({"decision": "always"}) == "allow_always" + + +def test_map_verdict_deny_variants() -> None: + assert map_verdict_to_decision({"decision": "deny"}) == "reject" + assert map_verdict_to_decision({"verdict": "block"}) == "reject" + + +def test_map_verdict_unknown_fails_closed_to_ask() -> None: + assert map_verdict_to_decision(None) == "ask" + assert map_verdict_to_decision({}) == "ask" + assert map_verdict_to_decision({"decision": "maybe"}) == "ask" + + +def test_decision_to_reply() -> None: + assert decision_to_reply("allow_once") == "once" + assert decision_to_reply("allow_always") == "always" + assert decision_to_reply("reject") == "reject" + # ask has no automatic reply (needs a human). + assert decision_to_reply("ask") is None + + +def test_reply_body() -> None: + assert reply_body("once") == {"reply": "once"} + assert reply_body("reject", message="blocked by policy") == { + "reply": "reject", + "message": "blocked by policy", + } diff --git a/tests/test_opencode_native_provider.py b/tests/test_opencode_native_provider.py new file mode 100644 index 00000000..e6d9d10f --- /dev/null +++ b/tests/test_opencode_native_provider.py @@ -0,0 +1,140 @@ +"""Unit tests for opencode-native provider-config synthesis.""" + +from __future__ import annotations + +import json +import stat +import sys +import types +from pathlib import Path + +import pytest + +from omnigent.opencode_native_provider import ( + DEFAULT_DATABRICKS_GATEWAY_MODEL, + OpenCodeGatewayResolution, + _gateway_endpoint_for_model, + build_opencode_model_default_config, + build_opencode_provider_config, + resolve_databricks_gateway, + write_opencode_provider_config, +) + + +def test_build_model_default_config_pins_model_without_provider_block() -> None: + cfg = build_opencode_model_default_config("anthropic/claude-sonnet-4-5") + assert cfg == { + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-5", + } + # No provider block: opencode resolves the provider from the model prefix. + assert "provider" not in cfg + + +def test_model_default_config_round_trips_through_writer(tmp_path: Path) -> None: + path = write_opencode_provider_config( + tmp_path, build_opencode_model_default_config("openai/gpt-5.5") + ) + written = json.loads(path.read_text(encoding="utf-8")) + assert written["model"] == "openai/gpt-5.5" + + +def test_qualified_model_joins_provider_and_endpoint() -> None: + res = OpenCodeGatewayResolution( + base_url="https://ws/serving-endpoints", + api_key="tok", + model_id="databricks-claude-sonnet-4-6", + provider_id="databricks-gateway", + ) + assert res.qualified_model == "databricks-gateway/databricks-claude-sonnet-4-6" + + +def test_build_provider_config_shape() -> None: + res = OpenCodeGatewayResolution( + base_url="https://ws/serving-endpoints", + api_key="sekret", + model_id="databricks-claude-sonnet-4-6", + ) + cfg = build_opencode_provider_config(res) + block = cfg["provider"]["databricks-gateway"] + assert block["npm"] == "@ai-sdk/openai-compatible" + assert block["options"] == {"baseURL": "https://ws/serving-endpoints", "apiKey": "sekret"} + assert "databricks-claude-sonnet-4-6" in block["models"] + assert cfg["$schema"].endswith("config.json") + + +def test_write_provider_config_is_0600_and_valid_json(tmp_path: Path) -> None: + res = OpenCodeGatewayResolution( + base_url="https://ws/serving-endpoints", api_key="tok", model_id="databricks-x" + ) + path = write_opencode_provider_config(tmp_path, build_opencode_provider_config(res)) + assert path == tmp_path / "opencode" / "opencode.json" + # Token-bearing config must not be world/group readable. + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + parsed = json.loads(path.read_text()) + assert parsed["provider"]["databricks-gateway"]["options"]["apiKey"] == "tok" + + +@pytest.mark.parametrize( + "model_id,expected", + [ + ("databricks-claude-sonnet-4-6", "databricks-claude-sonnet-4-6"), + ("databricks/databricks-gpt-5-5", "databricks-gpt-5-5"), + ("claude-opus-4", None), # not a gateway endpoint name + ("anthropic/claude-opus-4", None), + (None, None), + ], +) +def test_gateway_endpoint_normalization(model_id: str | None, expected: str | None) -> None: + assert _gateway_endpoint_for_model(model_id) == expected + + +def test_resolve_gateway_none_without_profile() -> None: + assert resolve_databricks_gateway(None) is None + assert resolve_databricks_gateway("") is None + + +def test_resolve_gateway_none_when_sdk_absent(monkeypatch: pytest.MonkeyPatch) -> None: + # Simulate databricks-sdk not installed: the import inside the function raises. + monkeypatch.setitem(sys.modules, "databricks.sdk.core", None) + assert resolve_databricks_gateway("oss") is None + + +def _install_fake_sdk(monkeypatch: pytest.MonkeyPatch, *, host: str, token: str | None) -> None: + fake = types.ModuleType("databricks.sdk.core") + + class _Config: + def __init__(self, *, profile: str) -> None: + self.profile = profile + self.host = host + + def authenticate(self) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} if token else {} + + fake.Config = _Config # type: ignore[attr-defined] + # Ensure parent packages resolve for the dotted import. + monkeypatch.setitem(sys.modules, "databricks", types.ModuleType("databricks")) + monkeypatch.setitem(sys.modules, "databricks.sdk", types.ModuleType("databricks.sdk")) + monkeypatch.setitem(sys.modules, "databricks.sdk.core", fake) + + +def test_resolve_gateway_success(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_sdk(monkeypatch, host="https://ws.cloud.databricks.com/", token="abc123") + res = resolve_databricks_gateway("oss", model_id="databricks-gpt-5-5") + assert res is not None + assert res.base_url == "https://ws.cloud.databricks.com/serving-endpoints" + assert res.api_key == "abc123" + assert res.model_id == "databricks-gpt-5-5" + assert res.qualified_model == "databricks-gateway/databricks-gpt-5-5" + + +def test_resolve_gateway_defaults_non_gateway_model(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_sdk(monkeypatch, host="https://ws.databricks.com", token="t") + res = resolve_databricks_gateway("oss", model_id="claude-opus-4") + assert res is not None + assert res.model_id == DEFAULT_DATABRICKS_GATEWAY_MODEL + + +def test_resolve_gateway_none_when_no_token(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_sdk(monkeypatch, host="https://ws.databricks.com", token=None) + assert resolve_databricks_gateway("oss") is None diff --git a/tests/test_opencode_native_state.py b/tests/test_opencode_native_state.py new file mode 100644 index 00000000..179c39b4 --- /dev/null +++ b/tests/test_opencode_native_state.py @@ -0,0 +1,69 @@ +"""Tests for client-side opencode-native launch state.""" + +from __future__ import annotations + +import hashlib +import logging +from pathlib import Path + +import pytest + +from omnigent.opencode_native_state import ( + read_launch_state, + write_launch_state, +) + + +def test_write_and_read_round_trips(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(tmp_path / "state")) + write_launch_state("conv_abc", "/repo") + state = read_launch_state("conv_abc") + assert state is not None + assert state.working_directory == "/repo" + + +def test_missing_state_is_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(tmp_path / "state")) + assert read_launch_state("nope") is None + + +def test_path_hashes_conversation_id(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + state_root = tmp_path / "state" + monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(state_root)) + conversation_id = "../../../etc/passwd" + digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()[:32] + write_launch_state(conversation_id, "/repo") + assert (state_root / digest / "launch.json").is_file() + assert not (tmp_path / "etc").exists() + + +def test_relative_path_rejected(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(tmp_path / "state")) + with pytest.raises(ValueError, match="absolute path"): + write_launch_state("conv_abc", "relative/dir") + + +def test_conflicting_write_keeps_original( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(tmp_path / "state")) + logging.getLogger("omnigent").propagate = True + write_launch_state("conv_abc", "/original") + with caplog.at_level(logging.WARNING): + write_launch_state("conv_abc", "/other") + state = read_launch_state("conv_abc") + assert state is not None + assert state.working_directory == "/original" + assert any("launch state mismatch" in r.message for r in caplog.records) + + +def test_malformed_state_is_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + state_root = tmp_path / "state" + monkeypatch.setenv("OMNIGENT_OPENCODE_NATIVE_STATE_DIR", str(state_root)) + digest = hashlib.sha256(b"conv_abc").hexdigest()[:32] + target = state_root / digest + target.mkdir(parents=True) + (target / "launch.json").write_text("{bad", encoding="utf-8") + assert read_launch_state("conv_abc") is None diff --git a/tests/test_opencode_polly_debby_worker.py b/tests/test_opencode_polly_debby_worker.py new file mode 100644 index 00000000..ed0b53c6 --- /dev/null +++ b/tests/test_opencode_polly_debby_worker.py @@ -0,0 +1,59 @@ +"""Tests for the optional OpenCode worker in polly and debby specs.""" + +from __future__ import annotations + +from pathlib import Path + +from omnigent.spec import load + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _sub_agents(bundle: str) -> dict[str, object]: + spec = load(_REPO_ROOT / "examples" / bundle) + return {sa.name: sa for sa in (getattr(spec, "sub_agents", None) or [])} + + +def _config(sub_agent: object) -> dict[str, object]: + executor = getattr(sub_agent, "executor", None) + config = getattr(executor, "config", None) + if isinstance(config, dict): + return config + return {} + + +def test_polly_declares_opencode_worker() -> None: + subs = _sub_agents("polly") + assert "opencode" in subs + cfg = _config(subs["opencode"]) + assert cfg.get("harness") == "opencode-native" + + +def test_polly_codex_worker_allowlists_opencode_override() -> None: + subs = _sub_agents("polly") + cfg = _config(subs["codex"]) + allowed = cfg.get("allowed_harnesses") + assert allowed is not None + assert "opencode-native" in allowed + assert "codex-native" in allowed + + +def test_polly_prompt_preflight_probes_opencode() -> None: + config = (_REPO_ROOT / "examples" / "polly" / "config.yaml").read_text(encoding="utf-8") + assert "command -v claude codex pi opencode" in config + + +def test_debby_declares_opencode_perspective() -> None: + subs = _sub_agents("debby") + assert "opencode" in subs + cfg = _config(subs["opencode"]) + assert cfg.get("harness") == "opencode-native" + # Default fanout is still the two heads. + assert "claude" in subs + assert "gpt" in subs + + +def test_debby_prompt_keeps_opencode_optional() -> None: + config = (_REPO_ROOT / "examples" / "debby" / "config.yaml").read_text(encoding="utf-8") + assert "Optional OpenCode perspective" in config + assert "do not dispatch" in config.lower() diff --git a/tests/tools/builtins/test_sys_session.py b/tests/tools/builtins/test_sys_session.py index 84fa309d..ff363d7a 100644 --- a/tests/tools/builtins/test_sys_session.py +++ b/tests/tools/builtins/test_sys_session.py @@ -22,7 +22,7 @@ import pytest from omnigent.entities.conversation import MessageData, NewConversationItem from omnigent.runtime import pending_elicitations from omnigent.session_lifecycle import CLOSED_LABEL_KEY, CLOSED_LABEL_VALUE -from omnigent.spec.types import AgentSpec +from omnigent.spec.types import AgentSpec, ExecutorSpec from omnigent.stores.conversation_store.sqlalchemy_store import ( SqlAlchemyConversationStore, ) @@ -198,6 +198,71 @@ def test_send_schema_advertises_plain_string_and_purpose_object_args() -> None: assert "harness default" in model_desc +def _object_branch_props(tool: SysSessionSendTool) -> set[str]: + """Return the property names of the object branch of ``args``.""" + params = tool.get_schema()["function"]["parameters"] + object_schema = next( + b for b in params["properties"]["args"]["anyOf"] if b.get("type") == "object" + ) + return set(object_schema["properties"]) + + +def test_send_schema_gates_harness_field_behind_allowlist_opt_in() -> None: + """ + ``args.harness`` is advertised ONLY when a sub-agent opts in. + + Per design D.4 the runtime harness override is allowlist-gated: the + schema exposes ``harness`` only when at least one declared sub-agent + declares a non-empty ``executor.config.allowed_harnesses``. A sub-agent + without that opt-in keeps the base ``{input, purpose, model}`` args + object, so the orchestrator never sees a harness knob it cannot use. + This mirrors the per-child dispatch guard in tool_dispatch.py — the two + gates must agree on what "opted in" means. + """ + # Not opted in: a plain sub-agent (no allowed_harnesses) → base schema. + plain = SysSessionSendTool( + {"claude": AgentSpec(spec_version=1, name="claude", description="Review helper.")} + ) + assert _object_branch_props(plain) == {"input", "purpose", "model"} + + # Opted in: a sub-agent whose executor.config.allowed_harnesses declares a + # non-empty allowlist (the polly/debby `codex`/`opencode` worker shape) → + # the schema adds the gated `harness` field. + opted_in_spec = AgentSpec( + spec_version=1, + name="codex", + description="Codex coding sub-agent.", + executor=ExecutorSpec( + type="omnigent", + config={ + "harness": "codex-native", + "allowed_harnesses": ["codex-native", "opencode-native"], + }, + ), + ) + opted_in = SysSessionSendTool({"codex": opted_in_spec}) + assert _object_branch_props(opted_in) == {"input", "purpose", "model", "harness"} + object_schema = next( + b + for b in opted_in.get_schema()["function"]["parameters"]["properties"]["args"]["anyOf"] + if b.get("type") == "object" + ) + assert "allowed_harnesses" in object_schema["properties"]["harness"]["description"] + # additionalProperties stays closed even with the extra gated field, so a + # spurious arg is still rejected by validation. + assert object_schema["additionalProperties"] is False + + # Mixed: one opted-in sub-agent among several opts the whole tool's schema + # in — the dispatch guard still rejects harness for the non-opted children. + mixed = SysSessionSendTool( + { + "claude": AgentSpec(spec_version=1, name="claude", description="Review helper."), + "codex": opted_in_spec, + } + ) + assert _object_branch_props(mixed) == {"input", "purpose", "model", "harness"} + + def test_peek_schema_required_fields_and_no_extra_props() -> None: """ The ``sys_session_get_history`` schema requires ``conversation_id``