Skip to content

Commit 03ee789

Browse files
committed
fix(agent): a reply-button click is not text the person typed
A reply in the next-step box shows a 1-3 word label; what a click sends is the reply's message, up to 300 characters the agent wrote (visible only as a tooltip). It went through the chat's normal send, so the host recorded it as typed — and since #112 and #114 that is the person's word: the Ask When Risky reviewer counts it as authorization, and remember may quote it. A README that steers the agent into a "Yes" button whose message says more could have turned one click into an explicit go-ahead. The page now sends a click as a reply ({ type: 'send', reply: true }), and keeps that mark through the queue (drained at the end of a run, or sent with Send now while idle). handleSend passes typed: false for it, so the reply reaches the agent as before but is not the person's word. A reply the person edits in the message box, or that is handed back to it, is sent as their own typing. Tested: the page's queue functions lifted from chat.html (sent at once, queued while busy, drained, Send now when idle — the mark survives each, and a typed message carries none); handleSend lifted into sessionExpiredHost (a reply reaches agentFlow with typed: false, a typed message with true); the button's click and the send message are pinned from source.
1 parent 50687c1 commit 03ee789

5 files changed

Lines changed: 60 additions & 19 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2750,12 +2750,18 @@ function withImages(msgs) {
27502750
return touched ? out : msgs;
27512751
}
27522752

