feat: add opencode provider and refactor stream model to head/tail architecture

This commit is contained in:
Mohamed Boudra
2026-01-11 16:00:25 +07:00
parent 7fe10f9b4a
commit ebabcc09e5
42 changed files with 4416 additions and 2926 deletions
@@ -1,6 +1,6 @@
import type { Options as ClaudeAgentOptions } from "@anthropic-ai/claude-agent-sdk";
export type AgentProvider = "codex" | "claude";
export type AgentProvider = string;
export type AgentMetadata = { [key: string]: unknown };
@@ -215,10 +215,15 @@ export interface AgentSession {
setManagedAgentId?(agentId: string): void;
}
export interface ListModelsOptions {
cwd?: string;
}
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
@@ -21,7 +21,7 @@ import {
} from "../messages.js";
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-manifest.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentRegistry } from "./agent-registry.js";
import { createWorktree } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
@@ -1,19 +1,32 @@
import { describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
fetchClaudeModelCatalog,
fetchCodexModelCatalog,
} from "./model-catalog.js";
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
describe("provider model catalogs (e2e)", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60_000);
describe("provider model catalogs", () => {
test(
"Claude catalog exposes Sonnet and Haiku variants",
async () => {
const models = await fetchClaudeModelCatalog();
expect(models.length).toBeGreaterThan(0);
const result = await ctx.client.listProviderModels("claude");
const descriptions = models
.map((model) => `${model.label} ${model.description ?? ""}`.toLowerCase());
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
const descriptions = result.models.map(
(model) => `${model.label} ${model.description ?? ""}`.toLowerCase()
);
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
},
@@ -23,10 +36,35 @@ describe("provider model catalogs", () => {
test(
"Codex catalog exposes gpt-5.1-codex",
async () => {
const models = await fetchCodexModelCatalog();
const ids = models.map((model) => model.id);
const result = await ctx.client.listProviderModels("codex");
expect(result.error).toBeNull();
const ids = result.models.map((model) => model.id);
expect(ids).toContain("gpt-5.1-codex");
},
180_000
);
test(
"OpenCode catalog returns models from multiple providers",
async () => {
const result = await ctx.client.listProviderModels("opencode");
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
for (const model of result.models) {
expect(model.provider).toBe("opencode");
expect(model.id).toContain("/");
expect(model.label).toBeTruthy();
expect(model.metadata).toBeDefined();
expect(model.metadata?.providerId).toBeTruthy();
expect(model.metadata?.modelId).toBeTruthy();
}
const providerIds = new Set(result.models.map((m) => m.metadata?.providerId));
expect(providerIds.size).toBeGreaterThan(0);
},
180_000
);
});
@@ -1,330 +0,0 @@
import {
spawn,
type ChildProcess,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath, URL } from "node:url";
import {
query,
type ModelInfo,
type Options,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type {
AgentModelDefinition,
AgentProvider,
} from "./agent-sdk-types.js";
type ProviderModelCatalogOptions = {
cwd?: string;
};
type ClaudeModelInfo = ModelInfo;
type ClaudeOptions = Options;
export async function fetchProviderModelCatalog(
provider: AgentProvider,
options?: ProviderModelCatalogOptions
): Promise<AgentModelDefinition[]> {
if (provider === "claude") {
return fetchClaudeModelCatalog(options);
}
if (provider === "codex") {
return fetchCodexModelCatalog();
}
throw new Error(`Unsupported provider '${provider}'`);
}
export async function fetchClaudeModelCatalog(
options?: ProviderModelCatalogOptions
): Promise<AgentModelDefinition[]> {
const prompt = emptySdkMessageStream();
const claudeOptions: ClaudeOptions = {
cwd: options?.cwd ?? process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
};
const claudeQuery = query({ prompt, options: claudeOptions });
try {
const models: ClaudeModelInfo[] = await claudeQuery.supportedModels();
return models.map((model) => ({
provider: "claude",
id: model.value,
label: model.displayName,
description: model.description,
metadata: {
description: model.description,
},
}));
} finally {
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
} catch {
// ignore shutdown errors
}
}
}
}
export async function fetchCodexModelCatalog(): Promise<AgentModelDefinition[]> {
const binaryPath = resolveCodexBinary();
const child = spawn(binaryPath, ["app-server"], {
stdio: ["pipe", "pipe", "pipe"],
});
assertChildHasPipes(child);
const client = new CodexAppServerClient(child);
try {
await client.request("initialize", {
clientInfo: {
name: "voice-dev",
title: "Voice Dev",
version: "0.0.0",
},
});
const response = await client.request("model/list", {});
if (!isCodexModelListResponse(response)) {
throw new Error("Unexpected Codex model list response");
}
return response.data.map((model) => ({
provider: "codex",
id: model.id,
label: model.displayName,
description: model.description,
isDefault: model.isDefault,
metadata: {
model: model.model,
defaultReasoningEffort: model.defaultReasoningEffort,
supportedReasoningEfforts: model.supportedReasoningEfforts,
},
}));
} finally {
await client.dispose();
}
}
function emptySdkMessageStream(): AsyncIterable<SDKUserMessage> {
return (async function* empty() {})();
}
function resolveCodexBinary(): string {
const repoRoot = path.resolve(
fileURLToPath(new URL("../../../../..", import.meta.url))
);
const packageRoot = path.join(repoRoot, "node_modules", "@openai", "codex-sdk");
const vendorDir = path.join(packageRoot, "vendor");
const { platform, arch } = process;
const triples: { [key: string]: string } = {
"darwin:x64": "x86_64-apple-darwin",
"darwin:arm64": "aarch64-apple-darwin",
"linux:x64": "x86_64-unknown-linux-musl",
"linux:arm64": "aarch64-unknown-linux-musl",
"win32:x64": "x86_64-pc-windows-msvc",
"win32:arm64": "aarch64-pc-windows-msvc",
};
const triple = triples[`${platform}:${arch}`];
if (!triple) {
throw new Error(`Unsupported Codex platform: ${platform} (${arch})`);
}
const binary = platform === "win32" ? "codex.exe" : "codex";
return path.join(vendorDir, triple, "codex", binary);
}
type CodexModelListResponse = {
data: CodexModelInfo[];
nextCursor: string | null;
};
type CodexModelInfo = {
id: string;
model: string;
displayName: string;
description: string;
supportedReasoningEfforts: Array<{ reasoningEffort: string; description: string }>;
defaultReasoningEffort: string;
isDefault: boolean;
};
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
function assertChildHasPipes(
child: ChildProcess
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Codex app-server must be started with stdio pipes");
}
}
function isObject(value: unknown): value is { [key: string]: unknown } {
return typeof value === "object" && value !== null;
}
function isCodexModelInfo(value: unknown): value is CodexModelInfo {
if (!isObject(value)) {
return false;
}
if (typeof value.id !== "string") {
return false;
}
if (typeof value.model !== "string") {
return false;
}
if (typeof value.displayName !== "string") {
return false;
}
if (typeof value.description !== "string") {
return false;
}
if (typeof value.defaultReasoningEffort !== "string") {
return false;
}
if (typeof value.isDefault !== "boolean") {
return false;
}
if (!Array.isArray(value.supportedReasoningEfforts)) {
return false;
}
for (const entry of value.supportedReasoningEfforts) {
if (!isObject(entry)) {
return false;
}
if (typeof entry.reasoningEffort !== "string") {
return false;
}
if (typeof entry.description !== "string") {
return false;
}
}
return true;
}
function isCodexModelListResponse(
value: unknown
): value is CodexModelListResponse {
if (!isObject(value)) {
return false;
}
if (!Array.isArray(value.data)) {
return false;
}
if (value.nextCursor !== null && typeof value.nextCursor !== "string") {
return false;
}
return value.data.every((entry) => isCodexModelInfo(entry));
}
class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
private nextId = 1;
private stderrBuffer = "";
private disposed = false;
constructor(private readonly child: ChildProcessWithoutNullStreams) {
this.rl = readline.createInterface({ input: child.stdout });
this.rl.on("line", (line) => this.handleLine(line));
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk;
});
child.on("exit", (code, signal) => {
if (this.disposed) {
return;
}
const message = code === 0
? "Codex app-server exited"
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
const error = new Error(this.stderrBuffer || message);
this.rejectAll(error);
});
}
async request(
method: string,
params: { [key: string]: unknown }
): Promise<unknown> {
if (this.disposed) {
throw new Error("Codex app-server client is closed");
}
const id = this.nextId++;
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
this.child.stdin.write(payload);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (!this.pending.has(id)) {
return;
}
this.pending.delete(id);
reject(new Error(`Timeout waiting for '${method}' response`));
}, 60_000);
this.pending.set(id, { resolve, reject, timer });
});
}
async dispose(): Promise<void> {
if (this.disposed) {
return;
}
this.disposed = true;
this.rl.close();
if (this.child.stdin && !this.child.killed) {
this.child.stdin.end();
setTimeout(() => {
if (this.child.exitCode === null && !this.child.killed) {
this.child.kill("SIGINT");
}
}, 100);
}
}
private handleLine(line: string) {
const trimmed = line.trim();
if (!trimmed) {
return;
}
let message: any;
try {
message = JSON.parse(trimmed);
} catch {
this.stderrBuffer += `\n[stdout] ${trimmed}`;
return;
}
if (message.id && this.pending.has(message.id)) {
const pending = this.pending.get(message.id)!;
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.error) {
const error = new Error(message.error.message ?? "Codex RPC error");
pending.reject(error);
} else {
pending.resolve(message.result);
}
return;
}
// ignore notifications
}
private rejectAll(error: Error) {
for (const [, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
@@ -2,13 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveAgentModel } from "./model-resolver.js";
vi.mock("./model-catalog.js", () => ({
fetchProviderModelCatalog: vi.fn(),
vi.mock("./provider-registry.js", () => ({
fetchProviderModels: vi.fn(),
}));
import { fetchProviderModelCatalog } from "./model-catalog.js";
import { fetchProviderModels } from "./provider-registry.js";
const mockedFetch = vi.mocked(fetchProviderModelCatalog);
const mockedFetch = vi.mocked(fetchProviderModels);
describe("resolveAgentModel", () => {
beforeEach(() => {
@@ -1,4 +1,4 @@
import { fetchProviderModelCatalog } from "./model-catalog.js";
import { fetchProviderModels } from "./provider-registry.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import { expandTilde } from "../../utils/path.js";
@@ -17,7 +17,7 @@ export async function resolveAgentModel(
}
try {
const models = await fetchProviderModelCatalog(options.provider, {
const models = await fetchProviderModels(options.provider, {
cwd: options.cwd ? expandTilde(options.cwd) : undefined,
});
const preferred = models.find((model) => model.isDefault) ?? models[0];
@@ -0,0 +1,140 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentStreamEventPayload } from "../messages.js";
describe("OpenCode reasoning events (e2e)", () => {
let ctx: DaemonTestContext;
let agentCwd: string;
beforeEach(async () => {
ctx = await createDaemonTestContext();
agentCwd = await mkdtemp(path.join(os.tmpdir(), "opencode-reasoning-test-"));
});
afterEach(async () => {
await ctx.cleanup();
await rm(agentCwd, { recursive: true, force: true });
}, 60_000);
test(
"gpt-5 nano emits reasoning events through daemon",
async () => {
const allEvents: Array<{
event: AgentStreamEventPayload;
timestamp: string;
}> = [];
// Subscribe to all events
ctx.client.on((event) => {
if (event.type === "agent_stream") {
allEvents.push({
event: event.event,
timestamp: event.timestamp,
});
}
});
// Create agent with gpt-5 nano model
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd: agentCwd,
model: "opencode/gpt-5-nano",
title: "reasoning-test",
});
expect(agent.id).toBeTruthy();
console.log("\n=== GPT-5 NANO E2E REASONING DEBUG ===\n");
console.log(`Agent ID: ${agent.id}`);
console.log(`Agent status: ${agent.status}`);
// Send a message that should trigger reasoning
await ctx.client.sendMessage(agent.id, "What is 2+2? Think step by step.");
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120_000);
console.log(`\nFinal status: ${finalState.status}`);
console.log(`Total events captured: ${allEvents.length}`);
// Log all events
console.log("\n=== ALL EVENTS ===\n");
for (let i = 0; i < allEvents.length; i++) {
const { event, timestamp } = allEvents[i];
console.log(`[EVENT ${i + 1}] timestamp=${timestamp} type=${event.type}`);
console.log(JSON.stringify(event, null, 2));
console.log("---");
}
// Group by type
const byType = new Map<string, number>();
for (const { event } of allEvents) {
byType.set(event.type, (byType.get(event.type) ?? 0) + 1);
}
console.log("\n=== EVENTS BY TYPE ===");
for (const [type, count] of byType) {
console.log(` ${type}: ${count}`);
}
// Check timeline events breakdown
const timelineEvents = allEvents.filter(
({ event }) => event.type === "timeline"
);
const itemTypes = new Map<string, number>();
for (const { event } of timelineEvents) {
if (event.type === "timeline") {
itemTypes.set(event.item.type, (itemTypes.get(event.item.type) ?? 0) + 1);
}
}
console.log("\n=== TIMELINE ITEM TYPES ===");
for (const [type, count] of itemTypes) {
console.log(` ${type}: ${count}`);
}
// Find reasoning events
const reasoningEvents = timelineEvents.filter(
({ event }) => event.type === "timeline" && event.item.type === "reasoning"
);
console.log(`\nReasoning events: ${reasoningEvents.length}`);
for (const { event } of reasoningEvents.slice(0, 5)) {
if (event.type === "timeline") {
console.log("Sample reasoning:", JSON.stringify(event.item, null, 2));
}
}
// Check for duplicate consecutive events
console.log("\n=== DUPLICATE CHECK ===");
let duplicateCount = 0;
for (let i = 1; i < allEvents.length; i++) {
const prev = allEvents[i - 1];
const curr = allEvents[i];
if (JSON.stringify(prev.event) === JSON.stringify(curr.event)) {
duplicateCount++;
if (duplicateCount <= 5) {
console.log(`Duplicate at index ${i}:`, JSON.stringify(curr.event, null, 2));
}
}
}
console.log(`Total duplicates: ${duplicateCount}`);
console.log("\n=== END DEBUG ===\n");
// HARD ASSERT: Agent completed
expect(finalState.status).toBe("idle");
// HARD ASSERT: Got events
expect(allEvents.length).toBeGreaterThan(0);
// Delete the agent
await ctx.client.deleteAgent(agent.id);
},
180_000
);
});
@@ -1,7 +1,8 @@
import type { AgentMode, AgentProvider } from "./agent-sdk-types.js";
import { z } from "zod";
import type { AgentMode } from "./agent-sdk-types.js";
export interface AgentProviderDefinition {
id: AgentProvider;
id: string;
label: string;
description: string;
defaultModeId: string | null;
@@ -51,6 +52,14 @@ const CODEX_MODES: AgentMode[] = [
},
];
const OPENCODE_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard permission rules",
},
];
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "claude",
@@ -68,12 +77,28 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModeId: "auto",
modes: CODEX_MODES,
},
{
id: "opencode",
label: "OpenCode",
description:
"Open-source coding assistant with multi-provider model support",
defaultModeId: "default",
modes: OPENCODE_MODES,
},
];
export function getAgentProviderDefinition(provider: AgentProvider): AgentProviderDefinition {
export function getAgentProviderDefinition(provider: string): AgentProviderDefinition {
const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider);
if (!definition) {
throw new Error(`Unknown agent provider: ${provider}`);
}
return definition;
}
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [string, ...string[]];
export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS);
export function isValidAgentProvider(value: string): boolean {
return AGENT_PROVIDER_IDS.includes(value);
}
@@ -0,0 +1,80 @@
import type {
AgentClient,
AgentModelDefinition,
AgentProvider,
ListModelsOptions,
} from "./agent-sdk-types.js";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexMcpAgentClient } from "./providers/codex-mcp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
getAgentProviderDefinition,
type AgentProviderDefinition,
} from "./provider-manifest.js";
export type {
AgentProviderDefinition,
};
export {
AGENT_PROVIDER_DEFINITIONS,
getAgentProviderDefinition,
};
export interface ProviderDefinition extends AgentProviderDefinition {
createClient: () => AgentClient;
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
}
const claudeClient = new ClaudeAgentClient();
const codexClient = new CodexMcpAgentClient();
const opencodeClient = new OpenCodeAgentClient();
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = {
claude: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!,
createClient: () => new ClaudeAgentClient(),
fetchModels: (options) => claudeClient.listModels(options),
},
codex: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!,
createClient: () => new CodexMcpAgentClient(),
fetchModels: (options) => codexClient.listModels(options),
},
opencode: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
createClient: () => new OpenCodeAgentClient(),
fetchModels: (options) => opencodeClient.listModels(options),
},
};
export function getProviderDefinition(provider: AgentProvider): ProviderDefinition {
const definition = PROVIDER_REGISTRY[provider];
if (!definition) {
throw new Error(`Unknown agent provider: ${provider}`);
}
return definition;
}
export function getAllProviderDefinitions(): ProviderDefinition[] {
return Object.values(PROVIDER_REGISTRY);
}
export function createAllClients(): Record<AgentProvider, AgentClient> {
const clients: Partial<Record<AgentProvider, AgentClient>> = {};
for (const [id, definition] of Object.entries(PROVIDER_REGISTRY)) {
clients[id as AgentProvider] = definition.createClient();
}
return clients as Record<AgentProvider, AgentClient>;
}
export async function fetchProviderModels(
provider: AgentProvider,
options?: { cwd?: string }
): Promise<AgentModelDefinition[]> {
const definition = getProviderDefinition(provider);
return definition.fetchModels(options);
}
@@ -1307,3 +1307,40 @@ function rawContainsText(raw: unknown, text: string, depth = 0): boolean {
// NOTE: Turn handoff integration tests are covered by the daemon E2E test:
// "interrupting message should produce coherent text without garbling from race condition"
// in daemon.e2e.test.ts which exercises the full flow through the WebSocket API.
describe("ClaudeAgentClient.listModels", () => {
test(
"returns models with required fields",
async () => {
const client = new ClaudeAgentClient();
const models = await client.listModels();
// HARD ASSERT: Returns an array
expect(Array.isArray(models)).toBe(true);
// HARD ASSERT: At least one model is returned
expect(models.length).toBeGreaterThan(0);
// HARD ASSERT: Each model has required fields with correct types
for (const model of models) {
expect(model.provider).toBe("claude");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
}
// HARD ASSERT: Contains known Claude model IDs
const modelIds = models.map((m) => m.id);
const hasKnownModel = modelIds.some(
(id) =>
id.includes("claude") ||
id.includes("sonnet") ||
id.includes("opus") ||
id.includes("haiku")
);
expect(hasKnownModel).toBe(true);
},
60_000
);
});
@@ -8,6 +8,7 @@ import {
type AgentDefinition,
type CanUseTool,
type McpServerConfig,
type ModelInfo,
type Options,
type PermissionMode,
type PermissionResult,
@@ -25,6 +26,7 @@ import type {
AgentClient,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionUpdate,
@@ -38,6 +40,7 @@ import type {
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
@@ -324,6 +327,37 @@ export class ClaudeAgentClient implements AgentClient {
});
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const prompt = (async function* empty() {})();
const claudeOptions: Options = {
cwd: options?.cwd ?? process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
};
const claudeQuery = query({ prompt, options: claudeOptions });
try {
const models: ModelInfo[] = await claudeQuery.supportedModels();
return models.map((model) => ({
provider: "claude" as const,
id: model.value,
label: model.displayName,
description: model.description,
metadata: {
description: model.description,
},
}));
} finally {
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
} catch {
// ignore shutdown errors
}
}
}
}
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const projectsRoot = path.join(configDir, "projects");
@@ -1538,4 +1538,38 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
},
180_000
);
test(
"listModels returns models with required fields",
async () => {
const { CodexMcpAgentClient } = await loadCodexMcpAgentClient();
const client = new CodexMcpAgentClient();
const models = await client.listModels();
// HARD ASSERT: Returns an array
expect(Array.isArray(models)).toBe(true);
// HARD ASSERT: At least one model is returned
expect(models.length).toBeGreaterThan(0);
// HARD ASSERT: Each model has required fields with correct types
for (const model of models) {
expect(model.provider).toBe("codex");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
}
// HARD ASSERT: Exactly one model is marked as default
const defaultModels = models.filter((m) => m.isDefault === true);
expect(defaultModels.length).toBe(1);
// HARD ASSERT: Default model has metadata with model info
const defaultModel = defaultModels[0];
expect(defaultModel.metadata).toBeTruthy();
expect(typeof defaultModel.metadata?.model).toBe("string");
},
60_000
);
});
@@ -1,8 +1,14 @@
import { execSync } from "node:child_process";
import {
spawn,
execSync,
type ChildProcess,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs, readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -13,6 +19,7 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
@@ -25,6 +32,7 @@ import type {
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
@@ -4248,6 +4256,223 @@ export class CodexMcpAgentClient implements AgentClient {
return descriptors;
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const binaryPath = resolveCodexBinary();
const child = spawn(binaryPath, ["app-server"], {
stdio: ["pipe", "pipe", "pipe"],
});
assertCodexChildHasPipes(child);
const client = new CodexAppServerClient(child);
try {
await client.request("initialize", {
clientInfo: {
name: "voice-dev",
title: "Voice Dev",
version: "0.0.0",
},
});
const response = await client.request("model/list", {});
if (!isCodexModelListResponse(response)) {
throw new Error("Unexpected Codex model list response");
}
return response.data.map((model) => ({
provider: "codex" as const,
id: model.id,
label: model.displayName,
description: model.description,
isDefault: model.isDefault,
metadata: {
model: model.model,
defaultReasoningEffort: model.defaultReasoningEffort,
supportedReasoningEfforts: model.supportedReasoningEfforts,
},
}));
} finally {
await client.dispose();
}
}
}
// ============================================================================
// Codex model listing helpers
// ============================================================================
type CodexModelListResponse = {
data: CodexModelInfo[];
nextCursor: string | null;
};
type CodexModelInfo = {
id: string;
model: string;
displayName: string;
description: string;
supportedReasoningEfforts: Array<{ reasoningEffort: string; description: string }>;
defaultReasoningEffort: string;
isDefault: boolean;
};
type CodexPendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
function resolveCodexBinary(): string {
try {
const codexPath = execSync("which codex", { encoding: "utf8" }).trim();
if (codexPath) {
return codexPath;
}
} catch {
// Fall through to error
}
throw new Error(
"Codex CLI not found. Please install codex globally: npm install -g @openai/codex"
);
}
function assertCodexChildHasPipes(
child: ChildProcess
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Codex app-server must be started with stdio pipes");
}
}
function isCodexModelInfo(value: unknown): value is CodexModelInfo {
if (typeof value !== "object" || value === null) {
return false;
}
const obj = value as Record<string, unknown>;
return (
typeof obj.id === "string" &&
typeof obj.model === "string" &&
typeof obj.displayName === "string" &&
typeof obj.description === "string" &&
typeof obj.defaultReasoningEffort === "string" &&
typeof obj.isDefault === "boolean" &&
Array.isArray(obj.supportedReasoningEfforts)
);
}
function isCodexModelListResponse(value: unknown): value is CodexModelListResponse {
if (typeof value !== "object" || value === null) {
return false;
}
const obj = value as Record<string, unknown>;
if (!Array.isArray(obj.data)) {
return false;
}
if (obj.nextCursor !== null && typeof obj.nextCursor !== "string") {
return false;
}
return obj.data.every((entry) => isCodexModelInfo(entry));
}
class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, CodexPendingRequest>();
private nextId = 1;
private stderrBuffer = "";
private disposed = false;
constructor(private readonly child: ChildProcessWithoutNullStreams) {
this.rl = readline.createInterface({ input: child.stdout });
this.rl.on("line", (line) => this.handleLine(line));
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk;
});
child.on("exit", (code, signal) => {
if (this.disposed) {
return;
}
const message = code === 0
? "Codex app-server exited"
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
const error = new Error(this.stderrBuffer || message);
this.rejectAll(error);
});
}
async request(
method: string,
params: { [key: string]: unknown }
): Promise<unknown> {
if (this.disposed) {
throw new Error("Codex app-server client is closed");
}
const id = this.nextId++;
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
this.child.stdin.write(payload);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (!this.pending.has(id)) {
return;
}
this.pending.delete(id);
reject(new Error(`Timeout waiting for '${method}' response`));
}, 60_000);
this.pending.set(id, { resolve, reject, timer });
});
}
async dispose(): Promise<void> {
if (this.disposed) {
return;
}
this.disposed = true;
this.rl.close();
if (this.child.stdin && !this.child.killed) {
this.child.stdin.end();
setTimeout(() => {
if (this.child.exitCode === null && !this.child.killed) {
this.child.kill("SIGINT");
}
}, 100);
}
}
private handleLine(line: string): void {
const trimmed = line.trim();
if (!trimmed) {
return;
}
let message: { id?: number; result?: unknown; error?: { message?: string } };
try {
message = JSON.parse(trimmed);
} catch {
this.stderrBuffer += `\n[stdout] ${trimmed}`;
return;
}
if (message.id && this.pending.has(message.id)) {
const pending = this.pending.get(message.id)!;
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.error) {
const error = new Error(message.error.message ?? "Codex RPC error");
pending.reject(error);
} else {
pending.resolve(message.result);
}
}
}
private rejectAll(error: Error): void {
for (const [, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
// ============================================================================
@@ -0,0 +1,426 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import type {
AgentSessionConfig,
AgentStreamEvent,
ToolCallTimelineItem,
AssistantMessageTimelineItem,
UserMessageTimelineItem,
AgentTimelineItem,
} from "../agent-sdk-types.js";
function tmpCwd(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
try {
return realpathSync(dir);
} catch {
return dir;
}
}
const TEST_MODEL = "opencode/glm-4.7-free";
interface TurnResult {
events: AgentStreamEvent[];
assistantMessages: AssistantMessageTimelineItem[];
toolCalls: ToolCallTimelineItem[];
allTimelineItems: AgentTimelineItem[];
turnCompleted: boolean;
turnFailed: boolean;
error?: string;
}
async function collectTurnEvents(
iterator: AsyncGenerator<AgentStreamEvent>
): Promise<TurnResult> {
const result: TurnResult = {
events: [],
assistantMessages: [],
toolCalls: [],
allTimelineItems: [],
turnCompleted: false,
turnFailed: false,
};
for await (const event of iterator) {
result.events.push(event);
if (event.type === "timeline") {
result.allTimelineItems.push(event.item);
if (event.item.type === "assistant_message") {
result.assistantMessages.push(event.item);
} else if (event.item.type === "tool_call") {
result.toolCalls.push(event.item);
}
}
if (event.type === "turn_completed") {
result.turnCompleted = true;
break;
}
if (event.type === "turn_failed") {
result.turnFailed = true;
result.error = event.error;
break;
}
}
return result;
}
describe("OpenCodeAgentClient", () => {
const buildConfig = (cwd: string): AgentSessionConfig => ({
provider: "opencode",
cwd,
model: TEST_MODEL,
});
test(
"creates a session with valid id and provider",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
// HARD ASSERT: Session has required fields
expect(typeof session.id).toBe("string");
expect(session.id.length).toBeGreaterThan(0);
expect(session.provider).toBe("opencode");
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
60_000
);
test(
"single turn completes with streaming deltas",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
const iterator = session.stream("Say hello");
const turn = await collectTurnEvents(iterator);
// HARD ASSERT: Turn completed successfully
expect(turn.turnCompleted).toBe(true);
expect(turn.turnFailed).toBe(false);
// HARD ASSERT: Got at least one assistant message
expect(turn.assistantMessages.length).toBeGreaterThan(0);
// HARD ASSERT: Each delta is non-empty
for (const msg of turn.assistantMessages) {
expect(msg.text.length).toBeGreaterThan(0);
}
// HARD ASSERT: Concatenated deltas form non-empty response
const fullResponse = turn.assistantMessages.map((m) => m.text).join("");
expect(fullResponse.length).toBeGreaterThan(0);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
120_000
);
test(
"user prompt text never appears in assistant_message",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
const userMarker = "UNIQUE_USER_MARKER_XYZ789";
const iterator = session.stream(`Reply with ACK only. ${userMarker}`);
const turn = await collectTurnEvents(iterator);
// HARD ASSERT: Turn completed
expect(turn.turnCompleted).toBe(true);
// HARD ASSERT: Got assistant response
expect(turn.assistantMessages.length).toBeGreaterThan(0);
// HARD ASSERT: User marker never appears in ANY assistant message
for (const msg of turn.assistantMessages) {
expect(msg.text).not.toContain(userMarker);
}
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
120_000
);
test(
"multi-turn preserves context",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
const secretCode = "ZEBRA_42";
// Turn 1: Establish a fact
const turn1 = await collectTurnEvents(
session.stream(`Remember this code: ${secretCode}. Just say OK.`)
);
// HARD ASSERT: Turn 1 completed
expect(turn1.turnCompleted).toBe(true);
expect(turn1.assistantMessages.length).toBeGreaterThan(0);
// Turn 2: Recall the fact
const turn2 = await collectTurnEvents(
session.stream("What was the code I told you? Reply with just the code.")
);
// HARD ASSERT: Turn 2 completed
expect(turn2.turnCompleted).toBe(true);
expect(turn2.assistantMessages.length).toBeGreaterThan(0);
// HARD ASSERT: Response contains the secret code (context preserved)
const fullResponse = turn2.assistantMessages.map((m) => m.text).join("");
expect(fullResponse).toContain(secretCode);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
180_000
);
test(
"emits tool_call events for file operations",
async () => {
const cwd = tmpCwd();
const testFile = path.join(cwd, "test-file.txt");
writeFileSync(testFile, "original content\n");
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
const iterator = session.stream(`Read the file at ${testFile}`);
const turn = await collectTurnEvents(iterator);
// HARD ASSERT: Turn completed
expect(turn.turnCompleted).toBe(true);
// HARD ASSERT: Got at least one tool call
expect(turn.toolCalls.length).toBeGreaterThan(0);
// HARD ASSERT: Tool call has required fields
const firstTool = turn.toolCalls[0];
expect(typeof firstTool.name).toBe("string");
expect(firstTool.name.length).toBeGreaterThan(0);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
120_000
);
test(
"can be interrupted during streaming",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
const events: AgentStreamEvent[] = [];
const iterator = session.stream(
"Write a very long essay about computing history with at least 10 paragraphs."
);
// Collect a few events then interrupt
for await (const event of iterator) {
events.push(event);
if (events.length >= 5) {
await session.interrupt();
break;
}
}
// HARD ASSERT: Received events before interruption
expect(events.length).toBeGreaterThanOrEqual(5);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
60_000
);
test(
"run() returns accumulated response text",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
const marker = "OPENCODE_ACK_TOKEN";
const result = await session.run(`Reply with exactly: ${marker}`);
// HARD ASSERT: Result has finalText
expect(typeof result.finalText).toBe("string");
// HARD ASSERT: Response contains the marker
expect(result.finalText).toContain(marker);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
120_000
);
test(
"handles permission requests",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
const events: AgentStreamEvent[] = [];
const iterator = session.stream("Run the command 'echo hello' in a shell.");
for await (const event of iterator) {
events.push(event);
if (event.type === "permission_requested") {
// HARD ASSERT: Permission request has required fields
expect(typeof event.request.id).toBe("string");
expect(event.request.id.length).toBeGreaterThan(0);
// Approve it
await session.respondToPermission(event.request.id, {
behavior: "allow",
});
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
// HARD ASSERT: Turn completed (either with or without permission)
const hasCompletion = events.some(
(e) => e.type === "turn_completed" || e.type === "turn_failed"
);
expect(hasCompletion).toBe(true);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
120_000
);
test(
"listModels returns models with required fields",
async () => {
const client = new OpenCodeAgentClient();
const models = await client.listModels();
// HARD ASSERT: Returns an array
expect(Array.isArray(models)).toBe(true);
// HARD ASSERT: At least one model is returned (OpenCode has connected providers)
expect(models.length).toBeGreaterThan(0);
// HARD ASSERT: Each model has required fields with correct types
for (const model of models) {
expect(model.provider).toBe("opencode");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
expect(model.id).toContain("/");
}
},
60_000
);
test(
"streamHistory returns exact conversation history after multi-turn session",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient();
const session = await client.createSession(buildConfig(cwd));
// Turn 1: Ask agent to remember a secret
const secretCode = "HISTORY_TEST_42";
const turn1 = await collectTurnEvents(
session.stream(`Remember this code: ${secretCode}. Reply with just "OK".`)
);
// HARD ASSERT: Turn 1 completed
expect(turn1.turnCompleted).toBe(true);
expect(turn1.assistantMessages.length).toBeGreaterThan(0);
// Turn 2: Ask agent to read a file (triggers tool use)
const testFile = path.join(cwd, "history-test.txt");
writeFileSync(testFile, "history test content\n");
const turn2 = await collectTurnEvents(
session.stream(`Read the file at ${testFile} and tell me what it contains.`)
);
// HARD ASSERT: Turn 2 completed with tool call
expect(turn2.turnCompleted).toBe(true);
expect(turn2.toolCalls.length).toBeGreaterThan(0);
expect(turn2.assistantMessages.length).toBeGreaterThan(0);
// Now load history from the session
const historyEvents: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
historyEvents.push(event);
}
// HARD ASSERT: History contains events
expect(historyEvents.length).toBeGreaterThan(0);
// Extract timeline items from history
const historyTimeline = historyEvents
.filter((e): e is Extract<AgentStreamEvent, { type: "timeline" }> => e.type === "timeline")
.map((e) => e.item);
// HARD ASSERT: History has user messages
const userMessages = historyTimeline.filter(
(item): item is UserMessageTimelineItem => item.type === "user_message"
);
expect(userMessages.length).toBe(2);
// HARD ASSERT: First user message contains our secret code prompt
expect(userMessages[0].text).toContain(secretCode);
// HARD ASSERT: Second user message contains the file read request
expect(userMessages[1].text).toContain("history-test.txt");
// HARD ASSERT: History has assistant messages
const assistantMessages = historyTimeline.filter(
(item): item is AssistantMessageTimelineItem => item.type === "assistant_message"
);
expect(assistantMessages.length).toBeGreaterThan(0);
// HARD ASSERT: History has tool calls from turn 2
const toolCalls = historyTimeline.filter(
(item): item is ToolCallTimelineItem => item.type === "tool_call"
);
expect(toolCalls.length).toBeGreaterThan(0);
// HARD ASSERT: Tool call has correct structure
const firstToolCall = toolCalls[0];
expect(typeof firstToolCall.name).toBe("string");
expect(firstToolCall.name.length).toBeGreaterThan(0);
expect(typeof firstToolCall.callId).toBe("string");
expect(firstToolCall.callId!.length).toBeGreaterThan(0);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
180_000
);
});
@@ -0,0 +1,798 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type {
AgentCapabilityFlags,
AgentClient,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const DEFAULT_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard permission rules",
},
];
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
async function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") {
const port = address.port;
server.close(() => resolve(port));
} else {
server.close(() => reject(new Error("Failed to get port")));
}
});
server.on("error", reject);
});
}
export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
private server: ChildProcess | null = null;
private port: number | null = null;
private startPromise: Promise<{ port: number; url: string }> | null = null;
static getInstance(): OpenCodeServerManager {
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager();
}
return OpenCodeServerManager.instance;
}
async ensureRunning(): Promise<{ port: number; url: string }> {
if (this.startPromise) {
return this.startPromise;
}
if (this.server && this.port && !this.server.killed) {
return { port: this.port, url: `http://127.0.0.1:${this.port}` };
}
this.startPromise = this.startServer();
try {
const result = await this.startPromise;
return result;
} finally {
this.startPromise = null;
}
}
private async startServer(): Promise<{ port: number; url: string }> {
this.port = await findAvailablePort();
const url = `http://127.0.0.1:${this.port}`;
return new Promise((resolve, reject) => {
this.server = spawn("opencode", ["serve", "--port", String(this.port)], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
let started = false;
const timeout = setTimeout(() => {
if (!started) {
reject(new Error("OpenCode server startup timeout"));
}
}, 30_000);
this.server.stdout?.on("data", (data: Buffer) => {
const output = data.toString();
if (output.includes("listening on") && !started) {
started = true;
clearTimeout(timeout);
resolve({ port: this.port!, url });
}
});
this.server.stderr?.on("data", (data: Buffer) => {
console.error("[OpenCode Server]", data.toString().trim());
});
this.server.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
this.server.on("exit", (code) => {
if (!started) {
clearTimeout(timeout);
reject(new Error(`OpenCode server exited with code ${code}`));
}
this.server = null;
this.port = null;
});
});
}
async shutdown(): Promise<void> {
if (this.server && !this.server.killed) {
this.server.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
this.server?.kill("SIGKILL");
resolve();
}, 5000);
this.server?.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
this.server = null;
this.port = null;
}
}
export class OpenCodeAgentClient implements AgentClient {
readonly provider: "opencode" = "opencode";
readonly capabilities = OPENCODE_CAPABILITIES;
private serverManager = OpenCodeServerManager.getInstance();
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const { url } = await this.serverManager.ensureRunning();
const client = createOpencodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
const response = await client.session.create({
directory: openCodeConfig.cwd,
});
if (response.error) {
throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.error)}`);
}
const session = response.data;
if (!session) {
throw new Error("OpenCode session creation returned no data");
}
return new OpenCodeAgentSession(openCodeConfig, client, session.id);
}
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
): Promise<AgentSession> {
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
if (!cwd) {
throw new Error("OpenCode resume requires the original working directory");
}
const config: AgentSessionConfig = {
provider: "opencode",
cwd,
...overrides,
};
const openCodeConfig = this.assertConfig(config);
const { url } = await this.serverManager.ensureRunning();
const client = createOpencodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId);
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const { url } = await this.serverManager.ensureRunning();
const client = createOpencodeClient({
baseUrl: url,
directory: options?.cwd ?? process.cwd(),
});
const response = await client.provider.list({
directory: options?.cwd ?? process.cwd(),
});
if (response.error) {
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
}
const providers = response.data;
if (!providers) {
return [];
}
// Only include models from connected providers (ones that are actually available)
const connectedProviderIds = new Set(providers.connected);
const models: AgentModelDefinition[] = [];
for (const provider of providers.all) {
// Skip providers that aren't connected/configured
if (!connectedProviderIds.has(provider.id)) {
continue;
}
for (const [modelId, model] of Object.entries(provider.models)) {
models.push({
provider: "opencode",
id: `${provider.id}/${modelId}`,
label: model.name,
description: `${provider.name} - ${model.family ?? ""}`.trim(),
metadata: {
providerId: provider.id,
providerName: provider.name,
modelId,
family: model.family,
releaseDate: model.release_date,
supportsAttachments: model.attachment,
supportsReasoning: model.reasoning,
supportsToolCall: model.tool_call,
cost: model.cost,
},
});
}
}
return models;
}
async listPersistedAgents(_options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
// TODO: Implement by listing sessions from OpenCode
return [];
}
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
if (config.provider !== "opencode") {
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`);
}
return { ...config, provider: "opencode" };
}
}
class OpenCodeAgentSession implements AgentSession {
readonly provider: "opencode" = "opencode";
readonly capabilities = OPENCODE_CAPABILITIES;
private readonly config: OpenCodeAgentConfig;
private readonly client: OpencodeClient;
private readonly sessionId: string;
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null;
private accumulatedUsage: AgentUsage = {};
/** Tracks the role of each message by ID to distinguish user from assistant messages */
private messageRoles = new Map<string, "user" | "assistant">();
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string
) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
}
get id(): string | null {
return this.sessionId;
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
return {
provider: "opencode",
sessionId: this.sessionId,
model: this.config.model ?? null,
modeId: this.currentMode,
};
}
async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
const events = this.stream(prompt);
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentUsage | undefined;
for await (const event of events) {
if (event.type === "timeline") {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
finalText = event.item.text;
}
} else if (event.type === "turn_completed") {
usage = event.usage;
} else if (event.type === "turn_failed") {
throw new Error(event.error);
}
}
return {
sessionId: this.sessionId,
finalText,
usage,
timeline,
};
}
async *stream(
prompt: AgentPromptInput,
_options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> {
this.abortController = new AbortController();
const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model);
// Send prompt asynchronously
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
...(model ? { model } : {}),
});
if (promptResponse.error) {
yield {
type: "turn_failed",
provider: "opencode",
error: JSON.stringify(promptResponse.error),
};
return;
}
// Subscribe to events
const eventsResult = await this.client.event.subscribe({
directory: this.config.cwd,
});
try {
for await (const event of eventsResult.stream) {
if (this.abortController.signal.aborted) {
break;
}
const translated = this.translateEvent(event);
for (const e of translated) {
yield e;
if (e.type === "turn_completed" || e.type === "turn_failed") {
return;
}
}
}
} catch (error) {
if (!this.abortController.signal.aborted) {
yield {
type: "turn_failed",
provider: "opencode",
error: error instanceof Error ? error.message : "Stream error",
};
}
}
}
async interrupt(): Promise<void> {
this.abortController?.abort();
await this.client.session.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
});
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
const response = await this.client.session.messages({
sessionID: this.sessionId,
directory: this.config.cwd,
});
if (response.error || !response.data) {
return;
}
const messages = response.data;
for (const message of messages) {
const { info, parts } = message;
const role = info.role as "user" | "assistant";
if (role === "user") {
// Extract user message text from parts
const textParts = parts.filter(
(p) => (p as { type?: string }).type === "text"
);
const text = textParts
.map((p) => (p as { text?: string }).text ?? "")
.join("");
if (text) {
yield {
type: "timeline",
provider: "opencode",
item: { type: "user_message", text },
};
}
} else if (role === "assistant") {
// Process each part
for (const part of parts) {
const partType = (part as { type?: string }).type;
if (partType === "text") {
const text = (part as { text?: string }).text;
if (text) {
yield {
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text },
};
}
} else if (partType === "reasoning") {
const text = (part as { text?: string }).text;
if (text) {
yield {
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text },
};
}
} else if (partType === "tool") {
const toolPart = part as {
id?: string;
tool?: string;
callID?: string;
state?: {
status?: string;
input?: AgentMetadata;
output?: string;
error?: string;
};
};
const toolName = toolPart.tool;
const state = toolPart.state;
if (toolName) {
yield {
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
name: toolName,
callId: toolPart.callID ?? toolPart.id,
status: this.mapToolState(state?.status),
input: state?.input,
output: state?.output,
error: state?.error,
},
};
}
}
}
}
}
}
async getAvailableModes(): Promise<AgentMode[]> {
return DEFAULT_MODES;
}
async getCurrentMode(): Promise<string | null> {
return this.currentMode;
}
async setMode(modeId: string): Promise<void> {
this.currentMode = modeId;
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values());
}
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
const pending = this.pendingPermissions.get(requestId);
if (!pending) {
throw new Error(`No pending permission request with id '${requestId}'`);
}
const reply = response.behavior === "allow" ? "once" : "reject";
await this.client.permission.reply({
requestID: requestId,
directory: this.config.cwd,
reply,
message: response.behavior === "deny" ? response.message : undefined,
});
this.pendingPermissions.delete(requestId);
}
describePersistence(): AgentPersistenceHandle | null {
return {
provider: "opencode",
sessionId: this.sessionId,
nativeHandle: this.sessionId,
metadata: {
cwd: this.config.cwd,
},
};
}
async close(): Promise<void> {
this.abortController?.abort();
}
private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> {
if (typeof prompt === "string") {
return [{ type: "text", text: prompt }];
}
return prompt
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => ({ type: "text", text: p.text }));
}
private parseModel(model?: string): { providerID: string; modelID: string } | undefined {
if (!model) {
return undefined;
}
const parts = model.split("/");
if (parts.length >= 2) {
return { providerID: parts[0], modelID: parts.slice(1).join("/") };
}
return { providerID: "opencode", modelID: model };
}
private translateEvent(event: unknown): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
if (!event || typeof event !== "object") {
return events;
}
const e = event as { type?: string; properties?: AgentMetadata };
const type = e.type;
const props = e.properties ?? {};
switch (type) {
case "session.created":
case "session.updated": {
const sessionId = props.id as string | undefined;
if (sessionId === this.sessionId) {
events.push({
type: "thread_started",
sessionId: this.sessionId,
provider: "opencode",
});
}
break;
}
case "message.updated": {
// Track message roles by ID so we can distinguish user from assistant parts
const info = props.info as AgentMetadata | undefined;
if (!info) {
break;
}
const messageId = info.id as string | undefined;
const messageSessionId = info.sessionID as string | undefined;
const role = info.role as "user" | "assistant" | undefined;
if (messageId && messageSessionId === this.sessionId && role) {
this.messageRoles.set(messageId, role);
}
break;
}
case "message.part.updated": {
// Structure: { part: { id, sessionID, messageID, type, text?, ... }, delta?: string }
const part = props.part as AgentMetadata | undefined;
const delta = props.delta as string | undefined;
if (!part) {
break;
}
const partSessionId = part.sessionID as string | undefined;
if (partSessionId !== this.sessionId) {
break;
}
const messageId = part.messageID as string | undefined;
const messageRole = messageId ? this.messageRoles.get(messageId) : undefined;
const partType = part.type as string | undefined;
const partTime = part.time as { start?: number; end?: number } | undefined;
if (partType === "text") {
// Skip user messages - agent-manager emits user_message via recordUserMessage
if (messageRole === "user") {
break;
}
// Skip if role unknown AND no delta (likely user message before role is known)
if (!messageRole && !delta) {
break;
}
// Emit delta for streaming, or full text only when complete (has time.end)
if (delta) {
events.push({
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: delta },
});
} else if (partTime?.end) {
// Final complete text - only emit if we haven't been streaming deltas
// (In practice, if delta was present, we've already streamed it all)
const text = part.text as string | undefined;
if (text) {
events.push({
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text },
});
}
}
} else if (partType === "reasoning") {
// Emit delta for streaming reasoning
if (delta) {
events.push({
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text: delta },
});
} else if (partTime?.end) {
const text = part.text as string | undefined;
if (text) {
events.push({
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text },
});
}
}
} else if (partType === "tool") {
// Tool parts: { tool: string, state: { status, input, output?, error? } }
const toolName = part.tool as string | undefined;
const state = part.state as AgentMetadata | undefined;
const status = state?.status as string | undefined;
const input = state?.input as AgentMetadata | undefined;
const output = state?.output as string | undefined;
const error = state?.error as string | undefined;
if (toolName) {
events.push({
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
name: toolName,
callId: part.callID as string | undefined,
status: this.mapToolState(status),
input,
output,
error,
},
});
}
} else if (partType === "step-finish") {
// Extract usage from step-finish parts
const tokens = part.tokens as { input?: number; output?: number; reasoning?: number } | undefined;
const cost = part.cost as number | undefined;
if (tokens) {
this.accumulatedUsage.inputTokens = (this.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
this.accumulatedUsage.outputTokens = (this.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
}
if (cost !== undefined) {
this.accumulatedUsage.totalCostUsd = (this.accumulatedUsage.totalCostUsd ?? 0) + cost;
}
}
break;
}
case "permission.asked": {
// props IS the PermissionRequest directly
const sessionId = props.sessionID as string | undefined;
if (sessionId !== this.sessionId) {
break;
}
const requestId = props.id as string;
const permission = props.permission as string;
const metadata = props.metadata as AgentMetadata | undefined;
const patterns = props.patterns as string[] | undefined;
const permRequest: AgentPermissionRequest = {
id: requestId,
provider: "opencode",
name: permission,
kind: "tool",
title: permission,
description: patterns?.join(", "),
input: metadata,
};
this.pendingPermissions.set(requestId, permRequest);
events.push({
type: "permission_requested",
provider: "opencode",
request: permRequest,
});
break;
}
case "session.idle": {
const sessionId = props.sessionID as string | undefined;
if (sessionId === this.sessionId) {
const usage = this.extractAndResetUsage();
events.push({
type: "turn_completed",
provider: "opencode",
usage,
});
}
break;
}
case "session.error": {
const sessionId = props.sessionID as string | undefined;
if (sessionId === this.sessionId) {
const error = props.error as string | undefined;
events.push({
type: "turn_failed",
provider: "opencode",
error: error ?? "Unknown error",
});
}
break;
}
}
return events;
}
private mapToolState(state?: string): string {
switch (state) {
case "pending":
return "pending";
case "running":
return "running";
case "complete":
return "completed";
case "error":
return "failed";
default:
return "pending";
}
}
private extractAndResetUsage(): AgentUsage | undefined {
const usage = this.accumulatedUsage;
this.accumulatedUsage = {};
if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) {
return undefined;
}
return usage;
}
}
+2 -4
View File
@@ -14,11 +14,10 @@ import { initializeTTS, type TTSConfig } from "./agent/tts-openai.js";
import { listConversations, deleteConversation } from "./persistence.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentRegistry } from "./agent/agent-registry.js";
import { ClaudeAgentClient } from "./agent/providers/claude-agent.js";
import { CodexMcpAgentClient } from "./agent/providers/codex-mcp-agent.js";
import { initializeTitleGenerator } from "../services/agent-title-generator.js";
import { attachAgentRegistryPersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients } from "./agent/provider-registry.js";
import type {
AgentClient,
AgentControlMcpConfig,
@@ -189,8 +188,7 @@ export async function createPaseoDaemon(
const agentRegistry = new AgentRegistry(config.agentRegistryPath);
const agentManager = new AgentManager({
clients: {
claude: new ClaudeAgentClient(),
codex: new CodexMcpAgentClient(),
...createAllClients(),
...config.agentClients,
},
registry: agentRegistry,
+1 -4
View File
@@ -4,6 +4,7 @@ import {
type ManagedAgent,
} from "./agent/agent-manager.js";
import { toAgentPayload } from "./agent/agent-projections.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
import type {
AgentCapabilityFlags,
AgentModelDefinition,
@@ -11,16 +12,12 @@ import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentProvider,
AgentRuntimeInfo,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
} from "./agent/agent-sdk-types.js";
const AGENT_PROVIDERS: [AgentProvider, AgentProvider] = ["claude", "codex"];
const AgentProviderSchema = z.enum(AGENT_PROVIDERS);
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
+8 -11
View File
@@ -36,7 +36,7 @@ import {
} from "./persistence-hooks.js";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { fetchProviderModelCatalog } from "./agent/model-catalog.js";
import { fetchProviderModels } from "./agent/provider-registry.js";
import { AgentManager } from "./agent/agent-manager.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import { toAgentPayload } from "./agent/agent-projections.js";
@@ -50,6 +50,7 @@ import type {
AgentPersistenceHandle,
} from "./agent/agent-sdk-types.js";
import { AgentRegistry, type StoredAgentRecord } from "./agent/agent-registry.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import {
listDirectoryEntries,
readExplorerFile,
@@ -81,7 +82,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
const ACTIVE_TITLE_GENERATIONS = new Set<string>();
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
let restartRequested = false;
const KNOWN_AGENT_PROVIDERS: AgentProvider[] = ["claude", "codex"];
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
const RESTART_EXIT_DELAY_MS = 250;
type ProcessingPhase = "idle" | "transcribing" | "llm";
@@ -153,18 +154,14 @@ function convertPCMToWavBuffer(
return wavBuffer;
}
function isKnownAgentProvider(value: string): value is AgentProvider {
return KNOWN_AGENT_PROVIDERS.includes(value as AgentProvider);
}
function coerceAgentProvider(value: string, agentId?: string): AgentProvider {
if (isKnownAgentProvider(value)) {
if (isValidAgentProvider(value)) {
return value;
}
console.warn(
`[Session] Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to 'claude'`
`[Session] Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`
);
return "claude";
return DEFAULT_AGENT_PROVIDER;
}
function toAgentPersistenceHandle(
@@ -174,7 +171,7 @@ function toAgentPersistenceHandle(
return null;
}
const provider = handle.provider;
if (!isKnownAgentProvider(provider)) {
if (!isValidAgentProvider(provider)) {
console.warn(
`[Session] Ignoring persistence handle with unknown provider '${provider}'`
);
@@ -1583,7 +1580,7 @@ export class Session {
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const models = await fetchProviderModelCatalog(msg.provider, {
const models = await fetchProviderModels(msg.provider, {
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
});
this.emit({
@@ -13,7 +13,7 @@ import type {
AgentPersistenceHandle,
AgentProvider,
} from "../agent/agent-sdk-types.js";
import { getAgentProviderDefinition } from "../agent/provider-manifest.js";
import { getAgentProviderDefinition } from "../agent/provider-registry.js";
// ============================================================================
// Configuration
@@ -84,8 +84,10 @@ export async function createTestPaseoDaemon(
const close = async (): Promise<void> => {
await daemon.close().catch(() => undefined);
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
// Wait a bit for file handles to release
await new Promise((r) => setTimeout(r, 200));
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
};
return {