fix(codebuddy): harden V2 sessions and elicitation flow
This commit is contained in:
1 parent
a4b0926ded
commit
a5b4db1ac8
24 files changed
+1305
-86
No files matched your search
@@ -227,3 +227,70 @@ test('CodebuddyElicitationCard can clear optional constrained values back to omi
|
||||
renderer!.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
test('CodebuddyElicitationCard submits required empty values when no size constraint forbids them', async () => {
|
||||
const responses: Array<{
|
||||
action: string;
|
||||
content?: Record<string, unknown>;
|
||||
}> = [];
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodebuddyElicitationCard
|
||||
elicitation={{
|
||||
elicitationId: 'el-required-empty',
|
||||
chatSessionId: 'chat-1',
|
||||
request: {
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
note: {
|
||||
type: 'string',
|
||||
default: 'temporary',
|
||||
},
|
||||
regions: {
|
||||
type: 'array',
|
||||
default: ['us-east'],
|
||||
items: { enum: ['us-east'] },
|
||||
},
|
||||
},
|
||||
required: ['note', 'regions'],
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRespond={async (action, content) => {
|
||||
responses.push({ action, content });
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const noteInput = renderer!.root.findByProps({ type: 'text' });
|
||||
const regionInput = renderer!.root.findByProps({ type: 'checkbox' });
|
||||
await act(async () => {
|
||||
noteInput.props.onChange({ target: { value: '' } });
|
||||
regionInput.props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
const continueButton = renderer!.root
|
||||
.findAllByType('button')
|
||||
.find((button) => button.children.join('') === 'ai.codebuddy.elicitation.accept');
|
||||
assert.ok(continueButton);
|
||||
assert.equal(continueButton.props.disabled, false);
|
||||
|
||||
await act(async () => {
|
||||
continueButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.deepEqual(responses, [{
|
||||
action: 'accept',
|
||||
content: {
|
||||
note: '',
|
||||
regions: [],
|
||||
},
|
||||
}]);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
});
|
||||
});
|
||||
@@ -148,7 +148,9 @@ export const CodebuddyElicitationCard: React.FC<{
|
||||
);
|
||||
return {
|
||||
...current,
|
||||
[field.id]: next.length === 0 ? undefined : next,
|
||||
[field.id]: next.length === 0 && !field.required
|
||||
? undefined
|
||||
: next,
|
||||
};
|
||||
});
|
||||
}}
|
||||
@@ -216,7 +218,9 @@ export const CodebuddyElicitationCard: React.FC<{
|
||||
markFieldTouched(field.id);
|
||||
const raw = event.target.value;
|
||||
const value = raw === ''
|
||||
? undefined
|
||||
? field.type === 'string' && field.required
|
||||
? ''
|
||||
: undefined
|
||||
: field.type === 'number' || field.type === 'integer'
|
||||
? Number(raw)
|
||||
: raw;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildManagedAgentState,
|
||||
getInitialManagedAgentPaths,
|
||||
updateCodebuddyManagedEnv,
|
||||
updateCodebuddyManagedOptions,
|
||||
} from '../settings/tabs/ai/managedAgentState';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
@@ -384,6 +385,46 @@ test('updateCodebuddyManagedEnv removes an empty pre-detection placeholder', ()
|
||||
assert.deepEqual(cleared, []);
|
||||
});
|
||||
|
||||
test('updateCodebuddyManagedOptions persists settings before CLI detection', () => {
|
||||
const state = updateCodebuddyManagedOptions([], {
|
||||
effort: 'high',
|
||||
enableFileCheckpointing: true,
|
||||
});
|
||||
|
||||
assert.equal(state.length, 1);
|
||||
assert.equal(state[0].id, 'discovered_codebuddy');
|
||||
assert.equal(state[0].command, 'codebuddy');
|
||||
assert.equal(state[0].enabled, false);
|
||||
assert.deepEqual(state[0].codebuddyOptions, {
|
||||
effort: 'high',
|
||||
enableFileCheckpointing: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves advanced CodeBuddy config when detection fails', () => {
|
||||
const agents = updateCodebuddyManagedOptions([], { effort: 'high' });
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_codebuddy',
|
||||
'codebuddy',
|
||||
{ path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
assert.equal(state.defaultAgentId, 'catty');
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].enabled, false);
|
||||
assert.equal(state.agents[0].available, false);
|
||||
assert.deepEqual(state.agents[0].codebuddyOptions, { effort: 'high' });
|
||||
});
|
||||
|
||||
test('updateCodebuddyManagedOptions removes an empty pre-detection placeholder', () => {
|
||||
const agents = updateCodebuddyManagedOptions([], { effort: 'high' });
|
||||
const cleared = updateCodebuddyManagedOptions(agents, undefined);
|
||||
|
||||
assert.deepEqual(cleared, []);
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
buildManagedAgentState,
|
||||
getInitialManagedAgentPaths,
|
||||
updateCodebuddyManagedEnv,
|
||||
updateCodebuddyManagedOptions,
|
||||
} from "./ai/managedAgentState";
|
||||
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
|
||||
import { splitCodebuddyEnv } from "./ai/codebuddyConfigEnv";
|
||||
@@ -290,11 +291,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const codebuddyAdvancedOptions = codebuddyManagedAgent?.codebuddyOptions;
|
||||
const updateCodebuddyAdvancedOptions = useCallback(
|
||||
(options: import("../../../infrastructure/ai/types").CodebuddyAdvancedOptions | undefined) => {
|
||||
setExternalAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === "discovered_codebuddy" ? { ...a, codebuddyOptions: options } : a,
|
||||
),
|
||||
);
|
||||
setExternalAgents((prev) => updateCodebuddyManagedOptions(prev, options));
|
||||
},
|
||||
[setExternalAgents],
|
||||
);
|
||||
|
||||
@@ -65,7 +65,14 @@ export function buildManagedAgentState(
|
||||
};
|
||||
}
|
||||
if (agentKey === "codebuddy") {
|
||||
if (existingManaged?.env && Object.keys(existingManaged.env).length > 0) {
|
||||
const hasSavedCodebuddyConfig = Boolean(
|
||||
(existingManaged?.env && Object.keys(existingManaged.env).length > 0) ||
|
||||
(
|
||||
existingManaged?.codebuddyOptions &&
|
||||
Object.keys(existingManaged.codebuddyOptions).length > 0
|
||||
),
|
||||
);
|
||||
if (hasSavedCodebuddyConfig) {
|
||||
return {
|
||||
agents: [
|
||||
...otherAgents,
|
||||
@@ -75,6 +82,7 @@ export function buildManagedAgentState(
|
||||
id: managedId,
|
||||
command: existingManaged.command || "codebuddy",
|
||||
enabled: false,
|
||||
available: false,
|
||||
},
|
||||
],
|
||||
defaultAgentId: existingManaged.id === defaultAgentId ? "catty" : defaultAgentId,
|
||||
@@ -182,6 +190,42 @@ export function updateCodebuddyManagedEnv(
|
||||
];
|
||||
}
|
||||
|
||||
export function updateCodebuddyManagedOptions(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
options: ExternalAgentConfig['codebuddyOptions'],
|
||||
): ExternalAgentConfig[] {
|
||||
const managedId = "discovered_codebuddy";
|
||||
const existingManaged = prevAgents.find((agent) => agent.id === managedId);
|
||||
|
||||
if (existingManaged) {
|
||||
if (
|
||||
!options &&
|
||||
(!existingManaged.env || Object.keys(existingManaged.env).length === 0) &&
|
||||
!isPathLikeCommand(existingManaged.command)
|
||||
) {
|
||||
return prevAgents.filter((agent) => agent.id !== managedId);
|
||||
}
|
||||
return prevAgents.map((agent) =>
|
||||
agent.id === managedId
|
||||
? { ...agent, codebuddyOptions: options }
|
||||
: agent,
|
||||
);
|
||||
}
|
||||
|
||||
if (!options) return prevAgents;
|
||||
|
||||
return [
|
||||
...prevAgents,
|
||||
{
|
||||
...AGENT_DEFAULTS.codebuddy,
|
||||
id: managedId,
|
||||
command: "codebuddy",
|
||||
enabled: false,
|
||||
codebuddyOptions: options,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
|
||||
return {
|
||||
codex: getAutoManagedAgentStoredPath(agents, "codex") ?? "",
|
||||
|
||||
@@ -231,3 +231,39 @@ test('CodeBuddy elicitation validation follows standard email and date-time boun
|
||||
[{ fieldId: 'email', code: 'format' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('CodeBuddy required fields allow empty values unless size constraints reject them', () => {
|
||||
const fields = parseCodebuddyElicitationFields({
|
||||
type: 'object',
|
||||
properties: {
|
||||
note: { type: 'string' },
|
||||
choices: {
|
||||
type: 'array',
|
||||
items: { enum: ['a', 'b'] },
|
||||
},
|
||||
constrainedNote: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
constrainedChoices: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: { enum: ['a', 'b'] },
|
||||
},
|
||||
},
|
||||
required: ['note', 'choices', 'constrainedNote', 'constrainedChoices'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
validateCodebuddyElicitationValues(fields, {
|
||||
note: '',
|
||||
choices: [],
|
||||
constrainedNote: '',
|
||||
constrainedChoices: [],
|
||||
}).map(({ fieldId, code }) => ({ fieldId, code })),
|
||||
[
|
||||
{ fieldId: 'constrainedNote', code: 'minLength' },
|
||||
{ fieldId: 'constrainedChoices', code: 'minItems' },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -245,7 +245,6 @@ function validateField(
|
||||
|
||||
if (field.type === 'array') {
|
||||
if (!Array.isArray(value)) return issue(field, 'invalidType');
|
||||
if (field.required && value.length === 0) return issue(field, 'required');
|
||||
if (field.minItems !== undefined && value.length < field.minItems) {
|
||||
return issue(field, 'minItems', { limit: field.minItems });
|
||||
}
|
||||
@@ -281,7 +280,6 @@ function validateField(
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') return issue(field, 'invalidType');
|
||||
if (field.required && value.trim().length === 0) return issue(field, 'required');
|
||||
const length = [...value].length;
|
||||
if (field.minLength !== undefined && length < field.minLength) {
|
||||
return issue(field, 'minLength', { limit: field.minLength });
|
||||
|
||||
@@ -170,7 +170,8 @@ function buildExternalAgentSystemContext({ mode, chatSessionId, defaultTargetSes
|
||||
`${scopeHint}` +
|
||||
`${defaultTargetHint}` +
|
||||
`Use Skills + CLI instead of the "netcatty-remote-hosts" MCP server for Netcatty session access. ` +
|
||||
`Use the local shell only to invoke Netcatty CLI commands or inspect local attachments explicitly supplied by the user. Do not use local shell or filesystem tools for unrelated local-machine work. ` +
|
||||
`Use the local shell only to invoke Netcatty CLI commands. Do not use local shell or filesystem tools for unrelated local-machine work. ` +
|
||||
`For files explicitly attached by the user, call \`${cliCommandPrefix} attachment list --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, then read the selected file with \`${cliCommandPrefix} attachment read --filename <filename> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`. ` +
|
||||
`First classify the task: remote command execution tasks go through \`exec\`, while remote file or directory tasks go through \`sftp\`. If the user explicitly says to avoid shell or \`exec\`, do not use \`exec\`. Treat \`exec\` as the short-command path only: use it only for commands expected to finish within about 60 seconds. For builds, scans, watch mode, tail-following, ping, or anything likely to exceed that budget or stream output for an extended period, do not use plain \`exec\`; use the long-running job commands instead. ` +
|
||||
`${discoveryHint}` +
|
||||
`After choosing a target session ID, call \`${cliCommandPrefix} session --session <id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` before executing anything. Do not infer protocol, shell type, device type, or connection readiness from the \`env\` result alone when you are about to run a command. ` +
|
||||
|
||||
@@ -3,7 +3,20 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const electronPath = require.resolve("electron");
|
||||
const previousElectron = require.cache[electronPath];
|
||||
require.cache[electronPath] = {
|
||||
id: electronPath,
|
||||
filename: electronPath,
|
||||
loaded: true,
|
||||
exports: { dialog: {}, shell: {} },
|
||||
};
|
||||
const { buildExternalAgentSystemContext } = require("./aiBridge.cjs");
|
||||
if (previousElectron) {
|
||||
require.cache[electronPath] = previousElectron;
|
||||
} else {
|
||||
delete require.cache[electronPath];
|
||||
}
|
||||
|
||||
test("buildExternalAgentSystemContext (MCP mode) includes vault host vs notes guidance", () => {
|
||||
const context = buildExternalAgentSystemContext({
|
||||
@@ -14,3 +27,14 @@ test("buildExternalAgentSystemContext (MCP mode) includes vault host vs notes gu
|
||||
assert.match(context, /NOT vault_notes_create/i);
|
||||
assert.match(context, /do not silently create a Vault note/i);
|
||||
});
|
||||
|
||||
test("buildExternalAgentSystemContext (skills mode) routes attachments through Netcatty CLI", () => {
|
||||
const context = buildExternalAgentSystemContext({
|
||||
mode: "skills",
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
|
||||
assert.match(context, /attachment list --json --chat-session chat-1/i);
|
||||
assert.match(context, /attachment read --filename <filename> --json --chat-session chat-1/i);
|
||||
assert.match(context, /Use the local shell only to invoke Netcatty CLI commands/i);
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
* resume, thinking mode config, and model listing.
|
||||
*/
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
const { isLikelyNetcattyCliShellCommand } = require("./copilotDriver.cjs");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
// Built-in tools that need interactive UI netcatty doesn't provide — they would
|
||||
@@ -240,20 +241,33 @@ function buildCodebuddyQueryOptions({
|
||||
: systemPrompt;
|
||||
}
|
||||
// Effort level for model reasoning.
|
||||
if (effort) options.effort = effort;
|
||||
if (["low", "medium", "high", "xhigh"].includes(effort)) {
|
||||
options.effort = effort;
|
||||
}
|
||||
// Safety guardrails.
|
||||
if (maxTurns != null && maxTurns > 0) options.maxTurns = maxTurns;
|
||||
if (maxBudgetUsd != null && maxBudgetUsd > 0) options.maxBudgetUsd = maxBudgetUsd;
|
||||
if (Number.isInteger(maxTurns) && maxTurns > 0) options.maxTurns = maxTurns;
|
||||
if (Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0) {
|
||||
options.maxBudgetUsd = maxBudgetUsd;
|
||||
}
|
||||
// Fallback model when primary is unavailable.
|
||||
if (fallbackModel) options.fallbackModel = fallbackModel;
|
||||
if (typeof fallbackModel === "string" && fallbackModel.trim()) {
|
||||
options.fallbackModel = fallbackModel.trim();
|
||||
}
|
||||
// Sandbox settings.
|
||||
if (sandbox && typeof sandbox === "object") options.sandbox = sandbox;
|
||||
if (
|
||||
sandbox &&
|
||||
typeof sandbox === "object" &&
|
||||
!Array.isArray(sandbox) &&
|
||||
sandbox.enabled === true
|
||||
) {
|
||||
options.sandbox = sandbox;
|
||||
}
|
||||
// Custom subagent definitions.
|
||||
if (agents && typeof agents === "object") options.agents = agents;
|
||||
// Structured output format.
|
||||
if (outputFormat && typeof outputFormat === "object") options.outputFormat = outputFormat;
|
||||
// File checkpointing for rollback.
|
||||
if (enableFileCheckpointing != null) options.enableFileCheckpointing = enableFileCheckpointing;
|
||||
if (enableFileCheckpointing === true) options.enableFileCheckpointing = true;
|
||||
// Distributed tracing.
|
||||
if (traceId) options.traceId = traceId;
|
||||
if (parentSpanId) options.parentSpanId = parentSpanId;
|
||||
@@ -294,7 +308,7 @@ function translateCodebuddyMessage(message, emitter, opts = {}) {
|
||||
const type = message.type;
|
||||
|
||||
if (type === "system") {
|
||||
if (message.session_id) emitter.sessionId(message.session_id);
|
||||
if (!opts.skipSessionId && message.session_id) emitter.sessionId(message.session_id);
|
||||
if (message.message) emitter.status(message.message);
|
||||
return;
|
||||
}
|
||||
@@ -312,13 +326,28 @@ function translateCodebuddyMessage(message, emitter, opts = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
emitter.emitError(String(message.error || "CodeBuddy execution failed."));
|
||||
return { terminalError: true };
|
||||
}
|
||||
|
||||
if (type === "assistant" && message.message && Array.isArray(message.message.content)) {
|
||||
if (message.error) {
|
||||
emitter.emitError(String(message.error));
|
||||
return { terminalError: true };
|
||||
}
|
||||
for (const block of message.message.content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if (block.type === "tool_use") {
|
||||
emitter.toolCall(block.name, block.input || {}, block.id);
|
||||
} else if (!opts.skipAssistantText && block.type === "text" && block.text) {
|
||||
emitter.text(block.text);
|
||||
} else if (
|
||||
!opts.skipAssistantReasoning &&
|
||||
block.type === "thinking" &&
|
||||
block.thinking
|
||||
) {
|
||||
emitter.reasoning(block.thinking);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -341,8 +370,26 @@ function translateCodebuddyMessage(message, emitter, opts = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// result carries final duration/cost/usage — emit as status summary.
|
||||
// result carries final duration/cost/usage.
|
||||
if (type === "result") {
|
||||
const usage = message.usage;
|
||||
if (usage && typeof usage === "object") {
|
||||
const uncachedInputTokens = Number(usage.input_tokens) || 0;
|
||||
const cachedInputTokens = Number(usage.cache_read_input_tokens) || 0;
|
||||
const cacheCreationInputTokens = Number(usage.cache_creation_input_tokens) || 0;
|
||||
// CodeBuddy follows Anthropic usage semantics: cache read/creation tokens
|
||||
// are reported separately from input_tokens. AgentUsage expects total
|
||||
// prompt tokens with cachedPromptTokens as the cached subset.
|
||||
const inputTokens =
|
||||
uncachedInputTokens + cachedInputTokens + cacheCreationInputTokens;
|
||||
const outputTokens = Number(usage.output_tokens) || 0;
|
||||
emitter.usage?.({
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
});
|
||||
}
|
||||
const parts = [];
|
||||
if (message.num_turns) parts.push(`${message.num_turns} turns`);
|
||||
if (message.total_cost_usd > 0) parts.push(`$${message.total_cost_usd.toFixed(4)}`);
|
||||
@@ -352,11 +399,55 @@ function translateCodebuddyMessage(message, emitter, opts = {}) {
|
||||
if (parts.length > 0) {
|
||||
emitter.status(`CodeBuddy: ${parts.join(", ")}`);
|
||||
}
|
||||
return;
|
||||
if (message.is_error) {
|
||||
const detail = Array.isArray(message.errors) && message.errors.length > 0
|
||||
? message.errors.join("; ")
|
||||
: message.subtype === "error_max_turns"
|
||||
? "CodeBuddy stopped after reaching the maximum turn limit."
|
||||
: message.subtype === "error_max_budget_usd"
|
||||
? "CodeBuddy stopped after reaching the configured budget limit."
|
||||
: "CodeBuddy execution failed.";
|
||||
emitter.emitError(detail);
|
||||
return { terminalError: true };
|
||||
}
|
||||
return { terminalError: false };
|
||||
}
|
||||
// tool_progress, compact_boundary — no renderer mapping.
|
||||
}
|
||||
|
||||
function inspectCodebuddyMessageContent(message) {
|
||||
const delta = message?.type === "stream_event" ? message.event?.delta : null;
|
||||
const blocks = Array.isArray(message?.message?.content) ? message.message.content : [];
|
||||
const streamedText = delta?.type === "text_delta" && Boolean(delta.text);
|
||||
const streamedReasoning = delta?.type === "thinking_delta" && Boolean(delta.thinking);
|
||||
const assistantText = message?.type === "assistant" &&
|
||||
blocks.some((block) => block?.type === "text" && block.text);
|
||||
const assistantReasoning = message?.type === "assistant" &&
|
||||
blocks.some((block) => block?.type === "thinking" && block.thinking);
|
||||
const assistantTool = message?.type === "assistant" &&
|
||||
blocks.some((block) => block?.type === "tool_use");
|
||||
const toolResult = message?.type === "user" &&
|
||||
blocks.some((block) => block?.type === "tool_result");
|
||||
return {
|
||||
hasContent: streamedText || streamedReasoning ||
|
||||
assistantText || assistantReasoning || assistantTool || toolResult,
|
||||
hasText: streamedText || assistantText,
|
||||
streamedText,
|
||||
streamedReasoning,
|
||||
};
|
||||
}
|
||||
|
||||
function codebuddyResultFallbackText(message) {
|
||||
if (message?.type !== "result" || message.is_error) return "";
|
||||
if (typeof message.result === "string" && message.result) return message.result;
|
||||
if (message.structured_output === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(message.structured_output, null, 2);
|
||||
} catch {
|
||||
return String(message.structured_output);
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a spawn failure for user-friendly error messages. */
|
||||
function classifyCodebuddySpawnError(error) {
|
||||
const code = error && error.code;
|
||||
@@ -413,6 +504,11 @@ function buildCodebuddyPromptInput(prompt, attachments) {
|
||||
* @param {Function} [args.queryFn] inject @tencent-ai/agent-sdk query (for tests)
|
||||
*/
|
||||
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn }) {
|
||||
if (options.abortController?.signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId: null };
|
||||
}
|
||||
|
||||
let query = queryFn;
|
||||
if (!query) {
|
||||
let sdk;
|
||||
@@ -427,7 +523,11 @@ async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn
|
||||
|
||||
let sessionId = null;
|
||||
let hasContent = false;
|
||||
let hasAssistantText = false;
|
||||
let hasStreamedText = false;
|
||||
let hasStreamedReasoning = false;
|
||||
let hasTerminalError = false;
|
||||
let resultFallbackText = "";
|
||||
let queryRef = null;
|
||||
let removeAbortListener = null;
|
||||
try {
|
||||
@@ -460,21 +560,28 @@ async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn
|
||||
if (message?.session_id && message.session_id !== sessionId) {
|
||||
sessionId = message.session_id;
|
||||
}
|
||||
if (
|
||||
message?.type === "stream_event" ||
|
||||
(message?.type === "assistant" && Array.isArray(message?.message?.content) && message.message.content.length > 0)
|
||||
) {
|
||||
hasContent = true;
|
||||
}
|
||||
translateCodebuddyMessage(message, emitter, { skipAssistantText: hasStreamedText });
|
||||
if (
|
||||
message?.type === "stream_event" &&
|
||||
message.event?.type === "content_block_delta" &&
|
||||
message.event?.delta?.type === "text_delta" &&
|
||||
message.event.delta.text
|
||||
) {
|
||||
hasStreamedText = true;
|
||||
}
|
||||
const contentState = inspectCodebuddyMessageContent(message);
|
||||
if (contentState.hasContent) hasContent = true;
|
||||
if (contentState.hasText) hasAssistantText = true;
|
||||
resultFallbackText ||= codebuddyResultFallbackText(message);
|
||||
const translation = translateCodebuddyMessage(
|
||||
message,
|
||||
emitter,
|
||||
{
|
||||
skipAssistantText: hasStreamedText,
|
||||
skipAssistantReasoning: hasStreamedReasoning,
|
||||
},
|
||||
);
|
||||
if (translation?.terminalError) hasTerminalError = true;
|
||||
if (contentState.streamedText) hasStreamedText = true;
|
||||
if (contentState.streamedReasoning) hasStreamedReasoning = true;
|
||||
}
|
||||
if (hasTerminalError) {
|
||||
return { sessionId };
|
||||
}
|
||||
if (!hasAssistantText && resultFallbackText) {
|
||||
emitter.text(resultFallbackText);
|
||||
hasContent = true;
|
||||
}
|
||||
if (!hasContent && !options.abortController?.signal?.aborted) {
|
||||
emitter.emitError(
|
||||
@@ -486,6 +593,10 @@ async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn
|
||||
emitter.emitDone();
|
||||
return { sessionId };
|
||||
} catch (error) {
|
||||
if (options.abortController?.signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId };
|
||||
}
|
||||
const classified = classifyCodebuddySpawnError(error);
|
||||
if (classified.isSpawnEnoent) {
|
||||
emitter.emitError(
|
||||
@@ -579,7 +690,18 @@ async function listCodebuddyModels({ pathToCodebuddyCode, env, queryFn }) {
|
||||
* @param {object} emitter createStreamEmitter(...)
|
||||
* @returns {object} hooks map suitable for Options.hooks
|
||||
*/
|
||||
function buildCodebuddyHooks(emitter) {
|
||||
function isAllowedCodebuddySkillsBash(command, allowedCliCommandPrefix) {
|
||||
const text = String(command || "").trim();
|
||||
const prefix = String(allowedCliCommandPrefix || "").trim();
|
||||
if (!text || !prefix) return false;
|
||||
if (text !== prefix && !text.startsWith(`${prefix} `)) return false;
|
||||
return isLikelyNetcattyCliShellCommand(text);
|
||||
}
|
||||
|
||||
function buildCodebuddyHooks(
|
||||
emitter,
|
||||
{ toolIntegrationMode, additionalHooks, allowedCliCommandPrefix } = {},
|
||||
) {
|
||||
const makeHook = (hookEventName, extract) => ([{
|
||||
hooks: [async (input, _toolUseID, _opts) => {
|
||||
try {
|
||||
@@ -589,12 +711,40 @@ function buildCodebuddyHooks(emitter) {
|
||||
}],
|
||||
}]);
|
||||
|
||||
return {
|
||||
PreToolUse: makeHook("PreToolUse", (input) => ({
|
||||
toolName: input.tool_name,
|
||||
toolInput: input.tool_input,
|
||||
toolUseId: input.tool_use_id,
|
||||
})),
|
||||
const hooks = {
|
||||
PreToolUse: [{
|
||||
hooks: [async (input, _toolUseID, _opts) => {
|
||||
try {
|
||||
emitter.emitEvent({
|
||||
type: "hook",
|
||||
hookEvent: "PreToolUse",
|
||||
toolName: input.tool_name,
|
||||
toolInput: input.tool_input,
|
||||
toolUseId: input.tool_use_id,
|
||||
});
|
||||
} catch { /* best effort — never block the turn */ }
|
||||
if (
|
||||
toolIntegrationMode === "skills" &&
|
||||
input.tool_name === "Bash" &&
|
||||
(
|
||||
input.tool_input?.run_in_background === true ||
|
||||
!isAllowedCodebuddySkillsBash(
|
||||
input.tool_input?.command,
|
||||
allowedCliCommandPrefix,
|
||||
)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
continue: true,
|
||||
decision: "block",
|
||||
reason:
|
||||
"Only Netcatty CLI commands are allowed in Skills mode. " +
|
||||
"Use the netcatty-tool-cli command prefix provided by the host.",
|
||||
};
|
||||
}
|
||||
return { continue: true };
|
||||
}],
|
||||
}],
|
||||
PostToolUse: makeHook("PostToolUse", (input) => ({
|
||||
toolName: input.tool_name,
|
||||
toolInput: input.tool_input,
|
||||
@@ -615,6 +765,11 @@ function buildCodebuddyHooks(emitter) {
|
||||
notificationType: input.notification_type,
|
||||
})),
|
||||
};
|
||||
for (const [eventName, matchers] of Object.entries(additionalHooks || {})) {
|
||||
if (!Array.isArray(matchers) || matchers.length === 0) continue;
|
||||
hooks[eventName] = [...(hooks[eventName] || []), ...matchers];
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -633,13 +788,21 @@ function buildCodebuddyHooks(emitter) {
|
||||
* @returns {object} ElicitationHandler
|
||||
*/
|
||||
function buildCodebuddyElicitation(emitter, pendingMap, { chatSessionId } = {}) {
|
||||
const scopedId = (protocolElicitationId) => {
|
||||
const rawId = String(protocolElicitationId || "");
|
||||
if (!rawId) return "";
|
||||
return chatSessionId
|
||||
? `codebuddy:${encodeURIComponent(String(chatSessionId))}:${encodeURIComponent(rawId)}`
|
||||
: rawId;
|
||||
};
|
||||
return {
|
||||
async create(request, { signal } = {}) {
|
||||
if (signal?.aborted) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
const elicitationId = request?._meta?.["codebuddy.ai"]?.elicitationId
|
||||
const protocolElicitationId = request?._meta?.["codebuddy.ai"]?.elicitationId
|
||||
|| `elicitation_${randomUUID()}`;
|
||||
const elicitationId = scopedId(protocolElicitationId);
|
||||
|
||||
const previous = pendingMap.get(elicitationId);
|
||||
if (previous) {
|
||||
@@ -687,13 +850,20 @@ function buildCodebuddyElicitation(emitter, pendingMap, { chatSessionId } = {})
|
||||
});
|
||||
},
|
||||
complete(notification) {
|
||||
const elicitationId = String(notification?.elicitationId || "");
|
||||
const protocolElicitationId = String(notification?.elicitationId || "");
|
||||
const elicitationId = scopedId(protocolElicitationId);
|
||||
const pending = elicitationId ? pendingMap.get(elicitationId) : null;
|
||||
if (pending) {
|
||||
pendingMap.delete(elicitationId);
|
||||
pending.resolve({ action: "cancel" });
|
||||
}
|
||||
emitter.emitEvent({ type: "elicitation-complete", notification });
|
||||
emitter.emitEvent({
|
||||
type: "elicitation-complete",
|
||||
notification: {
|
||||
...notification,
|
||||
elicitationId,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -799,6 +969,8 @@ module.exports = {
|
||||
buildCodebuddyQueryOptions,
|
||||
buildCodebuddyCanUseTool,
|
||||
translateCodebuddyMessage,
|
||||
inspectCodebuddyMessageContent,
|
||||
codebuddyResultFallbackText,
|
||||
classifyCodebuddySpawnError,
|
||||
buildCodebuddyPromptInput,
|
||||
buildCodebuddyThinkingEnv,
|
||||
@@ -810,6 +982,7 @@ module.exports = {
|
||||
mapCodebuddyModels,
|
||||
codebuddyBuiltinTools,
|
||||
buildCodebuddyHooks,
|
||||
isAllowedCodebuddySkillsBash,
|
||||
buildCodebuddyElicitation,
|
||||
getCodebuddyMcpStatus,
|
||||
getCodebuddyAccountInfo,
|
||||
|
||||
@@ -21,6 +21,7 @@ function collector() {
|
||||
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||||
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
|
||||
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
|
||||
usage: (usage) => events.push({ k: "usage", usage }),
|
||||
status: (m) => events.push({ k: "status", m }),
|
||||
sessionId: (s) => events.push({ k: "sessionId", s }),
|
||||
emitDone: () => events.push({ k: "done" }),
|
||||
@@ -91,6 +92,18 @@ test("translateCodebuddyMessage can skip consolidated assistant text after strea
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage preserves consolidated reasoning without deltas", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodebuddyMessage(
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "thinking", thinking: "check the fallback" }] },
|
||||
},
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, [{ k: "reasoning", d: "check the fallback" }]);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage maps stream deltas, tool calls, and tool results", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodebuddyMessage(
|
||||
@@ -129,6 +142,136 @@ test("translateCodebuddyMessage emits system session id and status text", () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn preserves explicit SDK error messages", async () => {
|
||||
const { events, emitter } = collector();
|
||||
async function* fakeQuery() {
|
||||
yield {
|
||||
type: "error",
|
||||
session_id: "sess-error",
|
||||
error: "Provider quota exceeded",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await runCodebuddyTurn({
|
||||
prompt: "hello",
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
queryFn: fakeQuery,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { sessionId: "sess-error" });
|
||||
assert.deepEqual(events, [{ k: "error", m: "Provider quota exceeded" }]);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage emits actual result usage", () => {
|
||||
const { events, emitter } = collector();
|
||||
const result = translateCodebuddyMessage({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 321,
|
||||
output_tokens: 45,
|
||||
cache_read_input_tokens: 100,
|
||||
cache_creation_input_tokens: 20,
|
||||
},
|
||||
}, emitter);
|
||||
|
||||
assert.deepEqual(result, { terminalError: false });
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
k: "usage",
|
||||
usage: {
|
||||
inputTokens: 441,
|
||||
cachedInputTokens: 100,
|
||||
outputTokens: 45,
|
||||
totalTokens: 486,
|
||||
},
|
||||
},
|
||||
{ k: "status", m: "CodeBuddy: 1 turns" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn reports terminal result subtypes instead of an auth error", async () => {
|
||||
const { events, emitter } = collector();
|
||||
async function* fakeQuery() {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "error_max_budget_usd",
|
||||
is_error: true,
|
||||
num_turns: 2,
|
||||
total_cost_usd: 1,
|
||||
usage: { input_tokens: 10, output_tokens: 2 },
|
||||
permission_denials: [],
|
||||
};
|
||||
}
|
||||
|
||||
await runCodebuddyTurn({
|
||||
prompt: "spend",
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
queryFn: () => fakeQuery(),
|
||||
});
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
k: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12,
|
||||
},
|
||||
},
|
||||
{ k: "status", m: "CodeBuddy: 2 turns, $1.0000" },
|
||||
{ k: "error", m: "CodeBuddy stopped after reaching the configured budget limit." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn renders a successful result fallback when no text delta arrives", async () => {
|
||||
const { events, emitter } = collector();
|
||||
async function* fakeQuery() {
|
||||
yield {
|
||||
type: "stream_event",
|
||||
event: { type: "message_start", message: { content: [] } },
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "fallback answer",
|
||||
total_cost_usd: 0,
|
||||
usage: { input_tokens: 4, output_tokens: 2 },
|
||||
permission_denials: [],
|
||||
};
|
||||
}
|
||||
|
||||
await runCodebuddyTurn({
|
||||
prompt: "answer",
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
queryFn: () => fakeQuery(),
|
||||
});
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
k: "usage",
|
||||
usage: {
|
||||
inputTokens: 4,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 2,
|
||||
totalTokens: 6,
|
||||
},
|
||||
},
|
||||
{ k: "status", m: "CodeBuddy: 1 turns" },
|
||||
{ k: "text", t: "fallback answer" },
|
||||
{ k: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn does not duplicate assistant text after streamed text", async () => {
|
||||
const { events, emitter } = collector();
|
||||
async function* fakeQuery() {
|
||||
@@ -197,6 +340,60 @@ test("runCodebuddyTurn interrupts the SDK query as soon as abort is signaled", a
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn treats an abort rejection as normal completion", async () => {
|
||||
const ac = new AbortController();
|
||||
let rejectStream;
|
||||
const fakeQuery = () => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "system", session_id: "sess-abort" };
|
||||
await new Promise((_resolve, reject) => {
|
||||
rejectStream = reject;
|
||||
});
|
||||
},
|
||||
async interrupt() {
|
||||
rejectStream?.(new Error("interrupted"));
|
||||
},
|
||||
});
|
||||
const { events, emitter } = collector();
|
||||
|
||||
const turn = runCodebuddyTurn({
|
||||
prompt: "wait",
|
||||
options: { abortController: ac },
|
||||
emitter,
|
||||
queryFn: fakeQuery,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
ac.abort();
|
||||
|
||||
assert.deepEqual(await turn, { sessionId: "sess-abort" });
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "sess-abort" },
|
||||
{ k: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn does not start the legacy CLI after an early abort", async () => {
|
||||
const { events, emitter } = collector();
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
let queryCalls = 0;
|
||||
|
||||
const result = await runCodebuddyTurn({
|
||||
prompt: "hello",
|
||||
attachments: [],
|
||||
options: { abortController },
|
||||
emitter,
|
||||
queryFn() {
|
||||
queryCalls += 1;
|
||||
throw new Error("must not start");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(queryCalls, 0);
|
||||
assert.deepEqual(result, { sessionId: null });
|
||||
assert.deepEqual(events, [{ k: "done" }]);
|
||||
});
|
||||
|
||||
test("buildCodebuddyPromptInput sends supported images as native image blocks", async () => {
|
||||
const input = buildCodebuddyPromptInput("describe this", [
|
||||
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
|
||||
@@ -279,6 +476,37 @@ test("buildCodebuddyQueryOptions does not set maxThinkingTokens (deprecated remo
|
||||
assert.ok(!("maxThinkingTokens" in opts));
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions drops invalid numeric guardrails", () => {
|
||||
const fractionalTurns = buildCodebuddyQueryOptions({
|
||||
maxTurns: 1.5,
|
||||
maxBudgetUsd: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
assert.equal(fractionalTurns.maxTurns, undefined);
|
||||
assert.equal(fractionalTurns.maxBudgetUsd, undefined);
|
||||
|
||||
const valid = buildCodebuddyQueryOptions({
|
||||
maxTurns: 2,
|
||||
maxBudgetUsd: 0.25,
|
||||
});
|
||||
assert.equal(valid.maxTurns, 2);
|
||||
assert.equal(valid.maxBudgetUsd, 0.25);
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions drops disabled or malformed advanced options", () => {
|
||||
const opts = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
effort: "ultra",
|
||||
fallbackModel: { id: "fallback" },
|
||||
sandbox: { enabled: false },
|
||||
enableFileCheckpointing: false,
|
||||
});
|
||||
|
||||
assert.equal(opts.effort, undefined);
|
||||
assert.equal(opts.fallbackModel, undefined);
|
||||
assert.equal(opts.sandbox, undefined);
|
||||
assert.equal(opts.enableFileCheckpointing, undefined);
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions accepts object systemPrompt directly", () => {
|
||||
const opts = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
@@ -316,6 +544,87 @@ test("buildCodebuddyHooks returns hook matchers that emit events", async () => {
|
||||
assert.equal(events[0].ev.toolName, "Bash");
|
||||
});
|
||||
|
||||
test("buildCodebuddyHooks blocks non-Netcatty Bash commands in skills mode", async () => {
|
||||
const { emitter } = collector();
|
||||
emitter.emitEvent = () => {};
|
||||
const hooks = buildCodebuddyHooks(emitter, {
|
||||
toolIntegrationMode: "skills",
|
||||
allowedCliCommandPrefix: "netcatty-tool-cli",
|
||||
});
|
||||
const preHook = hooks.PreToolUse[0].hooks[0];
|
||||
|
||||
assert.deepEqual(
|
||||
await preHook(
|
||||
{ tool_name: "Bash", tool_input: { command: "ls -la" }, tool_use_id: "tu-local" },
|
||||
"tu-local",
|
||||
{ signal: new AbortController().signal },
|
||||
),
|
||||
{
|
||||
continue: true,
|
||||
decision: "block",
|
||||
reason:
|
||||
"Only Netcatty CLI commands are allowed in Skills mode. " +
|
||||
"Use the netcatty-tool-cli command prefix provided by the host.",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
await preHook(
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "netcatty-tool-cli session --session s1 --chat-session c1 --json",
|
||||
},
|
||||
tool_use_id: "tu-cli",
|
||||
},
|
||||
"tu-cli",
|
||||
{ signal: new AbortController().signal },
|
||||
),
|
||||
{ continue: true },
|
||||
);
|
||||
assert.equal(
|
||||
(await preHook(
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "/tmp/netcatty-tool-cli status --json",
|
||||
},
|
||||
tool_use_id: "tu-impostor",
|
||||
},
|
||||
"tu-impostor",
|
||||
{ signal: new AbortController().signal },
|
||||
)).decision,
|
||||
"block",
|
||||
);
|
||||
assert.equal(
|
||||
(await preHook(
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "netcatty-tool-cli status --json",
|
||||
run_in_background: true,
|
||||
},
|
||||
tool_use_id: "tu-background",
|
||||
},
|
||||
"tu-background",
|
||||
{ signal: new AbortController().signal },
|
||||
)).decision,
|
||||
"block",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCodebuddyHooks retains caller-provided lifecycle hooks", () => {
|
||||
const { emitter } = collector();
|
||||
emitter.emitEvent = () => {};
|
||||
const custom = { hooks: [async () => ({ continue: true })] };
|
||||
const hooks = buildCodebuddyHooks(emitter, {
|
||||
toolIntegrationMode: "skills",
|
||||
additionalHooks: { PreToolUse: [custom] },
|
||||
});
|
||||
|
||||
assert.equal(hooks.PreToolUse.length, 2);
|
||||
assert.equal(hooks.PreToolUse[1], custom);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elicitation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -378,7 +687,10 @@ test("buildCodebuddyElicitation tags pendings with chatSessionId and uses UUID f
|
||||
assert.equal(ids.length, 2);
|
||||
assert.notEqual(ids[0], ids[1]);
|
||||
for (const id of ids) {
|
||||
assert.match(id, /^elicitation_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
assert.match(
|
||||
id,
|
||||
/^codebuddy:chat-1:elicitation_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
assert.equal(pendingMap.get(id).chatSessionId, "chat-1");
|
||||
}
|
||||
|
||||
@@ -411,6 +723,40 @@ test("buildCodebuddyElicitation complete cancels and removes a pending create",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildCodebuddyElicitation scopes identical protocol ids to their chat", async () => {
|
||||
const pendingMap = new Map();
|
||||
const firstEvents = [];
|
||||
const secondEvents = [];
|
||||
const firstHandler = buildCodebuddyElicitation(
|
||||
{ emitEvent: (event) => firstEvents.push(event) },
|
||||
pendingMap,
|
||||
{ chatSessionId: "chat/one" },
|
||||
);
|
||||
const secondHandler = buildCodebuddyElicitation(
|
||||
{ emitEvent: (event) => secondEvents.push(event) },
|
||||
pendingMap,
|
||||
{ chatSessionId: "chat/two" },
|
||||
);
|
||||
|
||||
const first = firstHandler.create({
|
||||
_meta: { "codebuddy.ai": { elicitationId: "confirm:1" } },
|
||||
});
|
||||
const second = secondHandler.create({
|
||||
_meta: { "codebuddy.ai": { elicitationId: "confirm:1" } },
|
||||
});
|
||||
const firstId = firstEvents[0].elicitationId;
|
||||
const secondId = secondEvents[0].elicitationId;
|
||||
|
||||
assert.equal(firstId, "codebuddy:chat%2Fone:confirm%3A1");
|
||||
assert.equal(secondId, "codebuddy:chat%2Ftwo:confirm%3A1");
|
||||
assert.equal(pendingMap.size, 2);
|
||||
pendingMap.get(firstId).resolve({ action: "accept", content: { chat: "one" } });
|
||||
pendingMap.get(secondId).resolve({ action: "decline" });
|
||||
|
||||
assert.deepEqual(await first, { action: "accept", content: { chat: "one" } });
|
||||
assert.deepEqual(await second, { action: "decline" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP SSE/HTTP support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,6 +19,8 @@ const {
|
||||
buildCodebuddyHooks,
|
||||
buildCodebuddyElicitation,
|
||||
translateCodebuddyMessage,
|
||||
inspectCodebuddyMessageContent,
|
||||
codebuddyResultFallbackText,
|
||||
classifyCodebuddySpawnError,
|
||||
} = require("./codebuddyDriver.cjs");
|
||||
|
||||
@@ -36,9 +38,11 @@ function computeOptionsFingerprint(sessionOptions) {
|
||||
pathToCodebuddyCode: sessionOptions.pathToCodebuddyCode,
|
||||
mcpServers: sessionOptions.mcpServers,
|
||||
permissionMode: sessionOptions.permissionMode,
|
||||
extraArgs: sessionOptions.extraArgs,
|
||||
systemPrompt: sessionOptions.systemPrompt,
|
||||
tools: sessionOptions.tools,
|
||||
disallowedTools: sessionOptions.disallowedTools,
|
||||
settingSources: sessionOptions.settingSources,
|
||||
maxTurns: sessionOptions.maxTurns,
|
||||
agents: sessionOptions.agents,
|
||||
thinking: sessionOptions.thinking,
|
||||
@@ -120,7 +124,7 @@ class CodebuddySessionManager {
|
||||
if (existing) {
|
||||
// Reuse only when serialized options still match, but always refresh
|
||||
// turn-scoped callbacks so events target the current request emitter.
|
||||
if (existing.fingerprint === fingerprint) {
|
||||
if (fingerprint !== null && existing.fingerprint === fingerprint) {
|
||||
try {
|
||||
if (refreshSessionCallbacks(existing, sessionOptions)) {
|
||||
return existing.session;
|
||||
@@ -156,10 +160,14 @@ class CodebuddySessionManager {
|
||||
} else {
|
||||
session = createSession(sdkSessionOptions);
|
||||
}
|
||||
await session.connect();
|
||||
// Do not connect before the first send. In resume mode, send() marks the
|
||||
// initialization as having a prompt so the SDK does not replay historical
|
||||
// messages into the new turn's stream.
|
||||
this.sessions.set(sessionKey, { session, fingerprint, callbackState });
|
||||
return session;
|
||||
} catch {
|
||||
// A factory failure can still leave a partially constructed session.
|
||||
try { session?.close(); } catch { /* best effort */ }
|
||||
// V2 session creation failed — caller should fall back to query().
|
||||
return null;
|
||||
}
|
||||
@@ -193,7 +201,12 @@ class CodebuddySessionManager {
|
||||
const promptInput = buildCodebuddyPromptInput(prompt, attachments);
|
||||
let sessionId = session.sessionId || null;
|
||||
let hasContent = false;
|
||||
let hasAssistantText = false;
|
||||
let hasStreamedText = false;
|
||||
let hasStreamedReasoning = false;
|
||||
let hasTerminalError = false;
|
||||
let resultFallbackText = "";
|
||||
let emittedSessionId = null;
|
||||
let removeAbortListener = null;
|
||||
|
||||
try {
|
||||
@@ -216,20 +229,36 @@ class CodebuddySessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Send the prompt (string or async iterable).
|
||||
if (typeof promptInput === "string") {
|
||||
await session.send(promptInput);
|
||||
} else {
|
||||
// Async iterable of UserMessage — send first message.
|
||||
for await (const msg of promptInput) {
|
||||
await session.send(msg);
|
||||
try {
|
||||
// Send before the initial connection so resumed sessions suppress
|
||||
// historical replay and stream only the response to this prompt.
|
||||
if (typeof promptInput === "string") {
|
||||
await session.send(promptInput);
|
||||
} else {
|
||||
// Async iterable of UserMessage — send first message.
|
||||
for await (const msg of promptInput) {
|
||||
await session.send(msg);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Initial transport setup happens inside send(). Release any acquired
|
||||
// session lock/process before the caller falls back to legacy query().
|
||||
this.closeSession(sessionKey);
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
if (sessionId) {
|
||||
emitter.sessionId(sessionId);
|
||||
emittedSessionId = sessionId;
|
||||
}
|
||||
|
||||
// Stream responses.
|
||||
for await (const message of session.stream()) {
|
||||
@@ -243,23 +272,35 @@ class CodebuddySessionManager {
|
||||
if (message?.session_id && message.session_id !== sessionId) {
|
||||
sessionId = message.session_id;
|
||||
}
|
||||
if (
|
||||
message?.type === "stream_event" ||
|
||||
(message?.type === "assistant" && Array.isArray(message?.message?.content) && message.message.content.length > 0)
|
||||
) {
|
||||
hasContent = true;
|
||||
}
|
||||
translateCodebuddyMessage(message, emitter, { skipAssistantText: hasStreamedText });
|
||||
if (
|
||||
message?.type === "stream_event" &&
|
||||
message.event?.type === "content_block_delta" &&
|
||||
message.event?.delta?.type === "text_delta" &&
|
||||
message.event.delta.text
|
||||
) {
|
||||
hasStreamedText = true;
|
||||
if (sessionId && sessionId !== emittedSessionId) {
|
||||
emitter.sessionId(sessionId);
|
||||
emittedSessionId = sessionId;
|
||||
}
|
||||
const contentState = inspectCodebuddyMessageContent(message);
|
||||
if (contentState.hasContent) hasContent = true;
|
||||
if (contentState.hasText) hasAssistantText = true;
|
||||
resultFallbackText ||= codebuddyResultFallbackText(message);
|
||||
const translation = translateCodebuddyMessage(
|
||||
message,
|
||||
emitter,
|
||||
{
|
||||
skipAssistantText: hasStreamedText,
|
||||
skipAssistantReasoning: hasStreamedReasoning,
|
||||
skipSessionId: true,
|
||||
},
|
||||
);
|
||||
if (translation?.terminalError) hasTerminalError = true;
|
||||
if (contentState.streamedText) hasStreamedText = true;
|
||||
if (contentState.streamedReasoning) hasStreamedReasoning = true;
|
||||
}
|
||||
|
||||
if (hasTerminalError) {
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
if (!hasAssistantText && resultFallbackText) {
|
||||
emitter.text(resultFallbackText);
|
||||
hasContent = true;
|
||||
}
|
||||
if (!hasContent && !options.abortController?.signal?.aborted) {
|
||||
emitter.emitError(
|
||||
"CodeBuddy returned an empty response. Run `codebuddy` in a terminal to log in, " +
|
||||
@@ -270,6 +311,13 @@ class CodebuddySessionManager {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
// A stream failure means the transport is no longer safe to reuse. Close
|
||||
// it now so the next turn can create/resume a fresh V2 session.
|
||||
this.closeSession(sessionKey);
|
||||
const classified = classifyCodebuddySpawnError(error);
|
||||
if (classified.isSpawnEnoent) {
|
||||
emitter.emitError(
|
||||
|
||||
@@ -9,6 +9,7 @@ function collector() {
|
||||
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||||
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
|
||||
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
|
||||
usage: (usage) => events.push({ k: "usage", usage }),
|
||||
status: (m) => events.push({ k: "status", m }),
|
||||
sessionId: (s) => events.push({ k: "sessionId", s }),
|
||||
emitDone: () => events.push({ k: "done" }),
|
||||
@@ -146,16 +147,121 @@ test("getOrCreateSession closes stale session when options change", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("runTurn closes a session when initial send fails before fallback", async () => {
|
||||
const session = fakeSession([], { sessionId: "failed-connect-session" });
|
||||
session.send = async () => {
|
||||
throw new Error("connect failed");
|
||||
};
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
|
||||
const { events, emitter } = collector();
|
||||
const result = await mgr.runTurn({
|
||||
sessionKey: "failed-connect-key",
|
||||
prompt: "hello",
|
||||
attachments: [],
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(session.closed, true);
|
||||
assert.equal(mgr.sessions.has("failed-connect-key"), false);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test("runTurn closes and evicts a session when response streaming fails", async () => {
|
||||
const session = fakeSession([], { sessionId: "failed-stream-session" });
|
||||
session.stream = async function* stream() {
|
||||
throw new Error("transport died");
|
||||
};
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
|
||||
const { events, emitter } = collector();
|
||||
const result = await mgr.runTurn({
|
||||
sessionKey: "failed-stream-key",
|
||||
prompt: "hello",
|
||||
attachments: [],
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
sessionId: "failed-stream-session",
|
||||
usedV2: true,
|
||||
});
|
||||
assert.equal(session.closed, true);
|
||||
assert.equal(mgr.sessions.has("failed-stream-key"), false);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "failed-stream-session" },
|
||||
{ k: "error", m: "transport died" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("computeOptionsFingerprint detects option changes", () => {
|
||||
const base = { cwd: "/tmp", model: "glm-5", maxTurns: 10, effort: "high" };
|
||||
const same = { cwd: "/tmp", model: "glm-5", maxTurns: 10, effort: "high" };
|
||||
const base = {
|
||||
cwd: "/tmp",
|
||||
model: "glm-5",
|
||||
maxTurns: 10,
|
||||
effort: "high",
|
||||
extraArgs: { "dangerously-skip-permissions": null },
|
||||
};
|
||||
const same = {
|
||||
cwd: "/tmp",
|
||||
model: "glm-5",
|
||||
maxTurns: 10,
|
||||
effort: "high",
|
||||
extraArgs: { "dangerously-skip-permissions": null },
|
||||
};
|
||||
const diffModel = { cwd: "/tmp", model: "glm-4", maxTurns: 10, effort: "high" };
|
||||
const diffMaxTurns = { cwd: "/tmp", model: "glm-5", maxTurns: 20, effort: "high" };
|
||||
const diffEffort = { cwd: "/tmp", model: "glm-5", maxTurns: 10, effort: "low" };
|
||||
const diffExtraArgs = {
|
||||
...base,
|
||||
extraArgs: { "dangerously-skip-permissions": "false" },
|
||||
};
|
||||
assert.equal(computeOptionsFingerprint(base), computeOptionsFingerprint(same));
|
||||
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffModel));
|
||||
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffMaxTurns));
|
||||
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffEffort));
|
||||
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffExtraArgs));
|
||||
});
|
||||
|
||||
test("getOrCreateSession never reuses sessions with unserializable option fingerprints", async () => {
|
||||
const circular = {};
|
||||
circular.self = circular;
|
||||
const oldSession = fakeSession([], { sessionId: "circular-old" });
|
||||
const replacementSession = fakeSession([], { sessionId: "circular-new" });
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => replacementSession,
|
||||
unstable_v2_resumeSession: () => replacementSession,
|
||||
}),
|
||||
});
|
||||
mgr.sessions.set("circular-key", {
|
||||
session: oldSession,
|
||||
fingerprint: computeOptionsFingerprint({ mcpServers: circular }),
|
||||
});
|
||||
|
||||
const result = await mgr.getOrCreateSession({
|
||||
sessionKey: "circular-key",
|
||||
sessionOptions: { mcpServers: circular },
|
||||
});
|
||||
|
||||
assert.equal(result, replacementSession);
|
||||
assert.equal(oldSession.closed, true);
|
||||
assert.equal(mgr.sessions.get("circular-key").session, replacementSession);
|
||||
});
|
||||
|
||||
test("runTurn streams messages via V2 session when available", async () => {
|
||||
@@ -181,9 +287,65 @@ test("runTurn streams messages via V2 session when available", async () => {
|
||||
assert.deepEqual(result, { sessionId: "sess-v2", usedV2: true });
|
||||
assert.ok(events.some((e) => e.k === "text" && e.t === "hi from v2"));
|
||||
assert.ok(events.some((e) => e.k === "done"));
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.k === "sessionId"),
|
||||
[{ k: "sessionId", s: "sess-v2" }],
|
||||
);
|
||||
assert.ok(session.sentMessages.includes("say hi"));
|
||||
});
|
||||
|
||||
test("runTurn sends before connecting a resumed session and skips replayed history", async () => {
|
||||
let explicitlyConnected = false;
|
||||
const session = fakeSession([], { sessionId: "resumed-session" });
|
||||
session.connect = async () => {
|
||||
explicitlyConnected = true;
|
||||
};
|
||||
session.send = async (message) => {
|
||||
session.sentMessages.push(message);
|
||||
};
|
||||
session.stream = async function* stream() {
|
||||
if (explicitlyConnected) {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "old response" }] },
|
||||
};
|
||||
}
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "new response" }] },
|
||||
};
|
||||
};
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
const { events, emitter } = collector();
|
||||
|
||||
const result = await mgr.runTurn({
|
||||
sessionKey: "resumed-key",
|
||||
prompt: "new question",
|
||||
attachments: [],
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
resumeSessionId: "resumed-session",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { sessionId: "resumed-session", usedV2: true });
|
||||
assert.equal(explicitlyConnected, false);
|
||||
assert.deepEqual(session.sentMessages, ["new question"]);
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.k === "text").map((event) => event.t),
|
||||
["new response"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.k === "sessionId"),
|
||||
[{ k: "sessionId", s: "resumed-session" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("runTurn does not connect or send when already aborted", async () => {
|
||||
let loadSdkCalls = 0;
|
||||
const mgr = new CodebuddySessionManager({
|
||||
@@ -210,11 +372,12 @@ test("runTurn does not connect or send when already aborted", async () => {
|
||||
assert.deepEqual(events, [{ k: "done" }]);
|
||||
});
|
||||
|
||||
test("runTurn does not send when aborted while the session connects", async () => {
|
||||
let releaseConnect;
|
||||
test("runTurn does not stream when aborted while the initial send connects", async () => {
|
||||
let releaseSend;
|
||||
const session = fakeSession([], { sessionId: "slow-connect-session" });
|
||||
session.connect = () => new Promise((resolve) => {
|
||||
releaseConnect = resolve;
|
||||
session.send = (message) => new Promise((resolve) => {
|
||||
session.sentMessages.push(message);
|
||||
releaseSend = resolve;
|
||||
});
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
@@ -234,21 +397,64 @@ test("runTurn does not send when aborted while the session connects", async () =
|
||||
sessionOptions: {},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(typeof releaseConnect, "function");
|
||||
assert.equal(typeof releaseSend, "function");
|
||||
|
||||
controller.abort();
|
||||
releaseConnect();
|
||||
releaseSend();
|
||||
const result = await runPromise;
|
||||
|
||||
assert.deepEqual(result, {
|
||||
sessionId: "slow-connect-session",
|
||||
usedV2: true,
|
||||
});
|
||||
assert.deepEqual(session.sentMessages, []);
|
||||
assert.deepEqual(session.sentMessages, ["must not run"]);
|
||||
assert.equal(session.interruptCalls, 1);
|
||||
assert.deepEqual(events, [{ k: "done" }]);
|
||||
});
|
||||
|
||||
test("runTurn treats an abort rejection while streaming as normal completion", async () => {
|
||||
let rejectStream;
|
||||
const session = fakeSession([], { sessionId: "stream-abort-session" });
|
||||
session.stream = async function* stream() {
|
||||
await new Promise((_resolve, reject) => {
|
||||
rejectStream = reject;
|
||||
});
|
||||
};
|
||||
session.interrupt = async () => {
|
||||
rejectStream?.(new Error("interrupted"));
|
||||
};
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const { events, emitter } = collector();
|
||||
|
||||
const runPromise = mgr.runTurn({
|
||||
sessionKey: "stream-abort-key",
|
||||
prompt: "wait",
|
||||
attachments: [],
|
||||
options: { abortController: controller },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
|
||||
assert.deepEqual(await runPromise, {
|
||||
sessionId: "stream-abort-session",
|
||||
usedV2: true,
|
||||
});
|
||||
assert.ok(mgr.sessions.has("stream-abort-key"));
|
||||
assert.equal(session.closed, false);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "stream-abort-session" },
|
||||
{ k: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("steer returns unsupported when no session exists", async () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const { emitter } = collector();
|
||||
|
||||
@@ -21,9 +21,9 @@ const { codebuddySessionManager } = require("./codebuddySessionManager.cjs");
|
||||
function hasCodebuddyQueryOnlyOptions(options) {
|
||||
return Boolean(
|
||||
options.maxBudgetUsd ||
|
||||
options.sandbox ||
|
||||
options.sandbox?.enabled === true ||
|
||||
options.fallbackModel ||
|
||||
options.enableFileCheckpointing != null ||
|
||||
options.enableFileCheckpointing === true ||
|
||||
options.outputFormat,
|
||||
);
|
||||
}
|
||||
@@ -179,7 +179,11 @@ const DRIVER_REGISTRY = {
|
||||
enableFileCheckpointing: ctx.enableFileCheckpointing,
|
||||
traceId: ctx.traceId,
|
||||
parentSpanId: ctx.parentSpanId,
|
||||
hooks: ctx.hooks || codebuddy.buildCodebuddyHooks(ctx.emitter),
|
||||
hooks: codebuddy.buildCodebuddyHooks(ctx.emitter, {
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
additionalHooks: ctx.hooks,
|
||||
allowedCliCommandPrefix: ctx.skillsCliCommandPrefix,
|
||||
}),
|
||||
elicitation,
|
||||
canUseTool,
|
||||
});
|
||||
@@ -203,6 +207,7 @@ const DRIVER_REGISTRY = {
|
||||
pathToCodebuddyCode: options.pathToCodebuddyCode,
|
||||
mcpServers: options.mcpServers,
|
||||
permissionMode: options.permissionMode,
|
||||
extraArgs: options.extraArgs,
|
||||
systemPrompt: options.systemPrompt,
|
||||
hooks: options.hooks,
|
||||
elicitation: options.elicitation,
|
||||
@@ -210,6 +215,7 @@ const DRIVER_REGISTRY = {
|
||||
includePartialMessages: true,
|
||||
tools: options.tools,
|
||||
disallowedTools: options.disallowedTools,
|
||||
settingSources: options.settingSources,
|
||||
maxTurns: options.maxTurns,
|
||||
agents: options.agents,
|
||||
thinking: options.thinking,
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
listBackends,
|
||||
hasCodebuddyQueryOnlyOptions,
|
||||
} = require("./index.cjs");
|
||||
const { codebuddySessionManager } = require("./codebuddySessionManager.cjs");
|
||||
|
||||
test("registry exposes SDK backends", () => {
|
||||
assert.deepEqual(listBackends().sort(), ["claude", "codebuddy", "codex", "copilot", "cursor", "opencode"]);
|
||||
@@ -36,7 +37,40 @@ test("CodeBuddy keeps V2 for SessionOptions fields and falls back for query-only
|
||||
}), false);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ maxBudgetUsd: 1 }), true);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ sandbox: { enabled: true } }), true);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ sandbox: { enabled: false } }), false);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ fallbackModel: "fallback" }), true);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ enableFileCheckpointing: false }), true);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ enableFileCheckpointing: false }), false);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ outputFormat: { type: "json_schema" } }), true);
|
||||
});
|
||||
|
||||
test("CodeBuddy forwards the explicit bypass opt-in to V2 sessions", async () => {
|
||||
const originalRunTurn = codebuddySessionManager.runTurn;
|
||||
let capturedSessionOptions;
|
||||
codebuddySessionManager.runTurn = async ({ sessionOptions }) => {
|
||||
capturedSessionOptions = sessionOptions;
|
||||
return { sessionId: "v2-session", usedV2: true };
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getDriver("codebuddy").runTurn({
|
||||
chatSessionId: "chat-1",
|
||||
prompt: "hello",
|
||||
attachments: [],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
injectedMcpServers: [],
|
||||
permissionMode: "auto",
|
||||
toolIntegrationMode: "mcp",
|
||||
emitter: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(capturedSessionOptions.extraArgs, {
|
||||
"dangerously-skip-permissions": null,
|
||||
});
|
||||
assert.equal(capturedSessionOptions.permissionMode, "bypassPermissions");
|
||||
assert.deepEqual(capturedSessionOptions.settingSources, []);
|
||||
assert.deepEqual(result, { sessionId: "v2-session", usedV2: true });
|
||||
} finally {
|
||||
codebuddySessionManager.runTurn = originalRunTurn;
|
||||
}
|
||||
});
|
||||
@@ -330,6 +330,7 @@ function buildSdkTurnPrompt({
|
||||
historyMessages,
|
||||
replayHistory,
|
||||
attachments,
|
||||
toolIntegrationMode,
|
||||
writeAttachmentToTemp = defaultWriteAttachmentToTemp,
|
||||
onStagedAttachment,
|
||||
}) {
|
||||
@@ -365,10 +366,13 @@ function buildSdkTurnPrompt({
|
||||
}
|
||||
}
|
||||
if (hints.length > 0) {
|
||||
const attachmentAccessHint = toolIntegrationMode === "skills"
|
||||
? "[If direct local filesystem tools are unavailable, use Netcatty's attachment list/read CLI commands described in the host context.]"
|
||||
: "[If local filesystem tools are unavailable, use Netcatty's list_attachments and read_attachment MCP tools to inspect these user-supplied files.]";
|
||||
sections.push(
|
||||
[
|
||||
"[Attached files: these paths are local to the machine running Netcatty, not remote hosts. Inspect them locally if needed.]",
|
||||
"[If local filesystem tools are unavailable, use Netcatty's list_attachments and read_attachment MCP tools to inspect these user-supplied files.]",
|
||||
attachmentAccessHint,
|
||||
...hints,
|
||||
].join("\n"),
|
||||
);
|
||||
@@ -381,6 +385,21 @@ function buildSdkTurnPrompt({
|
||||
: trimmedPrompt;
|
||||
}
|
||||
|
||||
function shouldReplaySdkHistory({
|
||||
backendKey,
|
||||
codexRuntime,
|
||||
resumeSessionId,
|
||||
hasInMemorySession,
|
||||
}) {
|
||||
// CodeBuddy and Codex App Server resumes restore their own conversation
|
||||
// history. Replaying renderer history as well duplicates every prior turn
|
||||
// after the main-process session map is recreated.
|
||||
if (backendKey === "codebuddy" || codexRuntime === "app-server") {
|
||||
return !resumeSessionId;
|
||||
}
|
||||
return !hasInMemorySession;
|
||||
}
|
||||
|
||||
function registerSdkStreamHandlers(ctx) {
|
||||
with (ctx) {
|
||||
// chatSessionId -> { sessionId } for resume; controller per requestId.
|
||||
@@ -509,7 +528,12 @@ function registerSdkStreamHandlers(ctx) {
|
||||
const codexRuntime = backendKey === "codex" && requestedCodexRuntime === "app-server"
|
||||
? "app-server"
|
||||
: "sdk";
|
||||
sdkRequestRuntimes.set(requestId, { backendKey, codexRuntime, binPath });
|
||||
sdkRequestRuntimes.set(requestId, {
|
||||
backendKey,
|
||||
codexRuntime,
|
||||
binPath,
|
||||
toolIntegrationMode: effectiveMode,
|
||||
});
|
||||
|
||||
const hasConfiguredCommand = isPathLikeCommand(agentCommand);
|
||||
const sessionBinPath = backendKey === "cursor" && cursorAuthMode === "cli-login"
|
||||
@@ -557,8 +581,14 @@ function registerSdkStreamHandlers(ctx) {
|
||||
const turnPrompt = buildSdkTurnPrompt({
|
||||
prompt,
|
||||
historyMessages: payload?.historyMessages,
|
||||
replayHistory: codexRuntime === "app-server" ? !resumeSessionId : !hasInMemorySession,
|
||||
replayHistory: shouldReplaySdkHistory({
|
||||
backendKey,
|
||||
codexRuntime,
|
||||
resumeSessionId,
|
||||
hasInMemorySession,
|
||||
}),
|
||||
attachments: payload?.images,
|
||||
toolIntegrationMode: effectiveMode,
|
||||
onStagedAttachment: (attachment) => stagedAttachments.push(attachment),
|
||||
});
|
||||
mcpServerBridge.updateAttachmentMetadata?.(stagedAttachments, chatSessionId);
|
||||
@@ -630,6 +660,9 @@ function registerSdkStreamHandlers(ctx) {
|
||||
injectedMcpServers,
|
||||
claudeSettings,
|
||||
toolIntegrationMode: effectiveMode,
|
||||
skillsCliCommandPrefix: effectiveMode === "skills"
|
||||
? getSkillsCliInvocation().commandPrefix
|
||||
: undefined,
|
||||
skillsPathAllowlist,
|
||||
emitter: driverEmitter,
|
||||
signal: abortController.signal,
|
||||
@@ -843,6 +876,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
prompt,
|
||||
replayHistory: false,
|
||||
attachments: payload?.images,
|
||||
toolIntegrationMode: runtime.toolIntegrationMode,
|
||||
onStagedAttachment: (attachment) => stagedAttachments.push(attachment),
|
||||
});
|
||||
mcpServerBridge.updateAttachmentMetadata?.(stagedAttachments, chatSessionId);
|
||||
@@ -1070,4 +1104,5 @@ module.exports = {
|
||||
shouldCacheSdkRuntimeModels,
|
||||
normalizeHistoryMessages,
|
||||
buildSdkTurnPrompt,
|
||||
shouldReplaySdkHistory,
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
normalizeSdkListModelsResult,
|
||||
resolveSdkPromptPlacement,
|
||||
resolveSdkResumeSessionId,
|
||||
shouldReplaySdkHistory,
|
||||
expireSiblingCursorCliModeSessions,
|
||||
resolveBackendKey,
|
||||
resolveSdkBackendBinPath,
|
||||
@@ -439,6 +440,27 @@ test("buildSdkTurnPrompt replays history only when requested", () => {
|
||||
assert.equal(steadyStatePrompt, "latest question");
|
||||
});
|
||||
|
||||
test("CodeBuddy does not replay renderer history when a persisted session can resume", () => {
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "codebuddy",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: "resumed-codebuddy",
|
||||
hasInMemorySession: false,
|
||||
}), false);
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "codebuddy",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: undefined,
|
||||
hasInMemorySession: false,
|
||||
}), true);
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "claude",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: "resumed-claude",
|
||||
hasInMemorySession: false,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("buildSdkTurnPrompt stages attachments as local file hints", () => {
|
||||
const staged = [];
|
||||
const prompt = buildSdkTurnPrompt({
|
||||
@@ -463,6 +485,20 @@ test("buildSdkTurnPrompt stages attachments as local file hints", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
test("buildSdkTurnPrompt directs Skills-mode attachments to the controlled CLI", () => {
|
||||
const prompt = buildSdkTurnPrompt({
|
||||
prompt: "read it",
|
||||
toolIntegrationMode: "skills",
|
||||
attachments: [
|
||||
{ base64Data: "ZGF0YQ==", mediaType: "text/plain", filename: "notes.txt" },
|
||||
],
|
||||
writeAttachmentToTemp: (attachment) => `/tmp/${attachment.filename}`,
|
||||
});
|
||||
|
||||
assert.match(prompt, /attachment list\/read CLI commands/);
|
||||
assert.doesNotMatch(prompt, /list_attachments|read_attachment/);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath prefers configured CodeBuddy path", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
|
||||
@@ -10,6 +10,7 @@ const { TOOL_INPUT_FIELDS } = require("../schemas/toolInputs.cjs");
|
||||
/** Maps TOOL_INPUT_FIELDS keys to CLI flag names and opts property names. */
|
||||
const CLI_FIELD_BINDINGS = Object.freeze({
|
||||
hostId: { flag: "--host-id", optKey: "hostId" },
|
||||
filename: { flag: "--filename", optKey: "filename" },
|
||||
snippetId: { flag: "--snippet-id", optKey: "snippetId" },
|
||||
scriptId: { flag: "--script-id", optKey: "scriptId" },
|
||||
runId: { flag: "--run-id", optKey: "runId" },
|
||||
|
||||
@@ -18,6 +18,7 @@ function fakeCreateError(code, message) {
|
||||
|
||||
test("getCliRpcMethod resolves implemented cli commands to rpc methods", () => {
|
||||
assert.equal(getCliRpcMethod(["exec"]), "netcatty/exec");
|
||||
assert.equal(getCliRpcMethod(["attachment", "read"]), "netcatty/readAttachment");
|
||||
assert.equal(getCliRpcMethod(["sftp", "list"]), "netcatty/sftp/list");
|
||||
assert.equal(getCliRpcMethod(["vault", "host", "get"]), "vault/host/get");
|
||||
assert.equal(getCliRpcMethod(["portforward", "rules", "list"]), "portforward/rules/list");
|
||||
@@ -27,6 +28,8 @@ test("getCliRpcMethod resolves implemented cli commands to rpc methods", () => {
|
||||
test("listCliCapabilities returns implemented commands by default", () => {
|
||||
const entries = listCliCapabilities();
|
||||
assert.ok(entries.some((entry) => entry.id === "terminal.execute"));
|
||||
assert.ok(entries.some((entry) => entry.id === "attachment.list"));
|
||||
assert.ok(entries.some((entry) => entry.id === "attachment.read"));
|
||||
assert.ok(entries.some((entry) => entry.id === "vault.host.get"));
|
||||
assert.ok(entries.every((entry) => entry.status === CAPABILITY_STATUS.IMPLEMENTED));
|
||||
assert.ok(entries.every((entry) => entry.rpcMethod));
|
||||
@@ -42,6 +45,15 @@ test("buildCatalogCliParams maps vault host get flags", () => {
|
||||
assert.deepEqual(params, { hostId: "host-1" });
|
||||
});
|
||||
|
||||
test("buildCatalogCliParams maps attachment filename", () => {
|
||||
const params = buildCatalogCliParams(
|
||||
"attachment.read",
|
||||
{ filename: "hosts.csv" },
|
||||
fakeCreateError,
|
||||
);
|
||||
assert.deepEqual(params, { filename: "hosts.csv" });
|
||||
});
|
||||
|
||||
test("buildCatalogCliParams parses snippet variables JSON", () => {
|
||||
const params = buildCatalogCliParams("vault.snippets.run", {
|
||||
snippetId: "snip-1",
|
||||
|
||||
@@ -60,6 +60,7 @@ const META_CAPABILITIES = [
|
||||
},
|
||||
surfaces: {
|
||||
builtin: { rpcMethod: "netcatty/listAttachments", mcpTool: "list_attachments" },
|
||||
cli: { command: ["attachment", "list"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -78,6 +79,7 @@ const META_CAPABILITIES = [
|
||||
},
|
||||
surfaces: {
|
||||
builtin: { rpcMethod: "netcatty/readAttachment", mcpTool: "read_attachment" },
|
||||
cli: { command: ["attachment", "read"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,6 +22,8 @@ function printHelp() {
|
||||
"Examples:\n" +
|
||||
" netcatty-tool-cli status --json\n" +
|
||||
" netcatty-tool-cli env --chat-session ai_123 --json\n" +
|
||||
" netcatty-tool-cli attachment list --chat-session ai_123 --json\n" +
|
||||
" netcatty-tool-cli attachment read --filename hosts.csv --chat-session ai_123 --json\n" +
|
||||
" netcatty-tool-cli session --session sess_123 --json --chat-session ai_123\n" +
|
||||
" netcatty-tool-cli exec --session sess_123 --chat-session ai_123 --json -- \"pwd\"\n" +
|
||||
" netcatty-tool-cli vault host get --host-id host_123 --json\n" +
|
||||
@@ -73,6 +75,7 @@ function parseArgs(argv) {
|
||||
mode: null,
|
||||
encoding: null,
|
||||
hostId: null,
|
||||
filename: null,
|
||||
snippetId: null,
|
||||
ruleId: null,
|
||||
notes: null,
|
||||
@@ -159,6 +162,11 @@ function parseArgs(argv) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--filename") {
|
||||
opts.filename = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--snippet-id") {
|
||||
opts.snippetId = readFlagValue(args, i + 1);
|
||||
i += 1;
|
||||
|
||||
@@ -5,6 +5,25 @@ const assert = require("node:assert/strict");
|
||||
|
||||
const { parseArgs } = require("./netcatty-tool-cli.cjs");
|
||||
|
||||
test("parseArgs consumes attachment filename flag", () => {
|
||||
const { positionals, opts } = parseArgs([
|
||||
"node",
|
||||
"netcatty-tool-cli",
|
||||
"attachment",
|
||||
"read",
|
||||
"--filename",
|
||||
"hosts.csv",
|
||||
"--chat-session",
|
||||
"chat-1",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert.deepEqual(positionals, ["attachment", "read"]);
|
||||
assert.equal(opts.filename, "hosts.csv");
|
||||
assert.equal(opts.chatSessionId, "chat-1");
|
||||
assert.equal(opts.json, true);
|
||||
});
|
||||
|
||||
test("parseArgs consumes snippet multi-line run mode flag", () => {
|
||||
const { positionals, opts } = parseArgs([
|
||||
"node",
|
||||
|
||||
@@ -1554,7 +1554,36 @@ function createPreloadApi(ctx) {
|
||||
},
|
||||
// SDK external agent streaming
|
||||
aiSdkAgentStream: async (requestId, chatSessionId, sdkBackend, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext, agentEnv, agentCommand, codexRuntime, permissionMode, codebuddyOptions) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:sdk-agent:stream", { requestId, chatSessionId, sdkBackend, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext, agentEnv, agentCommand, codexRuntime, permissionMode, ...(codebuddyOptions || {}) });
|
||||
const advanced = codebuddyOptions && typeof codebuddyOptions === "object"
|
||||
? {
|
||||
effort: codebuddyOptions.effort,
|
||||
maxTurns: codebuddyOptions.maxTurns,
|
||||
maxBudgetUsd: codebuddyOptions.maxBudgetUsd,
|
||||
fallbackModel: codebuddyOptions.fallbackModel,
|
||||
sandbox: codebuddyOptions.sandbox,
|
||||
enableFileCheckpointing: codebuddyOptions.enableFileCheckpointing,
|
||||
}
|
||||
: {};
|
||||
return ipcRenderer.invoke("netcatty:ai:sdk-agent:stream", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
sdkBackend,
|
||||
prompt,
|
||||
cwd,
|
||||
providerId,
|
||||
model,
|
||||
existingSessionId,
|
||||
historyMessages,
|
||||
images,
|
||||
toolIntegrationMode,
|
||||
defaultTargetSession,
|
||||
userSkillsContext,
|
||||
agentEnv,
|
||||
agentCommand,
|
||||
codexRuntime,
|
||||
permissionMode,
|
||||
...advanced,
|
||||
});
|
||||
},
|
||||
aiSdkAgentSteer: async (requestId, chatSessionId, prompt, images, clientUserMessageId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:sdk-agent:steer", {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createPreloadApi } = require("./preload/api.cjs");
|
||||
|
||||
test("aiSdkAgentStream whitelists CodeBuddy options without overriding core fields", async () => {
|
||||
let invocation;
|
||||
const api = createPreloadApi({
|
||||
ipcRenderer: {
|
||||
invoke: async (channel, payload) => {
|
||||
invocation = { channel, payload };
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
dataListeners: new Map(),
|
||||
});
|
||||
|
||||
await api.aiSdkAgentStream(
|
||||
"request-1",
|
||||
"chat-1",
|
||||
"codebuddy",
|
||||
"hello",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"skills",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"observer",
|
||||
{
|
||||
effort: "high",
|
||||
maxTurns: 10,
|
||||
requestId: "overridden",
|
||||
chatSessionId: "overridden",
|
||||
permissionMode: "auto",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(invocation.channel, "netcatty:ai:sdk-agent:stream");
|
||||
assert.equal(invocation.payload.requestId, "request-1");
|
||||
assert.equal(invocation.payload.chatSessionId, "chat-1");
|
||||
assert.equal(invocation.payload.permissionMode, "observer");
|
||||
assert.equal(invocation.payload.effort, "high");
|
||||
assert.equal(invocation.payload.maxTurns, 10);
|
||||
});
|
||||
Reference in new issue
Block a user