2753-
async function handleSend(text, images) {
2753+
/**
2754+
* A message from the chat. `opts.reply` marks a click on a reply button in the next-step box: words the agent
2755+
* wrote (the person saw a short label), so the goal is not recorded as typed — not for the Ask When Risky
2756+
* reviewer, not for `remember` to quote.
2757+
*/
2758+
async function handleSend(text, images, opts) {
2759+
const reply = !!(opts && opts.reply);
27542760
const imageBlocks = storeImages(images);
27552761
if ((!text || !text.trim()) && !imageBlocks.length) { return; }
27562762
text = text || '';
27572763
if (ctx) { ctx.globalState.update('levelcode.ai.hasSentMessage', true); } // user engaged → stop auto-revealing the panel on launch
2758-
if (agentMode) { await agentFlow(text, imageBlocks, { typed: true }); return; }
2764+
if (agentMode) { await agentFlow(text, imageBlocks, { typed: !reply }); return; }
27592765
const cfg = aiConfig();
27602766
const providerId = currentProviderId();
27612767
dbg('chat.send', { provider: providerId, model: activeModel(cfg, providerId), chars: text.length, history: conversation.length });
@@ -3335,7 +3341,7 @@ class ChatViewProvider {
33353341
case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break;
33363342
case 'setApprovals': setApprovalMode(String(msg.mode || '')); break;
33373343
case 'setAutopilot': setApprovalMode(msg.on ? 'autopilot' : 'ask'); break; // the two-option menu's message
3338-
case 'send': await handleSend(msg.text, msg.images); break;
3344+
case 'send': await handleSend(msg.text, msg.images, { reply: msg.reply === true }); break;
33393345
// One surface for "that could not be attached" — VS Code's own, not a second one
33403346
// invented inside the transcript.
33413347
case 'notice': if (msg.text) { vscode.window.showWarningMessage(String(msg.text)); } break;

‎extensions/levelcode-ai/media/chat.html‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3466,7 +3466,8 @@
34663466
if (b.dataset.tb === 'reply') {
34673467
const a = (w.actions || [])[+b.dataset.i]; if (!a) { return; }
34683468
w.dismissed = true; renderTurnBox();
3469-
sendOrQueue(a.message || a.label, []);
3469+
// A reply's words are the agent's (you saw its label): sent as a reply, never as text you typed.
3470+
sendOrQueue(a.message || a.label, [], true);
34703471
} else if (b.dataset.tb === 'dismiss') {
34713472
w.dismissed = true; renderTurnBox(); input.focus();
34723473
}
@@ -3489,8 +3490,8 @@
34893490
function queuePeek(text){ return String(text || '').replace(/\s+/g, ' ').trim(); }
34903491
function queueCount(n){ return n + ' message' + (n === 1 ? '' : 's') + ' queued'; }
34913492
function canSendNow(e){ return !!e && !e.sending && !e.images.length && streaming && runIsAgent; }
3492-
function enqueue(text, images){
3493-
queue.push({ id: 'q' + (++queueSeq) + '-' + Date.now().toString(36), text: text, images: images || [], sending: false });
3493+
function enqueue(text, images, reply){
3494+
queue.push({ id: 'q' + (++queueSeq) + '-' + Date.now().toString(36), text: text, images: images || [], sending: false, reply: reply === true });
34943495
renderQueue();
34953496
}
34963497
function queueEntry(id){ return queue.find(function(e){ return e.id === id; }); }
@@ -3574,7 +3575,7 @@
35743575
}
35753576
function sendQueuedNow(id){
35763577
const e = queueEntry(id); if (!e || e.sending) { return; }
3577-
if (!busy()) { dropQueued(id); renderQueue(); postSend(e.text, e.images); return; }
3578+
if (!busy()) { dropQueued(id); renderQueue(); postSend(e.text, e.images, e.reply); return; }
35783579
if (!canSendNow(e)) { return; }
35793580
e.sending = true; renderQueue();
35803581
vscode.postMessage({ type: 'steer', id: e.id, text: e.text });
@@ -3606,12 +3607,13 @@
36063607
if (!lastRunOk) { queueToComposer(stopAsked ? ' because the run was stopped' : ' because the run didn’t finish'); return; }
36073608
const e = queue.find(function(x){ return !x.sending; }); if (!e) { return; }
36083609
dropQueued(e.id); renderQueue();
3609-
postSend(e.text, e.images);
3610+
postSend(e.text, e.images, e.reply);
36103611
}
36113612
// A reply button in the next-step box, or anything else that sends on your behalf: queued while busy.
3612-
function sendOrQueue(text, images){
3613-
if (busy()) { enqueue(text, images); return; }
3614-
postSend(text, images);
3613+
// `reply` marks words the agent wrote and you clicked, so the host does not count them as typed.
3614+
function sendOrQueue(text, images, reply){
3615+
if (busy()) { enqueue(text, images, reply); return; }
3616+
postSend(text, images, reply);
36153617
}
36163618
queueBar.addEventListener('click', function(ev){
36173619
const b = ev.target.closest && ev.target.closest('[data-q]');
@@ -3863,7 +3865,7 @@
38633865
postSend(t, imgs);
38643866
}
38653867
// Send a message: your bubble in the transcript, then the host. `imgs` are the composer's attachment objects.
3866-
function postSend(t, imgs){
3868+
function postSend(t, imgs, reply){
38673869
const payload = imgs.map(function(i){
38683870
return { media_type: i.media_type, base64: i.base64, w: i.w, h: i.h, bytes: i.bytes };
38693871
});
@@ -3873,7 +3875,7 @@
38733875
forceStick();
38743876
if (turn.wrap) { turn.wrap.dismissed = true; renderTurnBox(); } // you answered: the box has done its job
38753877
awaitingStart = true; stopAsked = false; syncSendBtn();
3876-
vscode.postMessage({ type: 'send', text: t, images: payload });
3878+
vscode.postMessage({ type: 'send', text: t, images: payload, reply: reply === true });
38773879
}
38783880

38793881
// ── attached images ───────────────────────────────────────────────────────────────────────────

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ test('SEND: an image with no words is a valid message', () => {
108108
});
109109

110110
test('HOST: images become refs in the conversation, and bytes only at request time', () => {
111-
assert.match(ext, /case 'send': await handleSend\(msg\.text, msg\.images\)/, 'send must carry images');
111+
assert.match(ext, /case 'send': await handleSend\(msg\.text, msg\.images[,)]/, 'send must carry images');
112112
const store = fnBody(ext, 'storeImages');
113113
assert.match(store, /imageStore\.put\(/, 'bytes must go through the store');
114114
assert.match(store, /type: 'image', ref/, 'the conversation must keep a ref, never the base64');
@@ -137,7 +137,7 @@ test('AGENT MODE: the default path carries images too', () => {
137137
// which never saw them — so in the DEFAULT mode a pasted screenshot was written to disk and
138138
// dropped. The exact silent-drop failure this whole feature exists to prevent.
139139
// (The third argument marks the goal as typed, for the Ask When Risky reviewer — not about images.)
140-
assert.match(ext, /if \(agentMode\) \{ await agentFlow\(text, imageBlocks(, \{ typed: true \})?\); return; \}/,
140+
assert.match(ext, /if \(agentMode\) \{ await agentFlow\(text, imageBlocks(, \{ typed: (true|!reply) \})?\); return; \}/,
141141
'agent mode must forward the image blocks');
142142
assert.match(ext, /async function agentFlow\(text, imageBlocks(, opts)?\)/, 'agentFlow must accept them');
143143
const body = fnBody(ext, 'agentFlow');

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ function makeQueue(state) {
162162
'const vscode = { postMessage: (m) => env.posted.push(m) };',
163163
'function renderQueue(){} function renderChips(){} function syncSendBtn(){} function auto(){}',
164164
'function note(t){ env.notes.push(t); }',
165-
'function postSend(t, imgs){ env.sent.push({ text: t, images: imgs }); awaitingStart = true; }',
165+
'function postSend(t, imgs, reply){ env.sent.push(reply === true ? { text: t, images: imgs, reply: true } : { text: t, images: imgs }); awaitingStart = true; }',
166166
...['busy', 'queuePeek', 'queueCount', 'canSendNow', 'enqueue', 'queueEntry', 'dropQueued', 'queueBtn', 'queueHtml', 'moveQueued',
167167
'sendQueuedNow', 'editQueued', 'queueToComposer', 'drainQueue', 'sendOrQueue'].map(extract),
168168
'Object.assign(this, { queue: () => queue, images: () => pendingImages, open: (v) => { queueOpen = v; }, busy, enqueue, queueHtml, moveQueued, sendQueuedNow, editQueued, drainQueue, sendOrQueue, canSendNow });'
@@ -249,6 +249,26 @@ test('STILL BUSY: nothing is drained, and a reply button queues like Enter does'
249249
waiting.sendOrQueue('x', []);
250250
assert.strictEqual(waiting.queue().length, 1, 'a message on its way that has not started yet counts as busy — no second run');
251251
});
252+
test('A REPLY STAYS A REPLY: a click\'s words are sent marked as a reply — straight away, queued, drained, and by Send now when idle', () => {
253+
const idle = makeQueue({});
254+
idle.sendOrQueue('Go ahead and push the fix.', [], true);
255+
assert.deepStrictEqual(idle.env.sent, [{ text: 'Go ahead and push the fix.', images: [], reply: true }]);
256+
const typed = makeQueue({});
257+
typed.sendOrQueue('typed by me', []);
258+
assert.deepStrictEqual(typed.env.sent, [{ text: 'typed by me', images: [] }], 'a message that is not a reply carries no mark');
259+
const busyQ = makeQueue({ streaming: true });
260+
busyQ.sendOrQueue('Run the tests.', [], true);
261+
busyQ.sendOrQueue('and mine', []);
262+
assert.deepStrictEqual(busyQ.queue().map((e) => [e.text, e.reply]), [['Run the tests.', true], ['and mine', false]], 'queued while busy, the mark is kept');
263+
const drained = makeQueue({ lastRunOk: true });
264+
drained.enqueue('Run the tests.', [], true);
265+
drained.drainQueue();
266+
assert.deepStrictEqual(drained.env.sent, [{ text: 'Run the tests.', images: [], reply: true }], 'the queued reply lost its mark on the way out');
267+
const now = makeQueue({});
268+
now.enqueue('Yes, fix it.', [], true);
269+
now.sendQueuedNow(now.queue()[0].id);
270+
assert.deepStrictEqual(now.env.sent, [{ text: 'Yes, fix it.', images: [], reply: true }]);
271+
});
252272
test('EDIT IN COMPOSER: out of the queue, into the message box after any draft', () => {
253273
const q = makeQueue({ streaming: true, composer: 'draft' });
254274
q.enqueue('fix the test', []);
@@ -258,6 +278,11 @@ test('EDIT IN COMPOSER: out of the queue, into the message box after any draft',
258278
});
259279

260280
// ── the wiring ──────────────────────────────────────────────────────────────────────────────────
281+
test('WIRING: a reply button sends its words as a reply, and the page tells the host so', () => {
282+
assert.match(html, /sendOrQueue\(a\.message \|\| a\.label, \[\], true\);/, 'the reply button no longer marks its click as a reply');
283+
assert.match(html, /vscode\.postMessage\(\{ type: 'send', text: t, images: payload, reply: reply === true \}\);/);
284+
});
285+
261286
test('ENTER WHILE THE AGENT WORKS: queued — ■ with an empty composer still stops', () => {
262287
const body = extract('doSend');
263288
const stopAt = body.indexOf("vscode.postMessage({ type: 'stop' })");

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,12 +1093,20 @@ async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name);
10931093
assert.deepStrictEqual(t.calls.flows, [{ text: 'Run the tests and fix what fails', typed: true }]);
10941094
});
10951095

1096+
await test('SEND a reply-button click: the agent\'s words reach agentFlow NOT marked typed — a click on "Yes" is not your words', async () => {
1097+
const t = boot({ agentMode: true, settings: { 'levelcode.ai.providerMode': 'byok' }, byokKey: 'k' });
1098+
await t.host.handleSend('Go ahead and push the fix to main.', [], { reply: true });
1099+
await t.host.handleSend('Fix it', [], { reply: false });
1100+
assert.deepStrictEqual(t.calls.flows, [{ text: 'Go ahead and push the fix to main.', typed: false }, { text: 'Fix it', typed: true }]);
1101+
});
1102+
10961103
await test('…and nothing else does: Retry, Continue and a diagram retry pass words the person did not type here', async () => {
10971104
// The response bar's buttons live in the message switch, which no harness lifts: pinned from source.
1098-
// Exactly one caller may say `typed: true`, and it is handleSend.
1099-
assert.strictEqual((src.match(/typed: true/g) || []).length, 1, 'another caller marks its goal as typed');
1105+
// Exactly one caller may pass a typed flag, and it is handleSend (a reply button's click is not typing).
1106+
assert.strictEqual((src.match(/\{ typed: /g) || []).length, 1, 'another caller marks its goal as typed');
11001107
assert.match(src, /async function agentFlow\(text, imageBlocks, opts\) \{[\s\S]{0,4000}?rememberTypedGoal\(text, opts\);/, 'agentFlow hands the flag through unchanged');
1101-
assert.match(src, /if \(agentMode\) \{ await agentFlow\(text, imageBlocks, \{ typed: true \}\); return; \}/);
1108+
assert.match(src, /if \(agentMode\) \{ await agentFlow\(text, imageBlocks, \{ typed: !reply \}\); return; \}/);
1109+
assert.match(src, /case 'send': await handleSend\(msg\.text, msg\.images, \{ reply: msg\.reply === true \}\); break;/);
11021110
assert.match(src, /case 'retry':[^\n]*await agentFlow\(lastAgentGoal\);/);
11031111
assert.match(src, /case 'continueAgent':[^\n]*await agentFlow\('Continue from where you left off[^\n]*'\);/);
11041112
});

0 commit comments

Comments
 (0)