Compare commits

..
Author SHA1 Message Date
bincxz afcf201367 test(ai): isolate mocked bridge window typing 2026-09-18 11:48:04 +08:00
bincxz 45b4dc6aa4 fix(ai): normalize sparse streamed tool call indexes 2026-09-18 11:46:16 +08:00
4 changed files with 95 additions and 53 deletions

No files matched your search

+1 -7
View File
@@ -635,13 +635,7 @@ function createSessionOpsApi(ctx) {
_rc_cwd=$(readlink "/proc/$1/cwd" 2>/dev/null)
if [ -n "$_rc_cwd" ]; then printf '%s\\n' "$_rc_cwd"; return 0; fi
if command -v lsof >/dev/null 2>&1; then
# An unknown-type record can put a readlink error in its name field.
# Only a confirmed directory name is usable as an upload destination.
_rc_cwd=$(LC_ALL=C lsof -a -p "$1" -d cwd -Fnt 2>/dev/null | awk '
/^f/ { is_dir=0 }
/^t/ { is_dir=($0 == "tDIR" || $0 == "tVDIR") }
/^n/ && is_dir { print substr($0, 2); exit }
')
_rc_cwd=$(LC_ALL=C lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n1)
if [ -n "$_rc_cwd" ]; then printf 'NETCATTY_LSOF_CWD=%s\\n' "$_rc_cwd"; return 0; fi
fi
return 1
@@ -29,7 +29,7 @@ function makeApi(session, siblingSessions = [], overrides = {}) {
sessions,
setTimeout: overrides.setTimeout || setTimeout,
clearTimeout: overrides.clearTimeout || clearTimeout,
quoteShellArg: overrides.quoteShellArg || quoteShellArg,
quoteShellArg,
log: () => {},
});
}
@@ -123,47 +123,6 @@ test("session cwd probe decodes the marked lsof pathname", async () => {
assert.deepEqual(result, { success: true, cwd: "/srv/中文" });
});
test("lsof cwd fallback accepts only directory records, not diagnostic names (#3237)", async () => {
let script;
const api = makeApi({
shellPid: "4242",
connRef: { count: 1 },
stream: {},
conn: {
exec(_command, callback) {
callback(null, makePwdStream("/srv/app", "4242"));
},
},
}, [], {
quoteShellArg(value) {
if (!script && value.includes("read_shell_cwd()")) script = value;
return quoteShellArg(value);
},
});
await api.getSessionPwd(null, { sessionId: "session-1" });
const helper = script.match(/ read_shell_cwd\(\) \{[\s\S]*?\n \}/)?.[0];
assert.ok(helper, "exercise the actual remote cwd helper");
const { spawnSync } = require("node:child_process");
for (const [output, expected] of [
["p4242\nfcwd\ntunknown\nn/proc/4242/cwd (readlink: Permission denied)\n", null],
["p4242\nfcwd\ntunknown\nn/proc/4242/cwd (readlink: No such file or directory)\n", null],
["p4242\nfcwd\nn/proc/4242/cwd\n", null],
["p4242\nfcwd\ntDIR\nn/srv/app\n", "/srv/app"],
["p4242\nfcwd\ntVDIR\nn/usr/home/alice\n", "/usr/home/alice"],
["p4242\nfcwd\ntDIR\nn/tmp/literal (readlink: Permission denied)\n", "/tmp/literal (readlink: Permission denied)"],
["p4242\nfcwd\ntDIR\nn/tmp/\\xe4\\xb8\\xad\\xe6\\x96\\x87\n", "/tmp/\\xe4\\xb8\\xad\\xe6\\x96\\x87"],
]) {
const result = spawnSync("sh", ["-c", `
readlink() { return 1; }
lsof() { printf '%s' "$LSOF_TEST_OUTPUT"; }
${helper}
read_shell_cwd 4242
`], { encoding: "utf8", env: { ...process.env, LSOF_TEST_OUTPUT: output } });
assert.equal(result.status, expected === null ? 1 : 0, output);
assert.equal(result.stdout, expected === null ? "" : `NETCATTY_LSOF_CWD=${expected}\n`, output);
}
});
test("session cwd probe closes a remote command that exceeds its timeout", async () => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
@@ -1234,3 +1234,74 @@ test('re-injects the remembered tool name when the SDK missed the naming chunk',
assert.deepEqual(executedCommands, ['pwd']);
assert.equal(text, 'tool completed');
});
for (const indexes of [[0], [1], [1, 4], [4, 1]]) {
test(`completes terminal_execute with streamed tool indexes ${indexes.join(',')} (#3274)`, async (t) => {
const globals = globalThis as unknown as { window?: unknown };
const originalWindow = globals.window;
t.after(() => { globals.window = originalWindow; });
const dataHandlers = new Map<string, (data: string) => void>();
const endHandlers = new Map<string, () => void>();
const sentBodies: Array<Record<string, unknown>> = [];
globals.window = {
netcatty: {
aiChatCancel: async () => true,
onAiStreamData: (id: string, cb: (data: string) => void) => {
dataHandlers.set(id, cb);
return () => dataHandlers.delete(id);
},
onAiStreamEnd: (id: string, cb: () => void) => {
endHandlers.set(id, cb);
return () => endHandlers.delete(id);
},
onAiStreamError: () => () => undefined,
aiChatStream: async (id: string, _url: string, _headers: unknown, body: string) => {
sentBodies.push(JSON.parse(body));
const emit = (delta: unknown, finishReason: string | null = null) => dataHandlers.get(id)?.(JSON.stringify({
id: 'chatcmpl-sparse', object: 'chat.completion.chunk', created: 0, model: 'test',
choices: [{ index: 0, delta, finish_reason: finishReason }],
}));
if (sentBodies.length === 1) {
emit({ content: 'Checking.' });
for (const index of indexes) {
emit({ tool_calls: [{ index, id: `call_${index}`, type: 'function',
function: { name: 'terminal_execute', arguments: '{"command":' } }] });
}
// Interleave continuations in reverse order to verify stable correlation.
for (const index of [...indexes].reverse()) {
emit({ tool_calls: [{ index, function: { arguments: `"echo ${index}"}` } }] });
}
emit({}, 'tool_calls');
} else {
emit({ content: 'Done.' });
emit({}, 'stop');
}
endHandlers.get(id)?.();
return { ok: true, statusCode: 200, statusText: 'OK' };
},
},
};
const executed: string[] = [];
const result = streamText({
model: createModelFromConfig({ id: 'sparse-test', providerId: 'custom', name: 'Test',
apiKey: 'test', baseURL: 'https://example.test/v1', defaultModel: 'test', enabled: true }),
messages: [{ role: 'user', content: 'Check the terminal' }],
tools: { terminal_execute: tool({ inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => { executed.push(command); return { ok: true }; } }) },
stopWhen: isStepCount(2),
});
const errors: unknown[] = [];
let text = '';
for await (const part of result.stream) {
if (part.type === 'error') errors.push(part.error);
if (part.type === 'text-delta') text += part.text;
}
assert.deepEqual(errors, []);
assert.deepEqual(executed.sort(), indexes.map(index => `echo ${index}`).sort());
assert.equal(text, 'Checking.Done.');
assert.equal(sentBodies.length, 2);
const messages = sentBodies[1].messages as Array<{ role: string; tool_call_id?: string }>;
assert.deepEqual(messages.filter(message => message.role === 'tool').map(message => message.tool_call_id).sort(),
indexes.map(index => `call_${index}`).sort());
});
}
+22 -4
View File
@@ -136,6 +136,7 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
// Keys forwarded inside choices array position 0 — the only element the
// AI SDK reads. Tool calls living at position > 0 are invisible to it.
const sdkVisibleKeys = new Set<string>();
const sdkIndexesByKey = new Map<string, number>();
const requestIdToken = requestId.replace(/[^a-zA-Z0-9_-]/g, '_');
return (data: string): string => {
@@ -165,6 +166,23 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
const choiceIndex = typeof choiceRecord.index === 'number' ? choiceRecord.index : choicePosition;
let deltaChanged = false;
const normalizedToolCalls: unknown[] = [];
const forwardToolCall = (toolCall: Record<string, unknown>, key: string) => {
// The SDK reads only choices[0] and stores calls in a dense array.
// Assign indexes only when forwarding a named call to that array.
if (choicePosition === 0) {
let sdkIndex = sdkIndexesByKey.get(key);
if (sdkIndex === undefined) {
sdkIndex = sdkIndexesByKey.size;
sdkIndexesByKey.set(key, sdkIndex);
}
if (toolCall.index !== sdkIndex) {
toolCall = { ...toolCall, index: sdkIndex };
changed = true;
deltaChanged = true;
}
}
normalizedToolCalls.push(toolCall);
};
for (const [toolCallPosition, toolCall] of deltaRecord.tool_calls.entries()) {
if (!toolCall || typeof toolCall !== 'object') {
normalizedToolCalls.push(toolCall);
@@ -209,11 +227,11 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
normalizedToolCall.function === toolCallRecord.function &&
(!rememberedName || hasFunctionName(toolCallRecord))
) {
normalizedToolCalls.push(toolCall);
forwardToolCall(toolCallRecord, key);
} else {
changed = true;
deltaChanged = true;
normalizedToolCalls.push(normalizedToolCall);
forwardToolCall(normalizedToolCall, key);
}
continue;
}
@@ -254,13 +272,13 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
normalizedToolCall.type === toolCallRecord.type &&
normalizedToolCall.function === toolCallRecord.function
) {
normalizedToolCalls.push(toolCall);
forwardToolCall(toolCallRecord, key);
continue;
}
changed = true;
deltaChanged = true;
normalizedToolCalls.push(normalizedToolCall);
forwardToolCall(normalizedToolCall, key);
}
if (!deltaChanged) return choice;