Merge remote-tracking branch 'origin/codebuddy' into codebuddy
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* Agent Output Parser
|
||||
*
|
||||
* Parses JSON Lines output from `codex exec --json` and similar structured
|
||||
* agent output into display-friendly text segments.
|
||||
*/
|
||||
|
||||
export interface AgentOutputSegment {
|
||||
type: 'thinking' | 'text' | 'command' | 'command_output' | 'file_change' | 'plan' | 'error' | 'usage';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a single line of agent output.
|
||||
* Returns structured segment(s) if it's a recognized JSON event,
|
||||
* or null if it's not JSON / not recognized (caller should treat as plain text).
|
||||
*/
|
||||
export function parseAgentJsonLine(line: string): AgentOutputSegment[] | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith('{')) return null;
|
||||
|
||||
let event: Record<string, unknown>;
|
||||
try {
|
||||
event = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!event.type) return null;
|
||||
|
||||
const type = event.type as string;
|
||||
const item = event.item as Record<string, unknown> | undefined;
|
||||
|
||||
// thread.started / turn.started — skip silently
|
||||
if (type === 'thread.started' || type === 'turn.started') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// turn.completed — show token usage
|
||||
if (type === 'turn.completed') {
|
||||
const usage = event.usage as { input_tokens?: number; output_tokens?: number } | undefined;
|
||||
if (usage) {
|
||||
return [{
|
||||
type: 'usage',
|
||||
content: `tokens: ${usage.input_tokens ?? '?'} in / ${usage.output_tokens ?? '?'} out`,
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// error
|
||||
if (type === 'error' || type === 'turn.failed') {
|
||||
const msg = (event.message as string)
|
||||
|| ((event.error as Record<string, unknown>)?.message as string)
|
||||
|| JSON.stringify(event);
|
||||
return [{ type: 'error', content: msg }];
|
||||
}
|
||||
|
||||
// item events
|
||||
if (type.startsWith('item.') && item) {
|
||||
return parseItemEvent(type, item);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseItemEvent(
|
||||
eventType: string,
|
||||
item: Record<string, unknown>,
|
||||
): AgentOutputSegment[] {
|
||||
const itemType = item.type as string;
|
||||
|
||||
// reasoning (thinking)
|
||||
if (itemType === 'reasoning') {
|
||||
if (eventType !== 'item.completed') return [];
|
||||
const text = item.text as string || '';
|
||||
if (!text.trim()) return [];
|
||||
return [{ type: 'thinking', content: text }];
|
||||
}
|
||||
|
||||
// agent_message (final response text)
|
||||
if (itemType === 'agent_message') {
|
||||
if (eventType !== 'item.completed') return [];
|
||||
const text = item.text as string || '';
|
||||
if (!text.trim()) return [];
|
||||
return [{ type: 'text', content: text }];
|
||||
}
|
||||
|
||||
// command_execution
|
||||
if (itemType === 'command_execution') {
|
||||
const segments: AgentOutputSegment[] = [];
|
||||
const command = item.command as string || '';
|
||||
const output = item.aggregated_output as string || '';
|
||||
const exitCode = item.exit_code as number | null;
|
||||
|
||||
if (eventType === 'item.started' && command) {
|
||||
segments.push({ type: 'command', content: command });
|
||||
}
|
||||
|
||||
if (eventType === 'item.completed') {
|
||||
if (command) {
|
||||
segments.push({ type: 'command', content: command });
|
||||
}
|
||||
if (output.trim()) {
|
||||
segments.push({ type: 'command_output', content: output.trim() });
|
||||
}
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
segments.push({ type: 'error', content: `exit code: ${exitCode}` });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// file_change
|
||||
if (itemType === 'file_change') {
|
||||
if (eventType !== 'item.completed') return [];
|
||||
const changes = item.changes as Array<{ path: string; kind: string }> | undefined;
|
||||
if (!changes?.length) return [];
|
||||
const lines = changes.map(c => `${c.kind}: ${c.path}`).join('\n');
|
||||
return [{ type: 'file_change', content: lines }];
|
||||
}
|
||||
|
||||
// todo_list / plan
|
||||
if (itemType === 'todo_list') {
|
||||
const items = item.items as Array<{ text: string; completed: boolean }> | undefined;
|
||||
if (!items?.length) return [];
|
||||
const lines = items.map(t => `${t.completed ? '✓' : '○'} ${t.text}`).join('\n');
|
||||
return [{ type: 'plan', content: lines }];
|
||||
}
|
||||
|
||||
// mcp_tool_call
|
||||
if (itemType === 'mcp_tool_call') {
|
||||
const tool = item.tool as string || 'unknown';
|
||||
const server = item.server as string || '';
|
||||
if (eventType === 'item.started') {
|
||||
return [{ type: 'command', content: `[MCP] ${server}/${tool}` }];
|
||||
}
|
||||
if (eventType === 'item.completed') {
|
||||
const result = item.result as Record<string, unknown> | null;
|
||||
const error = item.error as string | null;
|
||||
if (error) {
|
||||
return [{ type: 'error', content: `MCP ${tool}: ${error}` }];
|
||||
}
|
||||
if (result) {
|
||||
const content = (result.content as Array<{ text?: string }>) || [];
|
||||
const text = content.map(c => c.text || '').filter(Boolean).join('\n');
|
||||
if (text) return [{ type: 'command_output', content: text }];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format AgentOutputSegments into markdown text for display.
|
||||
*/
|
||||
export function formatSegmentsAsMarkdown(segments: AgentOutputSegment[]): string {
|
||||
return segments.map(seg => {
|
||||
switch (seg.type) {
|
||||
case 'thinking':
|
||||
return `> **Thinking:** ${seg.content}\n\n`;
|
||||
case 'text':
|
||||
return seg.content + '\n\n';
|
||||
case 'command':
|
||||
return `\`\`\`bash\n$ ${seg.content}\n\`\`\`\n\n`;
|
||||
case 'command_output':
|
||||
return `\`\`\`\n${seg.content}\n\`\`\`\n\n`;
|
||||
case 'file_change':
|
||||
return `**Files changed:**\n\`\`\`\n${seg.content}\n\`\`\`\n\n`;
|
||||
case 'plan':
|
||||
return `**Plan:**\n${seg.content}\n\n`;
|
||||
case 'error':
|
||||
return `**Error:** ${seg.content}\n\n`;
|
||||
case 'usage':
|
||||
return `---\n*${seg.content}*\n`;
|
||||
default:
|
||||
return seg.content;
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
289
infrastructure/ai/contextCompaction.test.ts
Normal file
289
infrastructure/ai/contextCompaction.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ModelMessage } from "ai";
|
||||
|
||||
import {
|
||||
buildCompactedMessages,
|
||||
estimateModelMessagesTokens,
|
||||
estimateUnknownTokens,
|
||||
findSafeCompactionSplitIndex,
|
||||
formatMessagesForCompaction,
|
||||
prepareContextCompaction,
|
||||
resolveContextWindow,
|
||||
shouldCompactContext,
|
||||
} from "./contextCompaction.ts";
|
||||
|
||||
test("shouldCompactContext waits until the prompt approaches the context window", () => {
|
||||
assert.equal(shouldCompactContext({ promptTokens: 70, contextWindow: 100 }), false);
|
||||
assert.equal(shouldCompactContext({ promptTokens: 85, contextWindow: 100 }), true);
|
||||
});
|
||||
|
||||
test("findSafeCompactionSplitIndex keeps recent messages intact", () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: "user", content: "old 1" },
|
||||
{ role: "assistant", content: "old 2" },
|
||||
{ role: "user", content: "recent 1" },
|
||||
{ role: "assistant", content: "recent 2" },
|
||||
];
|
||||
|
||||
assert.equal(findSafeCompactionSplitIndex(messages, 2), 2);
|
||||
});
|
||||
|
||||
test("findSafeCompactionSplitIndex avoids orphaning a tool result", () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: "user", content: "old" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "run_command",
|
||||
input: { command: "pwd" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "run_command",
|
||||
output: { type: "text", value: "/tmp" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "recent" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
];
|
||||
|
||||
assert.equal(findSafeCompactionSplitIndex(messages, 3), 1);
|
||||
});
|
||||
|
||||
test("buildCompactedMessages places the summary before recent messages", () => {
|
||||
const recentMessages: ModelMessage[] = [
|
||||
{ role: "user", content: "what next?" },
|
||||
];
|
||||
|
||||
const compacted = buildCompactedMessages({
|
||||
summary: "Earlier work is summarized here.",
|
||||
recentMessages,
|
||||
});
|
||||
|
||||
assert.deepEqual(compacted, [
|
||||
{
|
||||
role: "user",
|
||||
content: "[Previous conversation summary]\n\nEarlier work is summarized here.\n\n[Continue with the recent messages below.]",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I understand the previous conversation summary and will continue from the recent messages.",
|
||||
},
|
||||
{ role: "user", content: "what next?" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("prepareContextCompaction summarizes old messages and returns compacted context", async () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: "user", content: "old ".repeat(40) },
|
||||
{ role: "assistant", content: "older ".repeat(40) },
|
||||
{ role: "user", content: "recent question" },
|
||||
{ role: "assistant", content: "recent answer" },
|
||||
];
|
||||
|
||||
const result = await prepareContextCompaction({
|
||||
messages,
|
||||
contextWindow: 100,
|
||||
protectRecentMessages: 2,
|
||||
summarize: async (messagesToSummarize) => {
|
||||
assert.deepEqual(messagesToSummarize, messages.slice(0, 2));
|
||||
return "Summary of old messages.";
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.didCompact, true);
|
||||
assert.equal(result.summary, "Summary of old messages.");
|
||||
assert.deepEqual(result.messages.slice(-2), messages.slice(-2));
|
||||
});
|
||||
|
||||
test("prepareContextCompaction includes reserved request tokens in the compaction check", async () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: "user", content: "short prompt" },
|
||||
{ role: "assistant", content: "short answer" },
|
||||
{ role: "user", content: "recent question" },
|
||||
];
|
||||
|
||||
const result = await prepareContextCompaction({
|
||||
messages,
|
||||
contextWindow: 40,
|
||||
reservedTokens: estimateUnknownTokens("large system prompt ".repeat(20)),
|
||||
protectRecentMessages: 1,
|
||||
summarize: async (messagesToSummarize) => {
|
||||
assert.deepEqual(messagesToSummarize, messages.slice(0, 2));
|
||||
return "System prompt forced compaction.";
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.didCompact, true);
|
||||
assert.equal(result.summary, "System prompt forced compaction.");
|
||||
});
|
||||
|
||||
test("prepareContextCompaction summarizes older tool results instead of dropping them first", async () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: "user", content: "check disk usage" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "run_command",
|
||||
input: { command: "df -h" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "run_command",
|
||||
output: { type: "text", value: "/dev/disk1 81% full" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Disk is 81% full." },
|
||||
{ role: "user", content: "old follow-up ".repeat(80) },
|
||||
{ role: "assistant", content: "old answer ".repeat(80) },
|
||||
{ role: "user", content: "recent question" },
|
||||
{ role: "assistant", content: "recent answer" },
|
||||
];
|
||||
|
||||
const result = await prepareContextCompaction({
|
||||
messages,
|
||||
contextWindow: 120,
|
||||
protectRecentMessages: 2,
|
||||
summarize: async (messagesToSummarize) => {
|
||||
assert.match(formatMessagesForCompaction(messagesToSummarize), /81% full/);
|
||||
return "Earlier disk check showed /dev/disk1 was 81% full.";
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.didCompact, true);
|
||||
assert.match(result.messages[0]?.content as string, /81% full/);
|
||||
});
|
||||
|
||||
test("formatMessagesForCompaction redacts image and file payloads", () => {
|
||||
const imagePayload = "iVBORw0KGgo".repeat(200);
|
||||
const filePayload = "JVBERi0xLjQK".repeat(200);
|
||||
const formatted = formatMessagesForCompaction([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Please inspect these attachments." },
|
||||
{
|
||||
type: "image",
|
||||
image: imagePayload,
|
||||
mediaType: "image/png",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
data: filePayload,
|
||||
filename: "report.pdf",
|
||||
mediaType: "application/pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(formatted, /Please inspect these attachments/);
|
||||
assert.match(formatted, /redacted image payload/);
|
||||
assert.match(formatted, /mediaType=image\/png/);
|
||||
assert.match(formatted, /redacted file payload/);
|
||||
assert.match(formatted, /filename=report\.pdf/);
|
||||
assert.doesNotMatch(formatted, new RegExp(imagePayload.slice(0, 40)));
|
||||
assert.doesNotMatch(formatted, new RegExp(filePayload.slice(0, 40)));
|
||||
});
|
||||
|
||||
test("formatMessagesForCompaction keeps non-attachment data fields", () => {
|
||||
const formatted = formatMessagesForCompaction([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read_json",
|
||||
output: {
|
||||
type: "json",
|
||||
value: {
|
||||
data: {
|
||||
host: "prod-1",
|
||||
status: "healthy",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(formatted, /prod-1/);
|
||||
assert.match(formatted, /healthy/);
|
||||
assert.doesNotMatch(formatted, /redacted data payload/);
|
||||
});
|
||||
|
||||
test("estimateModelMessagesTokens counts multimodal and tool content", () => {
|
||||
const tokens = estimateModelMessagesTokens([
|
||||
{ role: "user", content: [{ type: "text", text: "hello world" }] },
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "run_command",
|
||||
output: { type: "text", value: "result text" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.ok(tokens >= 5);
|
||||
});
|
||||
|
||||
test("resolveContextWindow prefers manual override, then fetched model metadata, then default", () => {
|
||||
assert.equal(
|
||||
resolveContextWindow({
|
||||
provider: {
|
||||
contextWindow: 262144,
|
||||
modelContextWindows: { "qwen/test": 131072 },
|
||||
},
|
||||
modelId: "qwen/test",
|
||||
defaultContextWindow: 128000,
|
||||
}),
|
||||
262144,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveContextWindow({
|
||||
provider: {
|
||||
modelContextWindows: { "qwen/test": 131072 },
|
||||
},
|
||||
modelId: "qwen/test",
|
||||
defaultContextWindow: 128000,
|
||||
}),
|
||||
131072,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveContextWindow({
|
||||
provider: {},
|
||||
modelId: "unknown",
|
||||
defaultContextWindow: 128000,
|
||||
}),
|
||||
128000,
|
||||
);
|
||||
});
|
||||
316
infrastructure/ai/contextCompaction.ts
Normal file
316
infrastructure/ai/contextCompaction.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import type { ModelMessage } from "ai";
|
||||
import type { ProviderConfig } from "./types";
|
||||
|
||||
const DEFAULT_COMPACTION_RATIO = 0.85;
|
||||
const TOKEN_CHARS = 4;
|
||||
const REDACTED_PAYLOAD_PREVIEW_CHARS = 80;
|
||||
|
||||
export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000;
|
||||
export const DEFAULT_PROTECT_RECENT_MESSAGES = 10;
|
||||
|
||||
export const CONTEXT_COMPACTION_SYSTEM_PROMPT = `You are summarizing a long Netcatty agent conversation so it can continue without exceeding the model context window.
|
||||
|
||||
Create a concise but complete summary that preserves:
|
||||
- the user's current goal and requirements
|
||||
- important decisions and constraints
|
||||
- terminal hosts, paths, commands, files, errors, and results that still matter
|
||||
- what has already been tried
|
||||
- unresolved tasks or blockers
|
||||
|
||||
Do not add new advice. Only summarize what happened.`;
|
||||
|
||||
export interface ShouldCompactContextInput {
|
||||
promptTokens: number;
|
||||
contextWindow: number;
|
||||
thresholdRatio?: number;
|
||||
}
|
||||
|
||||
export interface PrepareContextCompactionInput {
|
||||
messages: ModelMessage[];
|
||||
contextWindow?: number;
|
||||
reservedTokens?: number;
|
||||
thresholdRatio?: number;
|
||||
protectRecentMessages?: number;
|
||||
summarize: (messagesToSummarize: ModelMessage[]) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface PrepareContextCompactionResult {
|
||||
messages: ModelMessage[];
|
||||
summary?: string;
|
||||
didCompact: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveContextWindowInput {
|
||||
provider?: Pick<ProviderConfig, "contextWindow" | "modelContextWindows"> | null;
|
||||
modelId?: string | null;
|
||||
defaultContextWindow?: number;
|
||||
}
|
||||
|
||||
export function shouldCompactContext({
|
||||
promptTokens,
|
||||
contextWindow,
|
||||
thresholdRatio = DEFAULT_COMPACTION_RATIO,
|
||||
}: ShouldCompactContextInput): boolean {
|
||||
if (contextWindow <= 0) return false;
|
||||
return promptTokens >= contextWindow * thresholdRatio;
|
||||
}
|
||||
|
||||
export function resolveContextWindow({
|
||||
provider,
|
||||
modelId,
|
||||
defaultContextWindow = DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
}: ResolveContextWindowInput): number {
|
||||
const manual = sanitizeContextWindow(provider?.contextWindow);
|
||||
if (manual != null) return manual;
|
||||
|
||||
const discovered = modelId ? sanitizeContextWindow(provider?.modelContextWindows?.[modelId]) : null;
|
||||
if (discovered != null) return discovered;
|
||||
|
||||
return defaultContextWindow;
|
||||
}
|
||||
|
||||
export function sanitizeContextWindow(value: unknown): number | undefined {
|
||||
const num = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) return undefined;
|
||||
return Math.max(1, Math.round(num));
|
||||
}
|
||||
|
||||
export function estimateModelMessagesTokens(messages: ModelMessage[]): number {
|
||||
const chars = messages.reduce((total, message) => {
|
||||
return total + estimateUnknownChars(message.role) + estimateUnknownChars(message.content);
|
||||
}, 0);
|
||||
return Math.ceil(chars / TOKEN_CHARS);
|
||||
}
|
||||
|
||||
export function estimateUnknownTokens(value: unknown): number {
|
||||
return Math.ceil(estimateUnknownChars(value) / TOKEN_CHARS);
|
||||
}
|
||||
|
||||
export function findSafeCompactionSplitIndex(
|
||||
messages: ModelMessage[],
|
||||
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
): number {
|
||||
let splitAt = Math.max(0, messages.length - protectRecentMessages);
|
||||
|
||||
while (splitAt > 0 && startsWithToolResult(messages[splitAt])) {
|
||||
splitAt -= 1;
|
||||
}
|
||||
|
||||
while (splitAt > 0 && endsWithToolCall(messages[splitAt - 1])) {
|
||||
splitAt -= 1;
|
||||
}
|
||||
|
||||
return splitAt;
|
||||
}
|
||||
|
||||
export function buildCompactedMessages({
|
||||
summary,
|
||||
recentMessages,
|
||||
}: {
|
||||
summary: string;
|
||||
recentMessages: ModelMessage[];
|
||||
}): ModelMessage[] {
|
||||
return [
|
||||
{
|
||||
role: "user",
|
||||
content: `[Previous conversation summary]\n\n${summary.trim()}\n\n[Continue with the recent messages below.]`,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I understand the previous conversation summary and will continue from the recent messages.",
|
||||
},
|
||||
...recentMessages,
|
||||
];
|
||||
}
|
||||
|
||||
export async function prepareContextCompaction({
|
||||
messages,
|
||||
contextWindow = DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
reservedTokens = 0,
|
||||
thresholdRatio,
|
||||
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
summarize,
|
||||
}: PrepareContextCompactionInput): Promise<PrepareContextCompactionResult> {
|
||||
const promptTokens = estimateModelMessagesTokens(messages) + Math.max(0, Math.ceil(reservedTokens));
|
||||
if (!shouldCompactContext({ promptTokens, contextWindow, thresholdRatio })) {
|
||||
return { messages, didCompact: false };
|
||||
}
|
||||
|
||||
const splitAt = findSafeCompactionSplitIndex(messages, protectRecentMessages);
|
||||
const oldMessages = messages.slice(0, splitAt);
|
||||
const recentMessages = messages.slice(splitAt);
|
||||
if (oldMessages.length === 0) {
|
||||
return { messages, didCompact: false };
|
||||
}
|
||||
|
||||
const summary = (await summarize(oldMessages)).trim();
|
||||
if (!summary) {
|
||||
return { messages, didCompact: false };
|
||||
}
|
||||
|
||||
return {
|
||||
messages: buildCompactedMessages({ summary, recentMessages }),
|
||||
summary,
|
||||
didCompact: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMessagesForCompaction(messages: ModelMessage[]): string {
|
||||
return messages
|
||||
.map((message, index) => {
|
||||
return `<message index="${index + 1}" role="${escapeXml(String(message.role))}">\n${escapeXml(formatMessageContent(message.content))}\n</message>`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function keepRecentContextMessages(
|
||||
messages: ModelMessage[],
|
||||
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
): ModelMessage[] {
|
||||
const splitAt = findSafeCompactionSplitIndex(messages, protectRecentMessages);
|
||||
return messages.slice(splitAt);
|
||||
}
|
||||
|
||||
function estimateUnknownChars(value: unknown): number {
|
||||
if (value == null) return 0;
|
||||
if (typeof value === "string") return value.length;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value).length;
|
||||
if (Array.isArray(value)) return value.reduce((total, part) => total + estimateUnknownChars(part), 0);
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
let total = 0;
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
total += key.length + estimateUnknownChars(entry);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
return String(value).length;
|
||||
}
|
||||
|
||||
function startsWithToolResult(message: ModelMessage | undefined): boolean {
|
||||
if (!message || message.role !== "tool") return false;
|
||||
if (!Array.isArray(message.content)) return true;
|
||||
return message.content.some((part) => {
|
||||
return part && typeof part === "object" && (part as { type?: string }).type === "tool-result";
|
||||
});
|
||||
}
|
||||
|
||||
function endsWithToolCall(message: ModelMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return false;
|
||||
return message.content.some((part) => {
|
||||
return part && typeof part === "object" && (part as { type?: string }).type === "tool-call";
|
||||
});
|
||||
}
|
||||
|
||||
function formatMessageContent(content: ModelMessage["content"]): string {
|
||||
if (typeof content === "string") return content;
|
||||
return JSON.stringify(sanitizeContentForCompaction(content), null, 2);
|
||||
}
|
||||
|
||||
function sanitizeContentForCompaction(content: Exclude<ModelMessage["content"], string>): unknown {
|
||||
if (!Array.isArray(content)) return sanitizeUnknownForCompaction(content);
|
||||
return content.map((part) => sanitizeContentPartForCompaction(part));
|
||||
}
|
||||
|
||||
function sanitizeContentPartForCompaction(part: unknown): unknown {
|
||||
if (!isRecord(part)) return sanitizeUnknownForCompaction(part);
|
||||
|
||||
if (part.type === "image") {
|
||||
const sanitized = sanitizeRecordForCompaction(part);
|
||||
return {
|
||||
...sanitized,
|
||||
image: describeRedactedPayload(part.image, {
|
||||
label: "image",
|
||||
mediaType: typeof part.mediaType === "string" ? part.mediaType : undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
const sanitized = sanitizeRecordForCompaction(part);
|
||||
return {
|
||||
...sanitized,
|
||||
data: describeRedactedPayload(part.data, {
|
||||
label: "file",
|
||||
mediaType: typeof part.mediaType === "string" ? part.mediaType : undefined,
|
||||
filename: typeof part.filename === "string" ? part.filename : undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return sanitizeUnknownForCompaction(part);
|
||||
}
|
||||
|
||||
function sanitizeRecordForCompaction(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [entryKey, entryValue] of Object.entries(value)) {
|
||||
sanitized[entryKey] = sanitizeUnknownForCompaction(entryValue, entryKey);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function sanitizeUnknownForCompaction(value: unknown, key?: string): unknown {
|
||||
if (value == null) return value;
|
||||
if (typeof value === "string") {
|
||||
if (key === "base64Data" || key === "dataUrl" || key === "file_data") {
|
||||
return describeRedactedPayload(value, { label: key });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (value instanceof URL) return value.toString();
|
||||
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
||||
return describeRedactedPayload(value, { label: key ?? "binary" });
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((part) => sanitizeUnknownForCompaction(part));
|
||||
if (isRecord(value)) return sanitizeRecordForCompaction(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function describeRedactedPayload(
|
||||
value: unknown,
|
||||
{
|
||||
label,
|
||||
filename,
|
||||
mediaType,
|
||||
}: {
|
||||
label: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
},
|
||||
): string {
|
||||
const details = [
|
||||
filename ? `filename=${filename}` : undefined,
|
||||
mediaType ? `mediaType=${mediaType}` : undefined,
|
||||
describePayloadSize(value),
|
||||
typeof value === "string" ? describeStringPreview(value) : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
return `[redacted ${label} payload${details.length ? `: ${details.join(", ")}` : ""}]`;
|
||||
}
|
||||
|
||||
function describePayloadSize(value: unknown): string {
|
||||
if (typeof value === "string") return `${value.length} chars`;
|
||||
if (value instanceof ArrayBuffer) return `${value.byteLength} bytes`;
|
||||
if (ArrayBuffer.isView(value)) return `${value.byteLength} bytes`;
|
||||
if (value instanceof URL) return "url";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function describeStringPreview(value: string): string | undefined {
|
||||
if (!value.startsWith("data:")) return undefined;
|
||||
const commaIndex = value.indexOf(",");
|
||||
const header = commaIndex >= 0 ? value.slice(0, commaIndex) : value.slice(0, REDACTED_PAYLOAD_PREVIEW_CHARS);
|
||||
return `source=${header.slice(0, REDACTED_PAYLOAD_PREVIEW_CHARS)}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import type {
|
||||
ExternalAgentConfig,
|
||||
} from './types';
|
||||
import { parseAgentJsonLine, formatSegmentsAsMarkdown } from './agentOutputParser';
|
||||
|
||||
/** Callbacks for streaming external agent output */
|
||||
export interface ExternalAgentCallbacks {
|
||||
onTextDelta: (text: string) => void;
|
||||
onError: (error: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge interface matching the agent-related methods from window.netcatty
|
||||
*/
|
||||
interface AgentBridge {
|
||||
aiSpawnAgent(
|
||||
agentId: string,
|
||||
command: string,
|
||||
args?: string[],
|
||||
env?: Record<string, string>,
|
||||
options?: { closeStdin?: boolean },
|
||||
): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiCloseAgentStdin(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
|
||||
}
|
||||
|
||||
const PROMPT_PLACEHOLDER = '{prompt}';
|
||||
|
||||
/**
|
||||
* Build the final command and args for an external agent.
|
||||
*/
|
||||
function buildAgentInvocation(
|
||||
config: ExternalAgentConfig,
|
||||
userMessage: string,
|
||||
): { command: string; args: string[]; useStdin: boolean; jsonMode: boolean } {
|
||||
const command = config.command;
|
||||
const templateArgs = config.args || [];
|
||||
|
||||
const hasPlaceholder = templateArgs.some(a => a.includes(PROMPT_PLACEHOLDER));
|
||||
const jsonMode = templateArgs.includes('--json');
|
||||
|
||||
if (hasPlaceholder) {
|
||||
const args = templateArgs.map(a =>
|
||||
a === PROMPT_PLACEHOLDER ? userMessage : a.replaceAll(PROMPT_PLACEHOLDER, userMessage),
|
||||
);
|
||||
return { command, args, useStdin: false, jsonMode };
|
||||
}
|
||||
|
||||
return { command, args: [...templateArgs], useStdin: true, jsonMode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stdout handler that parses JSON Lines (for --json mode agents)
|
||||
* and converts structured events to formatted markdown text.
|
||||
*
|
||||
* Handles partial lines since stdout chunks can split mid-line.
|
||||
*/
|
||||
function createJsonLinesHandler(onText: (text: string) => void): (data: string) => void {
|
||||
let lineBuffer = '';
|
||||
// Track seen item IDs to avoid duplicating command blocks
|
||||
// (item.started shows the command, item.completed shows command + output)
|
||||
const seenCommands = new Set<string>();
|
||||
|
||||
return (data: string) => {
|
||||
lineBuffer += data;
|
||||
const lines = lineBuffer.split('\n');
|
||||
// Keep the last (possibly incomplete) line in the buffer
|
||||
lineBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const segments = parseAgentJsonLine(line);
|
||||
if (segments === null) {
|
||||
// Not JSON — pass through as plain text
|
||||
onText(line + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segments.length === 0) continue;
|
||||
|
||||
// Deduplicate command_execution: skip started if we'll get completed
|
||||
const filtered = segments.filter(seg => {
|
||||
if (seg.type === 'command') {
|
||||
if (seenCommands.has(seg.content)) return false;
|
||||
seenCommands.add(seg.content);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
const markdown = formatSegmentsAsMarkdown(filtered);
|
||||
onText(markdown);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an external agent and send a message through it.
|
||||
*/
|
||||
export async function runExternalAgentTurn(
|
||||
config: ExternalAgentConfig,
|
||||
userMessage: string,
|
||||
callbacks: ExternalAgentCallbacks,
|
||||
bridge: AgentBridge | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (!bridge) {
|
||||
callbacks.onError('Bridge not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = `ext_${config.id}_${Date.now()}`;
|
||||
const { command, args, useStdin, jsonMode } = buildAgentInvocation(config, userMessage);
|
||||
|
||||
const cleanupFns: (() => void)[] = [];
|
||||
let done = false;
|
||||
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
for (const fn of cleanupFns) {
|
||||
try { fn(); } catch { /* cleanup */ }
|
||||
}
|
||||
callbacks.onDone();
|
||||
};
|
||||
|
||||
// ── Set up event listeners BEFORE spawning to avoid race condition ──
|
||||
|
||||
// For JSON mode, parse structured events; otherwise, pass through raw text
|
||||
const stdoutHandler = jsonMode
|
||||
? createJsonLinesHandler((text) => { if (!done) callbacks.onTextDelta(text); })
|
||||
: (data: string) => { if (!done) callbacks.onTextDelta(data); };
|
||||
|
||||
const unsubStdout = bridge.onAiAgentStdout(agentId, stdoutHandler);
|
||||
cleanupFns.push(unsubStdout);
|
||||
|
||||
// Collect stderr
|
||||
let stderrBuffer = '';
|
||||
const unsubStderr = bridge.onAiAgentStderr(agentId, (data) => {
|
||||
stderrBuffer += data;
|
||||
});
|
||||
cleanupFns.push(unsubStderr);
|
||||
|
||||
let resolveExit: (code: number | null) => void;
|
||||
const exitPromise = new Promise<number | null>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
const unsubExit = bridge.onAiAgentExit(agentId, (code) => {
|
||||
resolve(code);
|
||||
});
|
||||
cleanupFns.push(unsubExit);
|
||||
});
|
||||
|
||||
// Handle abort
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
bridge.aiKillAgent(agentId).catch(() => {});
|
||||
callbacks.onError('Cancelled');
|
||||
resolveExit(null);
|
||||
finish();
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
|
||||
}
|
||||
|
||||
// ── Spawn the process ──
|
||||
const result = await bridge.aiSpawnAgent(
|
||||
agentId,
|
||||
command,
|
||||
args,
|
||||
config.env,
|
||||
{ closeStdin: !useStdin },
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
callbacks.onError(`Failed to start ${config.name}: ${result.error}`);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the user message via stdin if needed, then close stdin (EOF)
|
||||
if (useStdin) {
|
||||
try {
|
||||
await bridge.aiWriteToAgent(agentId, userMessage + '\n');
|
||||
await bridge.aiCloseAgentStdin(agentId);
|
||||
} catch (err) {
|
||||
callbacks.onError(`Failed to write to agent: ${err}`);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout after 5 minutes
|
||||
const timeout = setTimeout(() => {
|
||||
if (!done) {
|
||||
bridge.aiKillAgent(agentId).catch(() => {});
|
||||
callbacks.onError('Agent timeout (5 minutes)');
|
||||
resolveExit(null);
|
||||
finish();
|
||||
}
|
||||
}, 300000);
|
||||
cleanupFns.push(() => clearTimeout(timeout));
|
||||
|
||||
// Wait for the process to exit
|
||||
const exitCode = await exitPromise;
|
||||
|
||||
// If process exited with error and no stdout was received, report stderr
|
||||
if (exitCode !== 0 && stderrBuffer.trim() && !done) {
|
||||
callbacks.onError(stderrBuffer.trim());
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a running external agent session
|
||||
*/
|
||||
export async function killExternalAgent(
|
||||
agentId: string,
|
||||
bridge: AgentBridge | undefined,
|
||||
): Promise<void> {
|
||||
if (bridge) {
|
||||
await bridge.aiKillAgent(agentId).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { matchesManagedAgentConfig } from './managedAgents';
|
||||
import { getExternalAgentSdkBackend, matchesManagedAgentConfig } from './managedAgents';
|
||||
|
||||
test('managed Claude matching ignores claude-agent-acp command-only configs', () => {
|
||||
test('managed Claude matching ignores legacy adapter command-only configs', () => {
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig(
|
||||
{
|
||||
@@ -16,7 +16,7 @@ test('managed Claude matching ignores claude-agent-acp command-only configs', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('managed Claude matching ignores claude-agent-acp adapter configs', () => {
|
||||
test('managed Claude matching ignores legacy adapter configs', () => {
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig(
|
||||
{
|
||||
@@ -29,3 +29,28 @@ test('managed Claude matching ignores claude-agent-acp adapter configs', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('codex managed config no longer matches legacy adapter backend values', () => {
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig({ id: 'x', command: 'codex', sdkBackend: 'codex' }, 'codex'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig({ id: 'x', command: 'other', acpCommand: 'codex-acp' }, 'codex'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('claude managed config matches by sdk backend value', () => {
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig({ id: 'discovered_claude', command: 'claude', sdkBackend: 'claude' }, 'claude'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy backend field is still accepted for saved settings', () => {
|
||||
assert.equal(
|
||||
getExternalAgentSdkBackend({ acpCommand: 'codex' }),
|
||||
'codex',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { DiscoveredAgent, ExternalAgentConfig } from './types';
|
||||
|
||||
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot' | 'codebuddy';
|
||||
|
||||
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
|
||||
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
|
||||
claude: { commandNames: ['claude'], acpCommand: 'claude-agent-acp' },
|
||||
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
|
||||
codebuddy: { commandNames: ['codebuddy'], acpCommand: 'codebuddy' },
|
||||
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; sdkBackend: string }> = {
|
||||
codex: { commandNames: ['codex'], sdkBackend: 'codex' },
|
||||
claude: { commandNames: ['claude'], sdkBackend: 'claude' },
|
||||
copilot: { commandNames: ['copilot'], sdkBackend: 'copilot' },
|
||||
codebuddy: { commandNames: ['codebuddy'], sdkBackend: 'codebuddy' },
|
||||
};
|
||||
|
||||
function getCommandBasename(command: string | undefined): string {
|
||||
@@ -33,7 +33,7 @@ export function isSettingsManagedDiscoveredAgent(
|
||||
}
|
||||
|
||||
export function matchesManagedAgentConfig(
|
||||
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'acpCommand'>,
|
||||
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'sdkBackend' | 'acpCommand'>,
|
||||
agentKey: ManagedAgentKey,
|
||||
): boolean {
|
||||
const meta = MANAGED_AGENT_META[agentKey];
|
||||
@@ -47,11 +47,17 @@ export function matchesManagedAgentConfig(
|
||||
}
|
||||
return (
|
||||
agent.id === `discovered_${agentKey}` ||
|
||||
getCommandBasename(agent.acpCommand) === meta.acpCommand ||
|
||||
getExternalAgentSdkBackend(agent) === meta.sdkBackend ||
|
||||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
|
||||
);
|
||||
}
|
||||
|
||||
export function getExternalAgentSdkBackend(
|
||||
agent: Pick<ExternalAgentConfig, 'sdkBackend' | 'acpCommand'> | undefined,
|
||||
): string | undefined {
|
||||
return agent?.sdkBackend || agent?.acpCommand || undefined;
|
||||
}
|
||||
|
||||
export function getManagedAgentStoredPath(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentKey: ManagedAgentKey,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveProviderStyle } from "./types";
|
||||
import { PROVIDER_PRESETS, resolveProviderStyle } from "./types";
|
||||
|
||||
test("resolveProviderStyle prefers an explicit style override", () => {
|
||||
assert.equal(resolveProviderStyle({ providerId: "custom", style: "anthropic" }), "anthropic");
|
||||
@@ -17,7 +17,34 @@ test("resolveProviderStyle falls back to providerId for google", () => {
|
||||
});
|
||||
|
||||
test("resolveProviderStyle treats every other providerId as the OpenAI-compatible family", () => {
|
||||
for (const providerId of ["openai", "ollama", "openrouter", "custom"] as const) {
|
||||
for (const providerId of ["openai", "ollama", "openrouter", "qwen", "deepseek", "kimi", "zhipu", "doubao", "mimo", "custom"] as const) {
|
||||
assert.equal(resolveProviderStyle({ providerId }), "openai", `expected openai for ${providerId}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("domestic provider presets include editable OpenAI-compatible base URLs", () => {
|
||||
assert.equal(PROVIDER_PRESETS.qwen.defaultBaseURL, "https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
assert.equal(PROVIDER_PRESETS.deepseek.defaultBaseURL, "https://api.deepseek.com/v1");
|
||||
assert.equal(PROVIDER_PRESETS.kimi.defaultBaseURL, "https://api.moonshot.ai/v1");
|
||||
assert.equal(PROVIDER_PRESETS.zhipu.defaultBaseURL, "https://open.bigmodel.cn/api/paas/v4");
|
||||
assert.equal(PROVIDER_PRESETS.doubao.defaultBaseURL, "https://ark.cn-beijing.volces.com/api/v3");
|
||||
assert.equal(PROVIDER_PRESETS.mimo.defaultBaseURL, "https://api.xiaomimimo.com/v1");
|
||||
});
|
||||
|
||||
test("openrouter keeps dynamic model discovery instead of a static preset list", () => {
|
||||
assert.equal(PROVIDER_PRESETS.openrouter.defaultBaseURL, "https://openrouter.ai/api/v1");
|
||||
assert.equal(PROVIDER_PRESETS.openrouter.modelsEndpoint, "/models");
|
||||
assert.equal(PROVIDER_PRESETS.openrouter.defaultModels, undefined);
|
||||
});
|
||||
|
||||
test("domestic provider presets expose provider-specific model suggestions", () => {
|
||||
assert.equal(PROVIDER_PRESETS.qwen.defaultModels?.[0], "qwen3.7-plus");
|
||||
assert.ok(PROVIDER_PRESETS.qwen.defaultModels?.includes("qwen3.7-max"));
|
||||
assert.equal(PROVIDER_PRESETS.deepseek.defaultModels?.[0], "deepseek-v4-flash");
|
||||
assert.ok(PROVIDER_PRESETS.kimi.defaultModels?.includes("kimi-k2.6"));
|
||||
assert.ok(PROVIDER_PRESETS.zhipu.defaultModels?.includes("glm-5.1"));
|
||||
assert.ok(PROVIDER_PRESETS.doubao.defaultModels?.includes("doubao-seed-2-0-pro-260215"));
|
||||
assert.ok(!PROVIDER_PRESETS.doubao.defaultModels?.some((model) => model.startsWith("ep-")));
|
||||
assert.ok(PROVIDER_PRESETS.mimo.defaultModels?.includes("mimo-v2.5-pro"));
|
||||
assert.equal(PROVIDER_PRESETS.custom.defaultModels, undefined);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { formatAcpErrorForDisplay, runAcpAgentTurn } from './acpAgentAdapter';
|
||||
import type { AcpAgentCallbacks } from './acpAgentAdapter';
|
||||
import { formatSdkAgentErrorForDisplay, runSdkAgentTurn } from './sdkAgentAdapter';
|
||||
import type { SdkAgentCallbacks } from './sdkAgentAdapter';
|
||||
import type { ExternalAgentConfig } from './types';
|
||||
|
||||
function createCallbacks(errors: string[]): AcpAgentCallbacks {
|
||||
function createCallbacks(errors: string[]): SdkAgentCallbacks {
|
||||
return {
|
||||
onTextDelta: () => {},
|
||||
onThinkingDelta: () => {},
|
||||
@@ -17,18 +17,17 @@ function createCallbacks(errors: string[]): AcpAgentCallbacks {
|
||||
};
|
||||
}
|
||||
|
||||
const acpConfig: ExternalAgentConfig = {
|
||||
const sdkConfig: ExternalAgentConfig = {
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
command: 'agent',
|
||||
enabled: true,
|
||||
acpCommand: 'agent-acp',
|
||||
acpArgs: [],
|
||||
sdkBackend: 'codex',
|
||||
};
|
||||
|
||||
test('formatAcpErrorForDisplay preserves nested ACP error messages', () => {
|
||||
test('formatSdkAgentErrorForDisplay preserves nested SDK agent error messages', () => {
|
||||
assert.equal(
|
||||
formatAcpErrorForDisplay({
|
||||
formatSdkAgentErrorForDisplay({
|
||||
error: {
|
||||
code: 'invalid_model',
|
||||
message: 'Model is not available',
|
||||
@@ -38,27 +37,27 @@ test('formatAcpErrorForDisplay preserves nested ACP error messages', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('formatAcpErrorForDisplay stringifies unknown objects instead of [object Object]', () => {
|
||||
test('formatSdkAgentErrorForDisplay stringifies unknown objects instead of [object Object]', () => {
|
||||
assert.equal(
|
||||
formatAcpErrorForDisplay({ status: 502, detail: 'Proxy failed' }),
|
||||
formatSdkAgentErrorForDisplay({ status: 502, detail: 'Proxy failed' }),
|
||||
'{"status":502,"detail":"Proxy failed"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatAcpErrorForDisplay handles circular errors', () => {
|
||||
test('formatSdkAgentErrorForDisplay handles circular errors', () => {
|
||||
const error: Record<string, unknown> = { status: 500 };
|
||||
error.self = error;
|
||||
|
||||
assert.equal(
|
||||
formatAcpErrorForDisplay(error),
|
||||
formatSdkAgentErrorForDisplay(error),
|
||||
'{"status":500,"self":"[Circular]"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn formats structured startup errors', async () => {
|
||||
test('runSdkAgentTurn formats structured startup errors', async () => {
|
||||
const errors: string[] = [];
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiAcpStream: async () => ({
|
||||
aiSdkAgentStream: async () => ({
|
||||
ok: false,
|
||||
error: {
|
||||
error: {
|
||||
@@ -67,17 +66,17 @@ test('runAcpAgentTurn formats structured startup errors', async () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
aiAcpCancel: async () => ({ ok: true }),
|
||||
onAiAcpEvent: () => () => {},
|
||||
onAiAcpDone: () => () => {},
|
||||
onAiAcpError: () => () => {},
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
onAiSdkAgentEvent: () => () => {},
|
||||
onAiSdkAgentDone: () => () => {},
|
||||
onAiSdkAgentError: () => () => {},
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
await runSdkAgentTurn(
|
||||
bridge,
|
||||
'request-1',
|
||||
'chat-1',
|
||||
acpConfig,
|
||||
sdkConfig,
|
||||
'hello',
|
||||
createCallbacks(errors),
|
||||
);
|
||||
@@ -85,30 +84,30 @@ test('runAcpAgentTurn formats structured startup errors', async () => {
|
||||
assert.deepEqual(errors, ['Model is not available']);
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn forwards configured ACP environment', async () => {
|
||||
test('runSdkAgentTurn forwards configured SDK agent environment', async () => {
|
||||
let streamArgs: unknown[] = [];
|
||||
let done: (() => void) | null = null;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiAcpStream: async (...args: unknown[]) => {
|
||||
aiSdkAgentStream: async (...args: unknown[]) => {
|
||||
streamArgs = args;
|
||||
queueMicrotask(() => done?.());
|
||||
return { ok: true };
|
||||
},
|
||||
aiAcpCancel: async () => ({ ok: true }),
|
||||
onAiAcpEvent: () => () => {},
|
||||
onAiAcpDone: (_requestId: unknown, cb: unknown) => {
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
onAiSdkAgentEvent: () => () => {},
|
||||
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
|
||||
done = cb as () => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiAcpError: () => () => {},
|
||||
onAiSdkAgentError: () => () => {},
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
await runSdkAgentTurn(
|
||||
bridge,
|
||||
'request-env',
|
||||
'chat-env',
|
||||
{
|
||||
...acpConfig,
|
||||
...sdkConfig,
|
||||
env: { CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude' },
|
||||
},
|
||||
'hello',
|
||||
@@ -118,13 +117,14 @@ test('runAcpAgentTurn forwards configured ACP environment', async () => {
|
||||
assert.deepEqual(streamArgs.at(-1), {
|
||||
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
||||
});
|
||||
assert.equal(streamArgs[2], 'codex');
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn formats structured async error events', async () => {
|
||||
test('runSdkAgentTurn formats structured async error events', async () => {
|
||||
const errors: string[] = [];
|
||||
let onError: ((error: unknown) => void) | null = null;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiAcpStream: async () => {
|
||||
aiSdkAgentStream: async () => {
|
||||
queueMicrotask(() => {
|
||||
onError?.({
|
||||
data: {
|
||||
@@ -136,20 +136,20 @@ test('runAcpAgentTurn formats structured async error events', async () => {
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
aiAcpCancel: async () => ({ ok: true }),
|
||||
onAiAcpEvent: () => () => {},
|
||||
onAiAcpDone: () => () => {},
|
||||
onAiAcpError: (_requestId: unknown, cb: unknown) => {
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
onAiSdkAgentEvent: () => () => {},
|
||||
onAiSdkAgentDone: () => () => {},
|
||||
onAiSdkAgentError: (_requestId: unknown, cb: unknown) => {
|
||||
onError = cb as (error: unknown) => void;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
await runSdkAgentTurn(
|
||||
bridge,
|
||||
'request-2',
|
||||
'chat-1',
|
||||
acpConfig,
|
||||
sdkConfig,
|
||||
'hello',
|
||||
createCallbacks(errors),
|
||||
);
|
||||
@@ -157,11 +157,11 @@ test('runAcpAgentTurn formats structured async error events', async () => {
|
||||
assert.deepEqual(errors, ['Proxy failed']);
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn formats structured stream error events', async () => {
|
||||
test('runSdkAgentTurn formats structured stream error events', async () => {
|
||||
const errors: string[] = [];
|
||||
let onEvent: ((event: unknown) => void) | null = null;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiAcpStream: async () => {
|
||||
aiSdkAgentStream: async () => {
|
||||
queueMicrotask(() => {
|
||||
onEvent?.({
|
||||
type: 'error',
|
||||
@@ -174,20 +174,20 @@ test('runAcpAgentTurn formats structured stream error events', async () => {
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
aiAcpCancel: async () => ({ ok: true }),
|
||||
onAiAcpEvent: (_requestId: unknown, cb: unknown) => {
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
onAiSdkAgentEvent: (_requestId: unknown, cb: unknown) => {
|
||||
onEvent = cb as (event: unknown) => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiAcpDone: () => () => {},
|
||||
onAiAcpError: () => () => {},
|
||||
onAiSdkAgentDone: () => () => {},
|
||||
onAiSdkAgentError: () => () => {},
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
await runSdkAgentTurn(
|
||||
bridge,
|
||||
'request-3',
|
||||
'chat-1',
|
||||
acpConfig,
|
||||
sdkConfig,
|
||||
'hello',
|
||||
createCallbacks(errors),
|
||||
);
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* ACP Agent Adapter
|
||||
* SDK Agent Adapter
|
||||
*
|
||||
* Bridges external agents that support the Agent Client Protocol (ACP)
|
||||
* through IPC. The main process runs `createACPProvider` + `streamText`,
|
||||
* and forwards stream events to the renderer via IPC.
|
||||
* Bridges managed external agents through IPC. The main process runs the
|
||||
* official SDK drivers and forwards stream events to the renderer.
|
||||
*/
|
||||
|
||||
import type { AIToolIntegrationMode, ExternalAgentConfig } from './types';
|
||||
import { getExternalAgentSdkBackend } from './managedAgents';
|
||||
|
||||
export interface DefaultTargetSessionHint {
|
||||
sessionId: string;
|
||||
@@ -21,7 +21,7 @@ export interface DefaultTargetSessionHint {
|
||||
source: 'scope-target' | 'only-connected-in-scope';
|
||||
}
|
||||
|
||||
export interface AcpAgentCallbacks {
|
||||
export interface SdkAgentCallbacks {
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onTextDelta: (text: string) => void;
|
||||
onThinkingDelta: (text: string) => void;
|
||||
@@ -33,12 +33,11 @@ export interface AcpAgentCallbacks {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
interface AcpBridge {
|
||||
aiAcpStream(
|
||||
interface SdkAgentBridge {
|
||||
aiSdkAgentStream(
|
||||
requestId: string,
|
||||
chatSessionId: string,
|
||||
acpCommand: string,
|
||||
acpArgs: string[],
|
||||
sdkBackend: string,
|
||||
prompt: string,
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
@@ -51,10 +50,10 @@ interface AcpBridge {
|
||||
userSkillsContext?: string,
|
||||
agentEnv?: Record<string, string>,
|
||||
): Promise<{ ok: boolean; error?: unknown }>;
|
||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
onAiAcpDone(requestId: string, cb: () => void): () => void;
|
||||
onAiAcpError(requestId: string, cb: (error: unknown) => void): () => void;
|
||||
aiSdkAgentCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiSdkAgentEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
onAiSdkAgentDone(requestId: string, cb: () => void): () => void;
|
||||
onAiSdkAgentError(requestId: string, cb: (error: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
interface StreamEvent {
|
||||
@@ -63,8 +62,8 @@ interface StreamEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an ACP agent turn.
|
||||
* Sends the prompt to the main process which runs streamText() with the ACP provider.
|
||||
* Run one managed SDK agent turn.
|
||||
* Sends the prompt to the main process and listens for streamed events.
|
||||
* Stream events are forwarded back via IPC.
|
||||
*/
|
||||
export interface FileAttachment {
|
||||
@@ -92,7 +91,7 @@ function safeJsonStringify(value: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function formatAcpErrorValue(error: unknown, seen = new WeakSet<object>()): string {
|
||||
function formatSdkAgentErrorValue(error: unknown, seen = new WeakSet<object>()): string {
|
||||
if (error == null) return '';
|
||||
if (typeof error === 'string') return error;
|
||||
if (typeof error === 'number' || typeof error === 'boolean') return String(error);
|
||||
@@ -116,7 +115,7 @@ function formatAcpErrorValue(error: unknown, seen = new WeakSet<object>()): stri
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const message = formatAcpErrorValue(candidate, seen).trim();
|
||||
const message = formatSdkAgentErrorValue(candidate, seen).trim();
|
||||
if (message && message !== '{}') {
|
||||
return message;
|
||||
}
|
||||
@@ -125,17 +124,17 @@ function formatAcpErrorValue(error: unknown, seen = new WeakSet<object>()): stri
|
||||
return safeJsonStringify(error) || String(error);
|
||||
}
|
||||
|
||||
export function formatAcpErrorForDisplay(error: unknown): string {
|
||||
return formatAcpErrorValue(error).trim() || 'Unknown error';
|
||||
export function formatSdkAgentErrorForDisplay(error: unknown): string {
|
||||
return formatSdkAgentErrorValue(error).trim() || 'Unknown error';
|
||||
}
|
||||
|
||||
export async function runAcpAgentTurn(
|
||||
export async function runSdkAgentTurn(
|
||||
bridge: Record<string, (...args: unknown[]) => unknown>,
|
||||
requestId: string,
|
||||
chatSessionId: string,
|
||||
config: ExternalAgentConfig,
|
||||
prompt: string,
|
||||
callbacks: AcpAgentCallbacks,
|
||||
callbacks: SdkAgentCallbacks,
|
||||
signal?: AbortSignal,
|
||||
providerId?: string,
|
||||
model?: string,
|
||||
@@ -146,10 +145,11 @@ export async function runAcpAgentTurn(
|
||||
defaultTargetSession?: DefaultTargetSessionHint,
|
||||
userSkillsContext?: string,
|
||||
): Promise<void> {
|
||||
const acpBridge = bridge as unknown as AcpBridge;
|
||||
const sdkBridge = bridge as unknown as SdkAgentBridge;
|
||||
const sdkBackend = getExternalAgentSdkBackend(config);
|
||||
|
||||
if (!config.acpCommand) {
|
||||
callbacks.onError('Agent does not support ACP protocol');
|
||||
if (!sdkBackend) {
|
||||
callbacks.onError('Agent has no SDK backend configured');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export async function runAcpAgentTurn(
|
||||
};
|
||||
|
||||
// Set up event listeners before starting stream
|
||||
const unsubEvent = acpBridge.onAiAcpEvent(requestId, (event: StreamEvent) => {
|
||||
const unsubEvent = sdkBridge.onAiSdkAgentEvent(requestId, (event: StreamEvent) => {
|
||||
const streamFailed = handleStreamEvent(event, callbacks);
|
||||
if (streamFailed) {
|
||||
settle();
|
||||
@@ -176,16 +176,16 @@ export async function runAcpAgentTurn(
|
||||
});
|
||||
cleanupFns.push(unsubEvent);
|
||||
|
||||
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
|
||||
const unsubDone = sdkBridge.onAiSdkAgentDone(requestId, () => {
|
||||
settle(() => {
|
||||
callbacks.onDone();
|
||||
});
|
||||
});
|
||||
cleanupFns.push(unsubDone);
|
||||
|
||||
const unsubError = acpBridge.onAiAcpError(requestId, (error: unknown) => {
|
||||
const unsubError = sdkBridge.onAiSdkAgentError(requestId, (error: unknown) => {
|
||||
settle(() => {
|
||||
callbacks.onError(formatAcpErrorForDisplay(error));
|
||||
callbacks.onError(formatSdkAgentErrorForDisplay(error));
|
||||
});
|
||||
});
|
||||
cleanupFns.push(unsubError);
|
||||
@@ -200,18 +200,17 @@ export async function runAcpAgentTurn(
|
||||
if (!settle()) {
|
||||
return;
|
||||
}
|
||||
acpBridge.aiAcpCancel(requestId, chatSessionId).catch(() => {});
|
||||
sdkBridge.aiSdkAgentCancel(requestId, chatSessionId).catch(() => {});
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
|
||||
}
|
||||
|
||||
// Start the ACP stream in the main process
|
||||
void acpBridge.aiAcpStream(
|
||||
// Start the SDK stream in the main process
|
||||
void sdkBridge.aiSdkAgentStream(
|
||||
requestId,
|
||||
chatSessionId,
|
||||
config.acpCommand,
|
||||
config.acpArgs || [],
|
||||
sdkBackend,
|
||||
prompt,
|
||||
undefined, // cwd
|
||||
providerId,
|
||||
@@ -228,14 +227,14 @@ export async function runAcpAgentTurn(
|
||||
settle(() => {
|
||||
callbacks.onError(
|
||||
result.error == null
|
||||
? 'Failed to start ACP stream'
|
||||
: formatAcpErrorForDisplay(result.error),
|
||||
? 'Failed to start SDK agent stream'
|
||||
: formatSdkAgentErrorForDisplay(result.error),
|
||||
);
|
||||
});
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
settle(() => {
|
||||
callbacks.onError(formatAcpErrorForDisplay(err));
|
||||
callbacks.onError(formatSdkAgentErrorForDisplay(err));
|
||||
});
|
||||
}).finally(() => {
|
||||
if (settled) {
|
||||
@@ -258,7 +257,7 @@ function cleanup(fns: (() => void)[]) {
|
||||
* Handle a single stream event from the AI SDK fullStream.
|
||||
* Events come from `streamText().fullStream` in the main process.
|
||||
*/
|
||||
function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): boolean {
|
||||
function handleStreamEvent(event: StreamEvent, callbacks: SdkAgentCallbacks): boolean {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const text = (event.textDelta as string) || (event.delta as string) || '';
|
||||
@@ -280,9 +279,9 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): bo
|
||||
}
|
||||
case 'tool-call': {
|
||||
const toolName = (event.toolName as string) || 'unknown';
|
||||
// The Electron bridge serializes tool args as `args` (see
|
||||
// shellUtils.cjs serializeStreamChunk), while direct AI SDK paths
|
||||
// use `input`. Read both so either source works.
|
||||
// The Electron bridge emits tool args as `args` (see sdk/emit.cjs
|
||||
// toolCall), while direct AI SDK paths use `input`. Read both so
|
||||
// either source works.
|
||||
const input =
|
||||
(event.input as Record<string, unknown>) ||
|
||||
(event.args as Record<string, unknown>) ||
|
||||
@@ -312,7 +311,7 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): bo
|
||||
return false;
|
||||
}
|
||||
case 'error': {
|
||||
callbacks.onError(formatAcpErrorForDisplay(event.error));
|
||||
callbacks.onError(formatSdkAgentErrorForDisplay(event.error));
|
||||
return true;
|
||||
}
|
||||
// step-start, step-finish, etc. — ignore silently
|
||||
@@ -5,7 +5,7 @@
|
||||
* a Promise that resolves when the user approves/rejects from the UI, or after
|
||||
* a timeout (default 5 minutes) to prevent indefinite hangs.
|
||||
*
|
||||
* Also supports MCP/ACP tool calls from the Electron main process:
|
||||
* Also supports MCP/SDK-agent tool calls from the Electron main process:
|
||||
* the main process sends an IPC approval request, and we route it
|
||||
* through the same listener/UI system. MCP approvals are stored in
|
||||
* the same pendingApprovals map so they survive ChatMessageList
|
||||
@@ -185,7 +185,7 @@ export function clearAllPendingApprovals(chatSessionId?: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a bridge to receive MCP/ACP approval requests from the Electron main process.
|
||||
* Set up a bridge to receive MCP/SDK-agent approval requests from the Electron main process.
|
||||
* Subscribes to IPC events and stores them in the same pendingApprovals map,
|
||||
* so the same ToolCall UI handles both SDK and MCP approvals, and approvals
|
||||
* survive ChatMessageList unmount/remount cycles via replayPendingApprovals().
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
import defaultCommandBlocklist from '../../lib/commandBlocklist.json';
|
||||
import type { ProviderContinuation } from './providerContinuation';
|
||||
|
||||
export type AIProviderId = 'openai' | 'anthropic' | 'google' | 'ollama' | 'openrouter' | 'custom';
|
||||
export type AIProviderId =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'google'
|
||||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'qwen'
|
||||
| 'deepseek'
|
||||
| 'kimi'
|
||||
| 'zhipu'
|
||||
| 'doubao'
|
||||
| 'mimo'
|
||||
| 'custom';
|
||||
|
||||
/**
|
||||
* Wire-protocol family for a provider. Three are supported because every
|
||||
@@ -36,6 +48,10 @@ export interface ProviderConfig {
|
||||
customHeaders?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
skipTLSVerify?: boolean; // skip TLS certificate verification (for self-signed certs)
|
||||
/** User override for the model context window, in tokens. Wins over discovered model metadata. */
|
||||
contextWindow?: number;
|
||||
/** Context windows discovered from provider model-list metadata, keyed by model id. */
|
||||
modelContextWindows?: Record<string, number>;
|
||||
advancedParams?: ProviderAdvancedParams;
|
||||
}
|
||||
|
||||
@@ -66,7 +82,10 @@ export interface ChatMessageAttachment {
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filename?: string;
|
||||
filePath?: string; // original filesystem path (for ACP agents to read directly)
|
||||
filePath?: string; // original filesystem path, when available
|
||||
terminalSelection?: boolean;
|
||||
previewText?: string;
|
||||
lineCount?: number;
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
@@ -76,6 +95,9 @@ export interface UploadedFile {
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filePath?: string;
|
||||
terminalSelection?: boolean;
|
||||
previewText?: string;
|
||||
lineCount?: number;
|
||||
}
|
||||
|
||||
export interface AIDraft {
|
||||
@@ -200,7 +222,7 @@ export interface AgentInfo {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// External Agent (ACP) config
|
||||
// External agent config. Managed agents route through official SDK backends.
|
||||
export interface ExternalAgentConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -209,8 +231,11 @@ export interface ExternalAgentConfig {
|
||||
env?: Record<string, string>;
|
||||
icon?: string;
|
||||
enabled: boolean;
|
||||
/** ACP command (e.g. 'codex-acp', 'claude-agent-acp', 'gemini --experimental-acp') */
|
||||
/** SDK backend key for managed agents (claude|codex|copilot). */
|
||||
sdkBackend?: string;
|
||||
/** @deprecated Legacy persisted field from the pre-SDK migration. Read only for compatibility. */
|
||||
acpCommand?: string;
|
||||
/** @deprecated Legacy persisted field from the pre-SDK migration. */
|
||||
acpArgs?: string[];
|
||||
/** Internal: disabled only because the managed CLI was unavailable. */
|
||||
autoDisabledUntilAvailable?: boolean;
|
||||
@@ -226,9 +251,16 @@ export interface DiscoveredAgent {
|
||||
path: string;
|
||||
version: string;
|
||||
available: boolean;
|
||||
/** ACP command if agent supports ACP protocol */
|
||||
/** @deprecated Legacy discovery field from the pre-SDK migration. */
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
/** SDK backend key (claude|codex|copilot) — the post-migration routing value. */
|
||||
sdkBackend?: 'claude' | 'codex' | 'copilot';
|
||||
/** Absolute resolved CLI path (preferred over `path`). */
|
||||
binPath?: string;
|
||||
installed?: boolean;
|
||||
authenticated?: boolean;
|
||||
authSource?: string | null;
|
||||
}
|
||||
|
||||
// Web Search types
|
||||
@@ -295,13 +327,95 @@ export const DEFAULT_AI_SETTINGS: AISettings = {
|
||||
maxIterations: 20,
|
||||
};
|
||||
|
||||
export interface ProviderPreset {
|
||||
name: string;
|
||||
defaultBaseURL: string;
|
||||
modelsEndpoint?: string;
|
||||
defaultModels?: readonly string[];
|
||||
}
|
||||
|
||||
// Provider presets for quick setup
|
||||
export const PROVIDER_PRESETS: Record<AIProviderId, { name: string; defaultBaseURL: string; modelsEndpoint?: string }> = {
|
||||
export const PROVIDER_PRESETS: Record<AIProviderId, ProviderPreset> = {
|
||||
openai: { name: 'OpenAI', defaultBaseURL: 'https://api.openai.com/v1', modelsEndpoint: '/models' },
|
||||
anthropic: { name: 'Anthropic', defaultBaseURL: 'https://api.anthropic.com', modelsEndpoint: '/v1/models' },
|
||||
google: { name: 'Google AI', defaultBaseURL: 'https://generativelanguage.googleapis.com/v1beta' },
|
||||
ollama: { name: 'Ollama', defaultBaseURL: 'http://localhost:11434/v1', modelsEndpoint: '/models' },
|
||||
openrouter: { name: 'OpenRouter', defaultBaseURL: 'https://openrouter.ai/api/v1', modelsEndpoint: '/models' },
|
||||
qwen: {
|
||||
name: 'Qwen',
|
||||
defaultBaseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
modelsEndpoint: '/models',
|
||||
defaultModels: [
|
||||
'qwen3.7-plus',
|
||||
'qwen3.7-max',
|
||||
'qwen3.6-plus',
|
||||
'qwen3.6-flash',
|
||||
'qwen3.6-max-preview',
|
||||
'qwen3.5-plus',
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-flash',
|
||||
'qwen-plus',
|
||||
'qwen-plus-latest',
|
||||
],
|
||||
},
|
||||
deepseek: {
|
||||
name: 'DeepSeek',
|
||||
defaultBaseURL: 'https://api.deepseek.com/v1',
|
||||
modelsEndpoint: '/models',
|
||||
defaultModels: [
|
||||
'deepseek-v4-flash',
|
||||
'deepseek-v4-pro',
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
],
|
||||
},
|
||||
kimi: {
|
||||
name: 'Kimi',
|
||||
defaultBaseURL: 'https://api.moonshot.ai/v1',
|
||||
modelsEndpoint: '/models',
|
||||
defaultModels: [
|
||||
'kimi-k2.6',
|
||||
'kimi-k2.5',
|
||||
'moonshot-v1-128k',
|
||||
'moonshot-v1-32k',
|
||||
'moonshot-v1-8k',
|
||||
],
|
||||
},
|
||||
zhipu: {
|
||||
name: 'Zhipu',
|
||||
defaultBaseURL: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
modelsEndpoint: '/models',
|
||||
defaultModels: [
|
||||
'glm-5.1',
|
||||
'glm-5',
|
||||
'glm-5-turbo',
|
||||
'glm-4.7',
|
||||
'glm-4.7-flash',
|
||||
'glm-4.6',
|
||||
'glm-4.5',
|
||||
'glm-4.5-air',
|
||||
],
|
||||
},
|
||||
doubao: {
|
||||
name: 'Doubao',
|
||||
defaultBaseURL: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
modelsEndpoint: '/models',
|
||||
defaultModels: [
|
||||
'doubao-seed-2-0-pro-260215',
|
||||
'doubao-seed-2-0-lite-260215',
|
||||
'doubao-seed-2-0-mini-260215',
|
||||
'doubao-seed-2-0-code-preview-260215',
|
||||
],
|
||||
},
|
||||
mimo: {
|
||||
name: 'Xiaomi MiMo',
|
||||
defaultBaseURL: 'https://api.xiaomimimo.com/v1',
|
||||
modelsEndpoint: '/models',
|
||||
defaultModels: [
|
||||
'mimo-v2.5-pro',
|
||||
'mimo-v2.5',
|
||||
],
|
||||
},
|
||||
custom: { name: 'Custom', defaultBaseURL: '' },
|
||||
};
|
||||
|
||||
@@ -320,14 +434,17 @@ export const CLAUDE_MODEL_PRESETS: AgentModelPreset[] = [
|
||||
{ id: 'haiku', name: 'Haiku 4.5', description: 'Fastest' },
|
||||
];
|
||||
|
||||
// Curated codex model list (codex-sdk has no enumeration API). Mirrors the
|
||||
// craft agent's `openai-codex` set. The codex driver splits "<id>/<effort>"
|
||||
// into model + modelReasoningEffort, so thinkingLevels work via codex-sdk.
|
||||
export const CODEX_MODEL_PRESETS: AgentModelPreset[] = [
|
||||
{ id: 'gpt-5.4', name: 'GPT 5.4', description: 'Latest', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.3-codex', name: 'Codex 5.3', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.2-codex', name: 'Codex 5.2', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.1-codex-max', name: 'Codex 5.1 Max', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.1-codex-mini', name: 'Codex 5.1 Mini', description: 'Fast', thinkingLevels: ['medium', 'high'] },
|
||||
{ id: 'o3', name: 'o3', description: 'Reasoning' },
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5', description: 'Latest', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.2', name: 'GPT-5.2', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.1', name: 'GPT-5.1', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5', name: 'GPT-5', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'o4-mini', name: 'o4-mini', description: 'Fast reasoning' },
|
||||
{ id: 'o3', name: 'o3', description: 'Reasoning' },
|
||||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||
];
|
||||
|
||||
export function getAgentModelPresets(agentCommand?: string): AgentModelPreset[] {
|
||||
|
||||
@@ -106,6 +106,7 @@ export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
|
||||
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
|
||||
export const STORAGE_KEY_SESSION_LOGS_DIR = 'netcatty_session_logs_dir_v1';
|
||||
export const STORAGE_KEY_SESSION_LOGS_FORMAT = 'netcatty_session_logs_format_v1';
|
||||
export const STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED = 'netcatty_session_logs_timestamps_enabled_v1';
|
||||
export const STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED = 'netcatty_ssh_debug_logs_enabled_v1';
|
||||
|
||||
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
|
||||
@@ -118,7 +119,7 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
|
||||
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
|
||||
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
|
||||
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
|
||||
|
||||
export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1';
|
||||
// Custom Terminal Themes
|
||||
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Host, Identity, PortForwardingRule, SSHKey, TerminalSettings } from '..
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { resolveHostKeepalive } from '../../domain/host';
|
||||
import { hasUsableProxyConfig } from '../../domain/proxyProfiles';
|
||||
|
||||
// Fallback matching DEFAULT_TERMINAL_SETTINGS so older call sites that don't
|
||||
// thread terminalSettings still get the cloud-friendly defaults.
|
||||
@@ -396,6 +397,7 @@ export const startPortForward = async (
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
command: host.proxyConfig.command,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
@@ -417,7 +419,7 @@ export const startPortForward = async (
|
||||
}
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
hasUsableProxyConfig(jumpHost.proxyConfig);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
@@ -460,11 +462,12 @@ export const startPortForward = async (
|
||||
keyId: jumpResolved.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
command: jumpHost.proxyConfig.command,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user