Fix reasoning timeline UI and type errors
This commit is contained in:
@@ -354,6 +354,14 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
finalized = true;
|
||||
|
||||
if (agent.pendingRun !== iterator) {
|
||||
if (error) {
|
||||
agent.lastError = error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
agent.pendingRun = null;
|
||||
agent.status = error ? "error" : "idle";
|
||||
agent.lastError = error;
|
||||
@@ -396,11 +404,30 @@ export class AgentManager {
|
||||
|
||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (!agent.pendingRun || typeof agent.pendingRun.return !== "function") {
|
||||
const pendingRun = agent.pendingRun;
|
||||
if (!pendingRun || typeof pendingRun.return !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await agent.pendingRun.return(undefined as unknown as AgentStreamEvent);
|
||||
await agent.session.interrupt();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[AgentManager] Failed to interrupt session for agent ${agentId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const cancellation = pendingRun.return(
|
||||
undefined as unknown as AgentStreamEvent
|
||||
);
|
||||
void cancellation.catch((error) => {
|
||||
console.error(
|
||||
`[AgentManager] Failed to cancel run for agent ${agentId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -12,6 +12,15 @@ export type AgentMode = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type AgentModelDefinition = {
|
||||
provider: AgentProvider;
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AgentCapabilityFlags = {
|
||||
supportsStreaming: boolean;
|
||||
supportsSessionPersistence: boolean;
|
||||
@@ -159,6 +168,7 @@ export interface AgentSession {
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
fetchClaudeModelCatalog,
|
||||
fetchCodexModelCatalog,
|
||||
} from "./model-catalog.js";
|
||||
|
||||
describe("provider model catalogs", () => {
|
||||
test(
|
||||
"Claude catalog exposes Sonnet and Haiku variants",
|
||||
async () => {
|
||||
const models = await fetchClaudeModelCatalog();
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
const descriptions = 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);
|
||||
},
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"Codex catalog exposes gpt-5.1-codex",
|
||||
async () => {
|
||||
const models = await fetchCodexModelCatalog();
|
||||
const ids = models.map((model) => model.id);
|
||||
expect(ids).toContain("gpt-5.1-codex");
|
||||
},
|
||||
180_000
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
query,
|
||||
type ModelInfo as ClaudeModelInfo,
|
||||
type Options as ClaudeOptions,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
type ProviderModelCatalogOptions = {
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
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"],
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
|
||||
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", {})) as CodexModelListResponse;
|
||||
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: Record<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: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
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: Record<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();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,40 @@ async function autoApprove(session: Awaited<ReturnType<ClaudeAgentClient["create
|
||||
}
|
||||
}
|
||||
|
||||
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
function extractCommandText(input: unknown): string | null {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
const command = (input as { command?: unknown }).command;
|
||||
if (typeof command === "string" && command.length > 0) {
|
||||
return command;
|
||||
}
|
||||
if (Array.isArray(command)) {
|
||||
const tokens = command.filter((value): value is string => typeof value === "string");
|
||||
if (tokens.length > 0) {
|
||||
return tokens.join(" ");
|
||||
}
|
||||
}
|
||||
if (typeof (input as { description?: string }).description === "string") {
|
||||
const description = (input as { description?: string }).description as string;
|
||||
if (description.length > 0) {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
||||
if (display.includes("sleep 60")) {
|
||||
return true;
|
||||
}
|
||||
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
||||
return inputCommand.includes("sleep 60");
|
||||
}
|
||||
|
||||
describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
test(
|
||||
"responds with text",
|
||||
@@ -242,6 +276,68 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"interrupts a long-running bash command before it finishes",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
cwd,
|
||||
extra: { claude: { maxThinkingTokens: 2048 } },
|
||||
};
|
||||
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
|
||||
let runStartedAt: number | null = null;
|
||||
let durationMs = 0;
|
||||
let sawSleepCommand = false;
|
||||
let interruptIssued = false;
|
||||
|
||||
try {
|
||||
session = await client.createSession(config);
|
||||
const prompt = [
|
||||
"Use your Bash command tool to run the exact command `sleep 60`.",
|
||||
"Do not run any other commands or respond until that command finishes.",
|
||||
].join(" ");
|
||||
|
||||
runStartedAt = Date.now();
|
||||
const events = session.stream(prompt);
|
||||
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
|
||||
if (event.type === "timeline" && event.item.type === "tool_call" && isSleepCommandToolCall(event.item)) {
|
||||
sawSleepCommand = true;
|
||||
if (!interruptIssued) {
|
||||
interruptIssued = true;
|
||||
await session.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (runStartedAt === null) {
|
||||
throw new Error("Claude run never started");
|
||||
}
|
||||
durationMs = Date.now() - runStartedAt;
|
||||
} finally {
|
||||
if (durationMs === 0 && runStartedAt !== null) {
|
||||
durationMs = Date.now() - runStartedAt;
|
||||
}
|
||||
await session?.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(sawSleepCommand).toBe(true);
|
||||
expect(interruptIssued).toBe(true);
|
||||
expect(durationMs).toBeGreaterThan(0);
|
||||
expect(durationMs).toBeLessThan(60_000);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
|
||||
test(
|
||||
"supports multi-turn conversations",
|
||||
async () => {
|
||||
|
||||
@@ -216,6 +216,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private turnCancelRequested = false;
|
||||
private streamedAssistantTextThisTurn = false;
|
||||
private streamedReasoningThisTurn = false;
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
config: ClaudeAgentConfig,
|
||||
@@ -282,6 +283,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
void this.interruptActiveTurn();
|
||||
queue.end();
|
||||
};
|
||||
this.cancelCurrentTurn = requestCancel;
|
||||
if (this.historyPending && this.persistedHistory.length > 0) {
|
||||
for (const item of this.persistedHistory) {
|
||||
queue.push({ type: "timeline", item, provider: "claude" });
|
||||
@@ -306,9 +308,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (this.eventQueue === queue) {
|
||||
this.eventQueue = null;
|
||||
}
|
||||
if (this.cancelCurrentTurn === requestCancel) {
|
||||
this.cancelCurrentTurn = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
this.cancelCurrentTurn?.();
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
if (!this.historyPending || this.persistedHistory.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentPermissionRequest,
|
||||
AgentPersistenceHandle,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
@@ -48,6 +49,34 @@ function log(message: string): void {
|
||||
console.info(`[CodexAgentTest] ${message}`);
|
||||
}
|
||||
|
||||
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
function commandTextFromInput(input: unknown): string | null {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
const command = (input as { command?: unknown }).command;
|
||||
if (typeof command === "string" && command.length > 0) {
|
||||
return command;
|
||||
}
|
||||
if (Array.isArray(command)) {
|
||||
const tokens = command.filter((value): value is string => typeof value === "string");
|
||||
if (tokens.length > 0) {
|
||||
return tokens.join(" ");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
||||
if (display.includes("sleep 60")) {
|
||||
return true;
|
||||
}
|
||||
const inputText = commandTextFromInput(item.input)?.toLowerCase() ?? "";
|
||||
return inputText.includes("sleep 60");
|
||||
}
|
||||
|
||||
describe("CodexAgentClient (SDK integration)", () => {
|
||||
test(
|
||||
"responds with text",
|
||||
@@ -392,6 +421,76 @@ describe("CodexAgentClient (SDK integration)", () => {
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"interrupts a long-running shell command before it completes",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const restoreSessionDir = useTempCodexSessionDir();
|
||||
const client = new CodexAgentClient();
|
||||
const config: AgentSessionConfig = { provider: "codex", cwd, modeId: "full-access" };
|
||||
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
|
||||
let runStartedAt: number | null = null;
|
||||
let durationMs = 0;
|
||||
let sawSleepCommand = false;
|
||||
let interruptIssued = false;
|
||||
|
||||
try {
|
||||
session = await client.createSession(config);
|
||||
log("Launching sleep command interrupt test");
|
||||
|
||||
const prompt = [
|
||||
"Run the exact shell command `sleep 60` using your shell tool.",
|
||||
"Do not run any additional commands or send a response until that command finishes.",
|
||||
].join(" ");
|
||||
|
||||
runStartedAt = Date.now();
|
||||
const stream = session.stream(prompt);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === "permission_requested" && session) {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.provider === "codex" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.server === "command" &&
|
||||
isSleepCommandToolCall(event.item)
|
||||
) {
|
||||
sawSleepCommand = true;
|
||||
if (!interruptIssued) {
|
||||
interruptIssued = true;
|
||||
await session.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (runStartedAt === null) {
|
||||
throw new Error("Codex run never started");
|
||||
}
|
||||
durationMs = Date.now() - runStartedAt;
|
||||
} finally {
|
||||
if (durationMs === 0 && runStartedAt !== null) {
|
||||
durationMs = Date.now() - runStartedAt;
|
||||
}
|
||||
await session?.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
restoreSessionDir();
|
||||
}
|
||||
|
||||
expect(sawSleepCommand).toBe(true);
|
||||
expect(interruptIssued).toBe(true);
|
||||
expect(durationMs).toBeGreaterThan(0);
|
||||
expect(durationMs).toBeLessThan(60_000);
|
||||
},
|
||||
90_000
|
||||
);
|
||||
|
||||
// Codex CLI currently doesn't emit approval request events even when approvalPolicy is set to on-request,
|
||||
// so we keep this test skipped until upstream support lands.
|
||||
test.skip(
|
||||
|
||||
@@ -271,6 +271,7 @@ class CodexAgentSession implements AgentSession {
|
||||
private rolloutPath: string | null;
|
||||
private historyEvents: AgentStreamEvent[] = [];
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
|
||||
constructor(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle) {
|
||||
this.codex = codex;
|
||||
@@ -337,6 +338,29 @@ class CodexAgentSession implements AgentSession {
|
||||
const { events } = await thread.runStreamed(input, turnOptions);
|
||||
|
||||
let finishedNaturally = false;
|
||||
let cancelIssued = false;
|
||||
const requestCancel = () => {
|
||||
if (cancelIssued) {
|
||||
return;
|
||||
}
|
||||
cancelIssued = true;
|
||||
if (typeof events.return !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cancellation = events.return({
|
||||
type: "turn.completed",
|
||||
} as ThreadEvent);
|
||||
void cancellation.catch((error) => {
|
||||
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
|
||||
}
|
||||
};
|
||||
|
||||
this.cancelCurrentTurn = requestCancel;
|
||||
|
||||
try {
|
||||
for await (const rawEvent of events) {
|
||||
yield* this.translateEvent(rawEvent);
|
||||
@@ -350,16 +374,19 @@ class CodexAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!finishedNaturally && typeof events.return === "function") {
|
||||
try {
|
||||
await events.return({ type: "turn.completed" } as ThreadEvent);
|
||||
} catch (error) {
|
||||
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
|
||||
}
|
||||
if (!finishedNaturally) {
|
||||
requestCancel();
|
||||
}
|
||||
if (this.cancelCurrentTurn === requestCancel) {
|
||||
this.cancelCurrentTurn = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
this.cancelCurrentTurn?.();
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
const replayEvents = this.drainHistoryEvents();
|
||||
for (const event of replayEvents) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentModelDefinition,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
@@ -45,6 +46,15 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
||||
supportsStreaming: z.boolean(),
|
||||
supportsSessionPersistence: z.boolean(),
|
||||
@@ -345,6 +355,13 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ListProviderModelsRequestMessageSchema = z.object({
|
||||
type: z.literal("list_provider_models_request"),
|
||||
provider: AgentProviderSchema,
|
||||
cwd: z.string().optional(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GitRepoInfoRequestMessageSchema = z.object({
|
||||
type: z.literal("git_repo_info_request"),
|
||||
cwd: z.string(),
|
||||
@@ -447,6 +464,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SendAgentMessageSchema,
|
||||
SendAgentAudioSchema,
|
||||
CreateAgentRequestMessageSchema,
|
||||
ListProviderModelsRequestMessageSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
RefreshAgentRequestMessageSchema,
|
||||
CancelAgentRequestMessageSchema,
|
||||
@@ -701,6 +719,17 @@ export const GitRepoInfoResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ListProviderModelsResponseMessageSchema = z.object({
|
||||
type: z.literal("list_provider_models_response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
models: z.array(AgentModelDefinitionSchema).optional(),
|
||||
error: z.string().optional(),
|
||||
fetchedAt: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ActivityLogMessageSchema,
|
||||
AssistantChunkMessageSchema,
|
||||
@@ -723,6 +752,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
GitDiffResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
GitRepoInfoResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
]);
|
||||
|
||||
export type SessionOutboundMessage = z.infer<
|
||||
@@ -750,6 +780,9 @@ export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionReques
|
||||
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
||||
export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
|
||||
export type ListPersistedAgentsResponseMessage = z.infer<typeof ListPersistedAgentsResponseSchema>;
|
||||
export type ListProviderModelsResponseMessage = z.infer<
|
||||
typeof ListProviderModelsResponseMessageSchema
|
||||
>;
|
||||
|
||||
// Type exports for payload types
|
||||
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
||||
@@ -760,6 +793,9 @@ export type RealtimeAudioChunkMessage = z.infer<typeof RealtimeAudioChunkMessage
|
||||
export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>;
|
||||
export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
|
||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||
export type ListProviderModelsRequestMessage = z.infer<
|
||||
typeof ListProviderModelsRequestMessageSchema
|
||||
>;
|
||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
||||
export type ListPersistedAgentsRequestMessage = z.infer<typeof ListPersistedAgentsRequestMessageSchema>;
|
||||
|
||||
@@ -37,6 +37,7 @@ import { experimental_createMCPClient } from "ai";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createTerminalMcpServer } from "./terminal-mcp/index.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { fetchProviderModelCatalog } from "./agent/model-catalog.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||
import type {
|
||||
@@ -748,6 +749,10 @@ export class Session {
|
||||
case "git_repo_info_request":
|
||||
await this.handleGitRepoInfoRequest(msg);
|
||||
break;
|
||||
|
||||
case "list_provider_models_request":
|
||||
await this.handleListProviderModelsRequest(msg);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
@@ -1167,24 +1172,27 @@ export class Session {
|
||||
await this.forwardAgentState(snapshot);
|
||||
|
||||
const trimmedPrompt = initialPrompt?.trim();
|
||||
if (trimmedPrompt && trimmedPrompt.length > 0) {
|
||||
if (trimmedPrompt) {
|
||||
try {
|
||||
this.agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
||||
} catch (recordError) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to record initial prompt for agent ${snapshot.id}:`,
|
||||
recordError
|
||||
await this.handleSendAgentMessage(
|
||||
snapshot.id,
|
||||
trimmedPrompt,
|
||||
uuidv4()
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedPrompt = this.buildAgentPrompt(trimmedPrompt);
|
||||
this.startAgentStream(snapshot.id, normalizedPrompt);
|
||||
} catch (promptError) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to run initial prompt for agent ${snapshot.id}:`,
|
||||
promptError
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1297,6 +1305,7 @@ export class Session {
|
||||
let snapshot: AgentSnapshot;
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
snapshot = await this.agentManager.refreshAgentFromPersistence(
|
||||
agentId
|
||||
);
|
||||
@@ -1478,6 +1487,40 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleListProviderModelsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
|
||||
): Promise<void> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
try {
|
||||
const models = await fetchProviderModelCatalog(msg.provider, {
|
||||
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||||
});
|
||||
this.emit({
|
||||
type: "list_provider_models_response",
|
||||
payload: {
|
||||
provider: msg.provider,
|
||||
models,
|
||||
fetchedAt,
|
||||
...(msg.requestId ? { requestId: msg.requestId } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to list models for ${msg.provider}:`,
|
||||
error
|
||||
);
|
||||
this.emit({
|
||||
type: "list_provider_models_response",
|
||||
payload: {
|
||||
provider: msg.provider,
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
fetchedAt,
|
||||
...(msg.requestId ? { requestId: msg.requestId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeGitOptions(
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string
|
||||
|
||||
Reference in New Issue
Block a user