Skip to content

Commit 9bf75df

Browse files
committed
feat(agent): a task list under the latest message, a hand-back box, and Enter that queues instead of stopping
Modelled on the Code tab of the Claude desktop app (docs/PLAN-PROGRESS.md has the evidence and the differences). - update_plan takes Claude's shape: the whole list once ({"items": [...]}, pending / in_progress / completed), then small updates ({"advance": true}, {"done": [n], "start": m}). The host keeps each step's status by number for the conversation; the old todos / title / done still read. - wrap_up hands the work back: a recap, and one question with one or two replies when the next move is the user's. Auto-verify runs there; a hand-back whose box holds a question ends the run with no further call. - The chat draws the list as the last thing in the transcript while a run works (spinning step, stale caption, a window of 8 with "earlier steps" / "more to go"), and the next-step box when it hands back. - Enter while the agent works queues the message above the composer; it is sent when the run ends, or given back to the message box if the run was stopped or failed. Send now hands it to the agent at its next step. - wrap_up is reserved from MCP tool names. The web skin rounds the new card and band like the old plan bar.
1 parent e528846 commit 9bf75df

12 files changed

Lines changed: 1574 additions & 124 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 122 additions & 21 deletions
Large diffs are not rendered by default.

‎extensions/levelcode-ai/extension.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ let review;
104104
/** Persistent agent transcript for the session (tool calls + results), so follow-up goals
105105
* remember prior runs. Reset by New Chat. */
106106
let agentMessages = [];
107+
/** The agent's task list for this conversation (docs/PLAN-PROGRESS.md). agent.js keeps `steps` current through
108+
* update_plan and wrap_up, and every run of the conversation gets the same object, so an {"advance": true}
109+
* moves the list an earlier run left. A new conversation, and a resumed one, start with none. */
110+
let agentPlan = { steps: null };
111+
/** Messages the user pressed "Send now" on while the agent works. The running loop takes them at its next
112+
* step (ctx.takeSteers); what it never reaches it hands back to the chat's queue when the run ends. */
113+
let steerInbox = [];
114+
/** The agent run going now, as a token only its own agentFlow holds: a late finally from a run that was torn
115+
* down cannot mark a newer run as over. */
116+
let activeAgentRun = null;
107117

108118
function post(msg) { if (activeWebview) { activeWebview.postMessage(msg); } }
109119

@@ -1638,6 +1648,7 @@ async function resumeSession(id) {
16381648
if (!r) { post({ type: 'agentError', message: 'That session could not be opened — it may have been deleted.' }); return; }
16391649

16401650
agentMessages = Array.isArray(r.messages) ? r.messages.slice() : []; // what the model resumes with
1651+
agentPlan = { steps: null }; steerInbox = []; // the list is drawn live, never replayed
16411652
// Tier 2/3: the head didn't fit the window, so only the recent tail is in context. Prepend a factual
16421653
// note so the model KNOWS earlier turns exist (a real head-summary is a later refinement) — the user
16431654
// still sees the whole transcript in the replay. Ephemeral: never persisted (recordTurn appends only
@@ -1728,6 +1739,7 @@ function resetConversationState() {
17281739
clearQuestions();
17291740
conversation = [];
17301741
agentMessages = [];
1742+
agentPlan = { steps: null }; steerInbox = [];
17311743
diagrams.reset(); // the next conversation starts at d-1, owing nothing
17321744
diagramsStubbed = false;
17331745
checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack
@@ -2200,6 +2212,19 @@ async function compactAgentMemory() {
22002212
return { ok: true, beforeMsgTokens, afterMsgTokens };
22012213
}
22022214

2215+
/**
2216+
* "Send now" on a queued message: hand it to the running agent at its next step. With no run going there is
2217+
* nothing to hand it to, so it goes straight back to the chat's queue — which sends it as a normal message.
2218+
*/
2219+
function acceptSteer(msg) {
2220+
const id = typeof msg.id === 'string' ? msg.id : '';
2221+
const text = typeof msg.text === 'string' ? msg.text.trim() : '';
2222+
if (!id) { return; }
2223+
if (!activeAgentRun || !text) { post({ type: 'steersReturned', ids: [id] }); return; }
2224+
steerInbox.push({ id, text });
2225+
dbg('steer.waiting', { chars: text.length, waiting: steerInbox.length });
2226+
}
2227+
22032228
let lastAgentGoal = null; // remembered so the response bar's Retry can re-run it
22042229
async function agentFlow(text, imageBlocks) {
22052230
if (text && text.trim()) { lastAgentGoal = text; }
@@ -2253,9 +2278,13 @@ async function agentFlow(text, imageBlocks) {
22532278
}
22542279
const diagBaseline = verifyCfg.enabled ? snapshotDiagnostics() : new Map();
22552280
dbg('verify.config', { enabled: verifyCfg.enabled, hasCommand: !!verifyCfg.command, maxRounds: verifyCfg.maxRounds, includeWarnings: verifyCfg.includeWarnings });
2281+
const runToken = {};
2282+
activeAgentRun = runToken;
22562283
try {
22572284
await runAgent({
22582285
messages: withImages(agentMessages), // persists across runs → the agent remembers the session
2286+
plan: agentPlan, // the conversation's task list (update_plan / wrap_up keep it)
2287+
takeSteers: () => steerInbox.splice(0), // "Send now" messages, taken at the loop's next step
22592288
providerId: req.providerId, // Anthropic native, or an OpenAI-shaped provider via translation (P2)
22602289
baseURL: req.baseURL, // for the custom / Ollama endpoints
22612290
label: req.label, // route name for error attribution — "LevelCode Cloud" on the gateway,
@@ -2329,6 +2358,7 @@ async function agentFlow(text, imageBlocks) {
23292358
signal: abort.signal
23302359
});
23312360
} finally {
2361+
if (activeAgentRun === runToken) { activeAgentRun = null; }
23322362
// Everything below writes to state a teardown may already have replaced. runAgent holds
23332363
// `agentMessages` BY REFERENCE, so a teardown that rebinds the global leaves this run pushing
23342364
// into an orphaned array — and then `agentMessages.slice(sessTurnStart)` would slice the NEW,
@@ -3118,6 +3148,7 @@ class ChatViewProvider {
31183148
case 'notice': if (msg.text) { vscode.window.showWarningMessage(String(msg.text)); } break;
31193149
case 'pickImages': await pickImages(); break;
31203150
case 'attachImagePaths': await attachImagePaths(msg.paths); break;
3151+
case 'steer': acceptSteer(msg); break;
31213152
case 'stop': dbg('stop.clicked', { running: commandStops.size }); for (const [, stop] of commandStops) { try { stop(); } catch (e) { /* gone */ } } if (abort) { abort.abort(); } clearApprovals(); clearQuestions(); break;
31223153
case 'stopCommand': { dbg('stopCommand', { id: msg.id }); const s = commandStops.get(msg.id); if (s) { try { s(); } catch (e) { /* gone */ } } break; }
31233154
case 'approvalResponse': {

‎extensions/levelcode-ai/mcpConfig.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const crypto = require('crypto');
3232

3333
// The agent's built-in tools (agent.js TOOLS). An MCP tool may never shadow one of these.
3434
const BUILTIN_TOOL_NAMES = [
35-
'list_files', 'read_file', 'search', 'update_plan', 'edit_file', 'write_file',
35+
'list_files', 'read_file', 'search', 'update_plan', 'wrap_up', 'edit_file', 'write_file',
3636
'delete_file', 'run_command', 'read_command_output', 'ask_user', 'use_skill'
3737
];
3838

0 commit comments

Comments
 (0)