Skip to content

Commit d24d0b5

Browse files
committed
fix(agent): only a goal the person typed is their word to the reviewer
agentFlow recorded every goal it was handed as the person's, but not every caller passes typed text: a diagram retry sends a request built from the model's spec and the repair report, and Retry after a resume re-runs lastAgentGoal read back from the session file — the very text resumeSession keeps out of the person's words. Both landed in the reviewer's PERSON section. agentFlow now takes { typed }, which only handleSend sets (the chat's send message, from the input box); the goal is recorded only then. A typed goal that Retry repeats is already recorded; Continue's fixed instruction and a diagram retry stay activity. Run, not regexed, where a harness lifts the call site (handleSend in sessionExpiredHost, retryDiagram in diagramHost); the response bar's Retry/Continue lines are pinned from source, with exactly one caller allowed to say typed: true.
1 parent 23a2d98 commit d24d0b5

4 files changed

Lines changed: 39 additions & 12 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2240,7 +2240,12 @@ async function compactAgentMemory() {
22402240
}
22412241

22422242
let lastAgentGoal = null; // remembered so the response bar's Retry can re-run it
2243-
async function agentFlow(text, imageBlocks) {
2243+
/**
2244+
* Run the agent on a goal. `opts.typed` says the person typed `text` into the chat just now (handleSend);
2245+
* every other caller — a diagram retry, Retry and Continue on the response bar — passes words the person
2246+
* did not type here, some of them read back from a session file or written by the model.
2247+
*/
2248+
async function agentFlow(text, imageBlocks, opts) {
22442249
if (text && text.trim()) { lastAgentGoal = text; }
22452250
const cfg = aiConfig();
22462251
const providerId = currentProviderId();
@@ -2269,7 +2274,10 @@ async function agentFlow(text, imageBlocks) {
22692274
checkpoints.push(currentCheckpoint);
22702275
post({ type: 'checkpointOpened', turnId: currentCheckpoint.turnId });
22712276
agentMessages.push(goalMsg);
2272-
if (text && text.trim()) { humanGoals.push(text.trim()); if (humanGoals.length > 20) { humanGoals.splice(0, humanGoals.length - 20); } }
2277+
// Only what the person typed becomes their word to the Ask When Risky reviewer. A retried or generated goal
2278+
// stays activity: a typed goal Retry repeats is already on this list, and one from a resumed session must
2279+
// not be (see resumeSession).
2280+
if (opts && opts.typed && text && text.trim()) { humanGoals.push(text.trim()); if (humanGoals.length > 20) { humanGoals.splice(0, humanGoals.length - 20); } }
22732281
trimAgentMemory();
22742282
// Sessions: mark where THIS turn's messages begin (the goal, post-trim). agentMessages is a trimmed
22752283
// live window, but a persisted session is the FULL append-only history — so after the run we persist
@@ -2578,7 +2586,7 @@ async function handleSend(text, images) {
25782586
if ((!text || !text.trim()) && !imageBlocks.length) { return; }
25792587
text = text || '';
25802588
if (ctx) { ctx.globalState.update('levelcode.ai.hasSentMessage', true); } // user engaged → stop auto-revealing the panel on launch
2581-
if (agentMode) { await agentFlow(text, imageBlocks); return; }
2589+
if (agentMode) { await agentFlow(text, imageBlocks, { typed: true }); return; }
25822590
const cfg = aiConfig();
25832591
const providerId = currentProviderId();
25842592
dbg('chat.send', { provider: providerId, model: activeModel(cfg, providerId), chars: text.length, history: conversation.length });

‎extensions/levelcode-ai/test/diagramHost.test.js‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ const makeHost = new Function('env', [
131131

132132
function boot(over) {
133133
const { vscode, ui } = makeVscode();
134-
const stats = [], logs = [], flows = [], posted = [];
134+
const stats = [], logs = [], flows = [], typedFlows = [], posted = [];
135135
const settings = Object.assign({ 'diagrams.enabled': true }, over && over.settings);
136136
const diagrams = createDiagrams({ resolveLink: (link) => diagramLinks.resolveLink(link, [{ name: 'app', root: ws }]), onStat: (ev) => stats.push(ev) });
137137
const env = {
@@ -144,7 +144,7 @@ function boot(over) {
144144
aiConfig: () => ({ get: (k, d) => (k in settings ? settings[k] : d) }),
145145
dbg: (label, data) => logs.push({ label, data }),
146146
recordDiagramStat: (ev) => stats.push(ev),
147-
agentFlow: async (text) => { flows.push(text); host.setGoal(text); },
147+
agentFlow: async (text, _img, opts) => { flows.push(text); if (opts && opts.typed) { typedFlows.push(text); } host.setGoal(text); },
148148
findCompactionCut, estimateMsgTokens, meterModel: () => 'm',
149149
prepProviderRequest: async () => ({ ok: true, providerId: 'claude', model: 'm' }),
150150
authRetry: async (req, fn) => fn(req),
@@ -157,7 +157,7 @@ function boot(over) {
157157
replaySessionExpired: async () => { }, postContextFiles: () => { }, refreshSessions: () => { }, focusChatView: () => { }
158158
};
159159
const host = makeHost(env);
160-
return { host, ui, diagrams, stats, logs, flows, posted };
160+
return { host, ui, diagrams, stats, logs, flows, typedFlows, posted };
161161
}
162162
const jev = () => ({
163163
title: 'Jev classifies; your code decides the action',
@@ -490,6 +490,9 @@ function drawn(b, input, key) { b.diagrams.beginRun(); const out = b.diagrams.re
490490
assert.ok(b.flows[0].includes('/edges/2/to: unknown node "ghost". Known ids: in, jev, bill.'), 'the model is told what was wrong');
491491
assert.ok(b.flows[0].includes('1 edge dropped: unknown nodes'));
492492
assert.strictEqual(b.host.goal(), 'the user\'s real goal', 'Retry/Continue on the response bar still mean what the user asked');
493+
// The redraw request is written from the model's spec and the repair report — never the person's
494+
// word to the Ask When Risky reviewer.
495+
assert.deepStrictEqual(b.typedFlows, [], 'a diagram retry was passed off as typed');
493496
});
494497

495498
// ── context budget: stubbed at compaction, never turn by turn ───────────────────────────────

‎extensions/levelcode-ai/test/imageAttach.test.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,10 @@ test('AGENT MODE: the default path carries images too', () => {
133133
// I shipped this broken. handleSend stored the bytes and then early-returned into agentFlow(text),
134134
// which never saw them — so in the DEFAULT mode a pasted screenshot was written to disk and
135135
// dropped. The exact silent-drop failure this whole feature exists to prevent.
136-
assert.match(ext, /if \(agentMode\) \{ await agentFlow\(text, imageBlocks\); return; \}/,
136+
// (The third argument marks the goal as typed, for the Ask When Risky reviewer — not about images.)
137+
assert.match(ext, /if \(agentMode\) \{ await agentFlow\(text, imageBlocks(, \{ typed: true \})?\); return; \}/,
137138
'agent mode must forward the image blocks');
138-
assert.match(ext, /async function agentFlow\(text, imageBlocks\)/, 'agentFlow must accept them');
139+
assert.match(ext, /async function agentFlow\(text, imageBlocks(, opts)?\)/, 'agentFlow must accept them');
139140
const body = fnBody(ext, 'agentFlow');
140141
assert.match(body, /imageBlocks && imageBlocks\.length/, 'and use them when present');
141142
assert.match(body, /\[\.\.\.labelImages\(imageBlocks\), \{ type: 'text', text \}\]/, 'images lead the goal');

‎extensions/levelcode-ai/test/sessionExpiredHost.test.js‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ const makeHost = new Function('env', [
108108
' sealLiveSession, resetConversationState, postContextFiles, postMemoryDigest, sessionsManager, currentContextLimit,',
109109
' refreshSessions, focusChatView, review, checkpoints } = env;',
110110
"const SYSTEM_PROMPT = 'system';",
111-
'let activeWebview = env.webview, cloudSignedIn = env.signedIn, agentMode = false, conversation = [], pendingContext = null, conversationEpoch = 0, abort = null;',
111+
'let activeWebview = env.webview, cloudSignedIn = env.signedIn, agentMode = !!env.agentMode, conversation = [], pendingContext = null, conversationEpoch = 0, abort = null;',
112112
'let agentMessages = [], currentCheckpoint = null, lastAgentGoal = null;',
113113
// resumeSession starts the Ask When Risky reviewer's record of the person's goals over (agentReview.js).
114114
'let humanGoals = [], humanAnswers = [];',
@@ -184,7 +184,7 @@ function boot(over) {
184184
for (const k of Object.keys(o.settings)) { if (o.settings[k] === null) { delete settings[k]; } else { settings[k] = o.settings[k]; } }
185185

186186
const secrets = new Map(), state = new Map(), posted = [], ops = [];
187-
const calls = { refresh: [], stream: [], keyPrompts: 0 };
187+
const calls = { refresh: [], stream: [], keyPrompts: 0, flows: [] };
188188
const vscode = { workspace: { getConfiguration: (section) => ({ get: (k, d) => (settings[section + '.' + k] === undefined ? d : settings[section + '.' + k]) }) } };
189189
const webview = { postMessage: (m) => { posted.push(m); } };
190190
const t = {
@@ -214,7 +214,7 @@ function boot(over) {
214214
last: (type) => posted.filter((m) => m.type === type).pop()
215215
};
216216
const env = {
217-
vscode, resolveGateway, webview: o.webview ? webview : undefined, signedIn: !!o.access,
217+
vscode, resolveGateway, webview: o.webview ? webview : undefined, signedIn: !!o.access, agentMode: !!o.agentMode,
218218
session: o.refreshTimeoutMs ? Object.assign({}, session, { REFRESH_TIMEOUT_MS: o.refreshTimeoutMs }) : session,
219219
ctx: {
220220
secrets: {
@@ -275,7 +275,7 @@ function boot(over) {
275275
aiConfig: () => vscode.workspace.getConfiguration('levelcode.ai'),
276276
dbg: () => { }, refreshCloudProfile: async () => { }, fetchCloudRoster: () => { }, sendConfigToWebview: () => { },
277277
gatewayModel: () => 'cloud-model', maxOutputTokens: () => 4096, currentProviderId: () => 'claude',
278-
baseUrlFor: () => '', activeModel: () => t.activeModelId || 'claude-model', storeImages: () => [], agentFlow: async () => { },
278+
baseUrlFor: () => '', activeModel: () => t.activeModelId || 'claude-model', storeImages: () => [], agentFlow: async (text, _img, opts) => { calls.flows.push({ text, typed: !!(opts && opts.typed) }); },
279279
listWorkspaceFiles: async () => [], workspaceMapBlock: () => '', activeFileBlock: () => '',
280280
contextFileBlocks: async () => [], gatherAutoContext: async () => ({ blocks: [], names: [] }),
281281
labelImages: (b) => b, withImages: (m) => m,
@@ -1084,6 +1084,21 @@ async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name);
10841084
assert.strictEqual(t.calls.refresh.length, 1);
10851085
});
10861086

1087+
await test('SEND in agent mode: the goal reaches agentFlow marked as typed — the Ask When Risky reviewer\'s only source of the person\'s word', async () => {
1088+
const t = boot({ agentMode: true, settings: { 'levelcode.ai.providerMode': 'byok' }, byokKey: 'k' });
1089+
await t.host.handleSend('Run the tests and fix what fails', []);
1090+
assert.deepStrictEqual(t.calls.flows, [{ text: 'Run the tests and fix what fails', typed: true }]);
1091+
});
1092+
1093+
await test('…and nothing else does: Retry, Continue and a diagram retry pass words the person did not type here', async () => {
1094+
// The response bar's buttons live in the message switch, which no harness lifts: pinned from source.
1095+
// Exactly one caller may say `typed: true`, and it is handleSend.
1096+
assert.strictEqual((src.match(/typed: true/g) || []).length, 1, 'another caller marks its goal as typed');
1097+
assert.match(src, /if \(agentMode\) \{ await agentFlow\(text, imageBlocks, \{ typed: true \}\); return; \}/);
1098+
assert.match(src, /case 'retry':[^\n]*await agentFlow\(lastAgentGoal\);/);
1099+
assert.match(src, /case 'continueAgent':[^\n]*await agentFlow\('Continue from where you left off[^\n]*'\);/);
1100+
});
1101+
10871102
await test('`ready` does not say it twice: the handler has just read the same token, and the card is replayed last', async () => {
10881103
const t = boot({ expired: true, profile: { name: 'Ada' } });
10891104
await t.host.checkCloudSession('ready');

0 commit comments

Comments
 (0)