Skip to content

Commit 05060c1

Browse files
committed
fix(agent): the reviewer works on the native Anthropic route
The Anthropic adapter called opts.onText for every text delta without checking it was given one, and the reviewer — a turn whose text is collected, never streamed — gave none. Every review on that route threw, and every command fell back to the approval card: Ask When Risky quietly behaved like Default Approvals for anyone using their own Anthropic key. The reviewer now hands the adapter callbacks that do nothing, and the adapter treats onText as optional, as the OpenAI adapter already does. test/agentReviewAnthropic.test.js runs the real dispatcher and the real Anthropic adapter with only fetch stood in (Anthropic's SSE shape): it failed with "opts.onText is not a function" before, passes now, and fails again with both halves removed.
1 parent 0c8ec05 commit 05060c1

3 files changed

Lines changed: 91 additions & 1 deletion

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,9 @@ async function reviewGate(ctx, action) {
888888
runTurn: (o) => providers.streamAgentTurn({
889889
providerId: ctx.providerId, apiKey: ctx.apiKey, baseURL: ctx.baseURL, label: ctx.label,
890890
model, maxTokens: reasons ? 4096 : 400, system: o.system, messages: o.messages, tools: [],
891+
// The answer is collected, never streamed into the chat — but the native Anthropic adapter calls
892+
// onText for every text delta, so it is handed one that does nothing.
893+
onText: () => { }, onToolStart: () => { }, onToolInput: () => { },
891894
signal: o.signal, reasoningEffort: catalog.reasoningEffortFor(model, 'review')
892895
}),
893896
messages: ctx.messages, userRequests: ctx.userRequests, userAnswers: ctx.userAnswers,

‎extensions/levelcode-ai/providers/anthropic.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ async function streamClaudeAgentTurn(opts) {
246246
} else if (ev.type === 'content_block_delta') {
247247
const blk = blocks[ev.index];
248248
if (!blk) { return; }
249-
if (ev.delta.type === 'text_delta') { blk.text += ev.delta.text; opts.onText(ev.delta.text); }
249+
// Optional, as in the OpenAI adapter: a caller that only wants the finished turn passes none.
250+
if (ev.delta.type === 'text_delta') { blk.text += ev.delta.text; if (opts.onText) { opts.onText(ev.delta.text); } }
250251
else if (ev.delta.type === 'input_json_delta') {
251252
blk._json += ev.delta.partial_json || '';
252253
// The arguments so far, for a caller that shows progress while a long input streams.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Ask When Risky on the native Anthropic route, EXECUTED — run: node test/agentReviewAnthropic.test.js
3+
*
4+
* agentReviewGate.test.js replaces providers.streamAgentTurn, so it could not see this: the native
5+
* Anthropic adapter called `opts.onText` for every text delta without checking it was given one, and the
6+
* reviewer — a turn whose text is collected, never streamed into the chat — gave none. Every review on that
7+
* route threw, and every command fell back to the approval card: the mode quietly became Default Approvals.
8+
*
9+
* So this runs the REAL dispatcher and the REAL Anthropic adapter, with only `fetch` stood in (answering
10+
* in Anthropic's SSE shape) and child_process.spawn replaced. No request leaves the process.
11+
*--------------------------------------------------------------------------------------------*/
12+
// @ts-check
13+
'use strict';
14+
15+
const assert = require('assert');
16+
const fs = require('fs');
17+
const os = require('os');
18+
const path = require('path');
19+
const Module = require('module');
20+
const { EventEmitter } = require('events');
21+
22+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-review-anthropic-'));
23+
process.on('exit', () => { try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) { /* temp */ } });
24+
const vscodeMock = { workspace: { workspaceFolders: [{ uri: { fsPath: root }, name: 'app' }] } };
25+
/** @type {string[]} */
26+
const ran = [];
27+
const childProcessMock = Object.assign({}, require('child_process'), {
28+
spawn: (command) => {
29+
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), pid: 4242, kill() { } });
30+
ran.push(String(command));
31+
setImmediate(() => { child.stdout.emit('data', Buffer.from('ok\n')); child.emit('close', 0, null); });
32+
return child;
33+
}
34+
});
35+
const origLoad = Module._load;
36+
// @ts-ignore — test-only loader shim
37+
Module._load = function (request, parent, isMain) {
38+
if (request === 'vscode') { return vscodeMock; }
39+
if (request === 'child_process') { return childProcessMock; }
40+
return origLoad.call(this, request, parent, isMain);
41+
};
42+
43+
// Anthropic's streaming shape, one event per `data:` line.
44+
const sse = (events) => new Response(events.map((e) => 'event: ' + e.type + '\ndata: ' + JSON.stringify(e) + '\n\n').join(''), { status: 200, headers: { 'content-type': 'text/event-stream' } });
45+
const start = { type: 'message_start', message: { usage: { input_tokens: 10 } } };
46+
const stop = (reason) => [{ type: 'message_delta', delta: { stop_reason: reason }, usage: { output_tokens: 5 } }, { type: 'message_stop' }];
47+
const textTurn = (text) => sse([start, { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
48+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: text.slice(0, 20) } },
49+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: text.slice(20) } },
50+
{ type: 'content_block_stop', index: 0 }, ...stop('end_turn')]);
51+
const toolTurn = (input) => sse([start, { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'toolu_1', name: 'run_command' } },
52+
{ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: JSON.stringify(input) } },
53+
{ type: 'content_block_stop', index: 0 }, ...stop('tool_use')]);
54+
55+
/** @type {any[]} */
56+
const seen = [];
57+
let agentTurns = 0;
58+
globalThis.fetch = async (url, init) => {
59+
assert.match(String(url), /api\.anthropic\.com/, 'a request went somewhere other than the stood-in Anthropic API');
60+
const body = JSON.parse(String(init && init.body));
61+
const reviewer = !body.tools;
62+
seen.push({ reviewer });
63+
if (reviewer) { return textTurn('{"risk":"low","authorization":"implied","injection":false,"reason":"Runs the tests you asked for."}'); }
64+
return agentTurns++ === 0 ? toolTurn({ command: 'npm test', explanation: 'Run the tests' }) : textTurn('Done: the tests pass, and nothing else changed here.');
65+
};
66+
67+
const { runAgent } = require('../agent');
68+
69+
(async () => {
70+
/** @type {any[]} */ const asked = [];
71+
/** @type {any[]} */ const posted = [];
72+
await runAgent({
73+
messages: [{ role: 'user', content: 'run the tests' }], userRequests: ['run the tests'],
74+
providerId: 'claude', model: 'claude-sonnet-4-6', apiKey: 'test-key-not-real', maxSteps: 5,
75+
approvalMode: 'review', post: (m) => posted.push(m), dbg: () => { },
76+
approve: async (req) => { asked.push(req); return false; },
77+
commandRuns: new Map(), commandStops: new Map(), signal: new AbortController().signal
78+
});
79+
assert.deepStrictEqual(seen.map((s) => s.reviewer), [false, true, false], 'agent turn, review, agent turn');
80+
assert.deepStrictEqual(asked, [], 'the review failed and fell back to the card: ' + JSON.stringify(asked.map((a) => a.review)));
81+
assert.deepStrictEqual(ran, ['npm test'], 'the reviewed command did not run');
82+
assert.ok(posted.some((m) => m.type === 'agentTool' && m.text === 'Reviewer · low risk — Runs the tests you asked for.'));
83+
assert.ok(!posted.some((m) => m.type === 'agentDelta' && /risk/.test(m.text)), 'the reviewer\'s answer was streamed into the chat');
84+
console.log(' ok - REVIEW (Anthropic native): the reviewer\'s answer is read, not streamed, and the command runs');
85+
console.log('\nagentReviewAnthropic: 1 test passed.');
86+
})().catch((e) => { console.error(e); process.exit(1); });

0 commit comments

Comments
 (0)