fix(codebuddy): harden V2 sessions and elicitation flow

This commit is contained in:
lengyuqu committed 2026-07-28 14:55:07 +08:00
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();
});
});
+6 -2
View File
@@ -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;
+41
View File
@@ -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[] = [
{
+2 -5
View File
@@ -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") ?? "",
+36
View File
@@ -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' },
],
);
});
-2
View File
@@ -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 });
+2 -1
View File
@@ -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);
});
+207 -34
View File
@@ -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();
+9 -3
View File
@@ -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,
+35 -1
View File
@@ -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",
+2
View File
@@ -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"] },
},
},
{
+8
View File
@@ -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;
+19
View File
@@ -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",
+30 -1
View File
@@ -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", {
+52
View File
@@ -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);
});