From 9bf75df61457e1d91236d6dedbb6465d31a832c0 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Mon, 19 Oct 2026 20:07:14 -0400 Subject: [PATCH 1/7] 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. --- extensions/levelcode-ai/agent.js | 143 ++++- extensions/levelcode-ai/extension.js | 31 + extensions/levelcode-ai/mcpConfig.js | 2 +- extensions/levelcode-ai/media/chat.html | 547 +++++++++++++++--- .../test/agentPlanProgress.test.js | 249 ++++++++ .../levelcode-ai/test/diagramHost.test.js | 2 +- .../levelcode-ai/test/imageAttach.test.js | 13 +- .../levelcode-ai/test/planProgressUi.test.js | 292 ++++++++++ .../test/sessionExpiredHost.test.js | 2 +- extensions/levelcode-ai/test/turnPlan.test.js | 178 ++++++ extensions/levelcode-ai/turnPlan.js | 237 ++++++++ web/ai-extension/skin/chat.css | 2 +- 12 files changed, 1574 insertions(+), 124 deletions(-) create mode 100644 extensions/levelcode-ai/test/agentPlanProgress.test.js create mode 100644 extensions/levelcode-ai/test/planProgressUi.test.js create mode 100644 extensions/levelcode-ai/test/turnPlan.test.js create mode 100644 extensions/levelcode-ai/turnPlan.js diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 7f2c51a..7a132d9 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -26,6 +26,7 @@ const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, const { connectAll, getServer } = require('./mcpClient'); const diagramTool = require('./diagram/tool'); const diagramRepair = require('./diagram/repair'); +const turnPlan = require('./turnPlan'); const SYSTEM_LINES = [ "You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their", @@ -42,11 +43,12 @@ const SYSTEM_LINES = [ '- Commands that do NOT exit on their own (dev servers, file watchers, tail -f) MUST be run with run_command background:true — it returns immediately so you keep working instead of hanging. After starting one, call read_command_output with the returned id to watch for a readiness/port line (e.g. "listening on :3000") before you test against it. Use a normal foreground run_command for things that finish (builds, installs, tests, git, curl). This pairs with verification: bring the app up in the background, confirm it serves, fix, repeat.', '- EVERY run_command, read_file and search MUST include "explanation": 3-8 words, active voice, imperative, saying what you are doing and why ("Run the extension unit tests", "Find the insertion point in section 10", "Read the runAgent call site"). It becomes that action\'s label in the user\'s activity view — never omit it.', '- Paths are relative to the workspace root. In a MULTI-ROOT workspace (several top-level folders), paths from list_files/search are prefixed with the folder name (e.g. "thin.ly/app/models/link.rb") — use them exactly as shown; an unprefixed path resolves against the first folder. To create a file in a specific folder, prefix its name. run_command accepts an optional "folder" to pick which folder it runs in.', - '- For a multi-step goal, call update_plan FIRST with a short checklist (3-8 short items, all "pending"), then call it again to set an item "in_progress" when you start it and "done" when finished. Skip the plan for trivial single-step goals.', + '- For a goal with more than one step, keep a task list with update_plan: the user sees it under your latest message while you work. Send the whole list ({"items": [...]}, short imperative steps, the one you are starting "in_progress", the rest "pending") together with your first tool call, then {"advance": true} together with the call that starts each next step. Keep exactly one step in_progress; if the plan changes, send the whole list again. Do not also write the plan out in your message. Skip the list for a one-step goal.', + '- The user can send you a message while you work. It arrives after a tool result, marked as sent during the run: read it at once, say in one line how it changes what you are doing, update the task list if it does, and carry on.', '- If the goal truly depends on a decision only the user can make (tech stack, scope, where to create files, must-have features), call ask_user ONCE with concise multiple-choice questions (a short header + 2-4 concrete options each) INSTEAD of writing the questions as prose. Put your RECOMMENDED option FIRST and use its description to say why — and, when it matters, what would change your mind. Then act on their answers and do not ask again. Do NOT ask about things you can reasonably decide yourself — prefer a sensible default and proceed.', '- You have SKILLS — short expert playbooks for common task types, listed under "Available skills" below with a name and a one-line description. When the user\'s goal clearly matches a skill\'s description (e.g. reviewing a diff, fixing one reported bug, writing tests), call use_skill with that exact name FIRST, before other tools, and follow the steps it returns. Pick at most one; if nothing clearly fits, just proceed normally. Do not mention skills to the user.', - '- When the goal is finished, end with a line starting with "Done:" — one crisp sentence of what was accomplished. For a substantial run (several files, a tricky diagnosis, or a decision the user should know about), follow that line with a SHORT wrap-up in plain lines: what landed, how you verified it, anything left open or worth pushing on. For a trivial goal the "Done:" line alone is right. Then STOP (no more tools).', - '- After you finish, your work is verified automatically: editor diagnostics for the files you changed (plus a verify command, if the user configured one) are checked. If problems are found you will get them back as a follow-up message — fix the ones you introduced, then finish again with "Done:". If a reported problem is clearly pre-existing and unrelated to the goal, do not chase it: note it in one line and finish.' + '- When the goal is finished, call wrap_up (its description says how) in every run where you kept a task list, changed files or ran commands. Then end with a line starting with "Done:" — one crisp sentence of what was accomplished — unless wrap_up said the box holds your question: then write nothing more. For a substantial run (several files, a tricky diagnosis, or a decision the user should know about), follow that line with a SHORT wrap-up in plain lines: what landed, how you verified it, anything left open or worth pushing on. For a trivial goal the "Done:" line alone is right. Then STOP (no more tools).', + '- When you finish (wrap_up, or the "Done:" line), your work is verified automatically: editor diagnostics for the files you changed (plus a verify command, if the user configured one) are checked. If problems are found you get them back (from wrap_up, or as a follow-up message) — fix the ones you introduced, then finish again. If a reported problem is clearly pre-existing and unrelated to the goal, do not chase it: note it in one line and finish.' ]; const SYSTEM_BASE = SYSTEM_LINES.join('\n'); @@ -107,7 +109,30 @@ const TOOLS = [ { name: 'list_files', description: 'List workspace files (optional glob like "**/*.js"). Excludes node_modules/.git/build dirs.', input_schema: { type: 'object', properties: { glob: { type: 'string' } } } }, { name: 'read_file', description: 'Read a workspace file (path relative to the workspace root; in a multi-root workspace use the folder-name prefix exactly as list_files shows it).', input_schema: { type: 'object', properties: { path: { type: 'string' }, explanation: { type: 'string', description: 'REQUIRED: 3-8 words, active voice, imperative — WHY you are reading this ("Read the runAgent call site", "Read agent.js tool definitions"). Shown to the user as this action\'s label.' } }, required: ['path', 'explanation'] } }, { name: 'search', description: 'Search file contents for a literal string. Returns file:line snippets.', input_schema: { type: 'object', properties: { query: { type: 'string' }, explanation: { type: 'string', description: 'REQUIRED: 3-8 words, active voice, imperative — what you are looking for ("Find every postMessage call site"). Shown to the user as this action\'s label.' } }, required: ['query', 'explanation'] } }, - { name: 'update_plan', description: 'Declare or update your task checklist for a multi-step goal. Pass the FULL list each time, each item with a status. Call it once up front (all pending), then again to mark an item in_progress when you start it and done when finished. Skip for trivial single-step goals.', input_schema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { title: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'done'] } }, required: ['title', 'status'] } } }, required: ['todos'] } }, + // The task list and the hand-back (docs/PLAN-PROGRESS.md). Pure conversation: what they decide lives in turnPlan.js. + { name: 'update_plan', description: 'Keep the task list the user sees under your latest message while you work. Use it for any goal with more than one step; skip it for a one-step answer. Send it in the same response as the tool call that starts the work, so it costs no extra round trip.\n' + + '- First: {"items": [...]}, the whole plan in order: short imperative steps (about 7 words, no numbering), the step you are starting "in_progress", the rest "pending".\n' + + '- Moving on: {"advance": true} with the call that starts the next step (completes the step in progress, starts the next pending one).\n' + + '- Out of order: {"done": [2], "start": 4} (steps are numbered from 1).\n' + + '- The plan changed (for example after a message from the user): {"items": [...]} again, the whole new list.\n' + + 'Keep exactly one step in_progress while you work. Do not also write the plan out in your message.', + input_schema: { type: 'object', properties: { + items: { type: 'array', description: 'The whole plan, in order (1-' + turnPlan.MAX_STEPS + ' steps); replaces the list.', items: { type: 'object', properties: { content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] } }, required: ['content', 'status'] } }, + advance: { type: 'boolean', description: 'Complete the step in progress and start the next pending one.' }, + done: { type: 'array', items: { type: 'integer' }, description: 'Step numbers (from 1) to mark completed.' }, + start: { type: 'integer', description: 'The step number (from 1) to mark in_progress.' } + } } }, + { name: 'wrap_up', description: 'Hand the work back: fills the box the user sees when the run is over, and checks off the task list. Call it once, when the goal is done, in every run where you kept a task list, changed files or ran commands; skip it for a short answer. Your changes are checked when you call it (the verify command and the editor\'s diagnostics for the files you changed): if problems come back instead, fix them and call it again.\n' + + '- recap: two or three short sentences to the user (about ' + turnPlan.RECAP_CHARS + ' characters): what is now true or changed, and what is not done or not verified. No recommendation here.\n' + + '- ask + actions: ONLY when the next move is the user\'s (a choice, a go-ahead, an approval): one short question (about ' + turnPlan.ASK_CHARS + ' characters, the decision alone) and 1-' + turnPlan.MAX_ACTIONS + ' replies, your recommendation first. A reply is a button: label is 1-3 words, message is what a click sends as the user\'s message (up to ' + turnPlan.MESSAGE_CHARS + ' characters).\n' + + '- blocked: true when you cannot go on without the user\'s answer.\n' + + 'When the box holds your question the run ends with this call: write nothing after it. Otherwise write your closing answer right after it.', + input_schema: { type: 'object', properties: { + recap: { type: 'string', description: 'Two or three short sentences to the user: what is now true or changed, then what is not done or not verified.' }, + ask: { type: 'string', description: 'One short question, only when the next move is the user\'s call. Omit it, and actions, when nothing waits on the user.' }, + actions: { type: 'array', description: '1-' + turnPlan.MAX_ACTIONS + ' replies to ask, your recommendation first.', items: { type: 'object', properties: { label: { type: 'string', description: 'The button: 1-3 words, e.g. "Yes, fix it", "Not now".' }, message: { type: 'string', description: 'What a click sends as the user\'s message. Omit when the label says it all.' } }, required: ['label'] } }, + blocked: { type: 'boolean', description: 'true when you cannot go on until the user answers.' } + }, required: ['recap'] } }, { name: 'edit_file', description: 'Make a targeted edit to an EXISTING file: replace an exact, unique snippet (old_str) with new_str. Applied immediately; the user reviews it with Keep/Undo. old_str must appear exactly once — include enough surrounding context to be unique.', input_schema: { type: 'object', properties: { path: { type: 'string' }, old_str: { type: 'string' }, new_str: { type: 'string' } }, required: ['path', 'old_str', 'new_str'] } }, { name: 'write_file', description: 'Create a new file (or fully overwrite a short one) with the COMPLETE content. For edits to existing files, prefer edit_file. Applied immediately; the user reviews it with Keep/Undo.', input_schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } }, { name: 'delete_file', description: 'Delete an EXISTING workspace file (e.g. removing a file during a refactor). Applied immediately; the user reviews it with Keep/Undo, and the per-turn checkpoint can restore it. To RENAME or move a file: write_file the new path, then delete_file the old one.', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } }, @@ -575,10 +600,13 @@ async function runTool(tu, ctx) { return hits || '(no matches)'; } if (tu.name === 'update_plan') { - const todos = Array.isArray(input.todos) ? input.todos.slice(0, 20) : []; - ctx.post({ type: 'plan', todos }); - const done = todos.filter((t) => t && t.status === 'done').length; - return 'Plan updated (' + done + '/' + todos.length + ' done).'; + // The list belongs to the conversation (extension.js hands the same object to every run), so an + // {"advance": true} in the next run moves the list the last run left. A host that passes none + // (a test, an older caller) gets one for this run. + if (!ctx.plan) { ctx.plan = { steps: null }; } + const r = turnPlan.planTool(input, ctx.plan); + if (r.changed) { ctx.post({ type: 'plan', steps: ctx.plan.steps }); } + return r.text; } if (tu.name === 'edit_file') { const abs = await resolveWorkspacePathAsync(input.path || '', { mustExist: true }); @@ -1182,19 +1210,30 @@ async function runAgent(ctx) { if (ctx.getNewDiagnostics) { try { diag = await ctx.getNewDiagnostics(); } catch (e) { dbg('verify.diagErr', { msg: String((e && e.message) || e) }); } } return { id, ok: !cmdProblem && diag.count === 0, cmdProblem, unrunnable, cmdTail, diag }; } + // The edit count at the last check that passed (or ran out of rounds). Finishing twice with nothing + // changed in between — wrap_up, then the closing "Done:" line — checks once, not twice. + let verifiedEdits = -1; /** Returns true if the loop should CONTINUE (verification failed, fix-feedback pushed). */ async function attemptVerify() { + const feedback = await verifyFeedback(); + if (feedback === null) { return false; } + messages.push({ role: 'user', content: feedback }); + return true; + } + /** Check the run's edits. Returns the problems to hand back to the model, or null when there is nothing to fix. */ + async function verifyFeedback() { const v = ctx.verify; - if (!v || !v.enabled) { return false; } - if (ctx.signal.aborted) { return false; } - if (!(ctx.editCount > 0)) { return false; } // no edits this run → nothing to verify + if (!v || !v.enabled) { return null; } + if (ctx.signal.aborted) { return null; } + if (!(ctx.editCount > 0)) { return null; } // no edits this run → nothing to verify + if (ctx.editCount === verifiedEdits) { return null; } // already checked, nothing changed since const cmd = shellOn ? (v.command || '').trim() : ''; const touchedCount = ctx.getTouchedUris ? ctx.getTouchedUris().length : 0; - if (!cmd && !touchedCount) { return false; } // nothing to check + if (!cmd && !touchedCount) { return null; } // nothing to check const max = Math.max(0, v.maxRounds || 0); ctx.post({ type: 'agentStatus', text: 'verifying edits…' }); const r = await runVerifyOnce(); - if (ctx.signal.aborted) { return false; } + if (ctx.signal.aborted) { return null; } const outcome = verifyOutcome(r.ok, feedbackUsed, max); ctx.post({ type: 'verifyDone', id: r.id, ok: r.ok, exhausted: outcome === 'exhausted', errorCount: r.diag.count, cmdFail: r.cmdProblem, unrunnable: r.unrunnable, hadCommand: !!cmd }); if (r.unrunnable) { @@ -1202,16 +1241,59 @@ async function runAgent(ctx) { dbg('verify.unrunnable', { tail: String(r.cmdTail || '').slice(0, 160) }); ctx.post({ type: 'agentTool', icon: 'warning', text: 'verify command couldn’t run — check the levelcode.ai.verify.command setting' }); } - if (outcome === 'pass') { dbg('verify.pass', { cmd: !!cmd, touched: touchedCount, unrunnable: r.unrunnable }); return false; } + if (outcome === 'pass') { dbg('verify.pass', { cmd: !!cmd, touched: touchedCount, unrunnable: r.unrunnable }); verifiedEdits = ctx.editCount; return null; } if (outcome === 'exhausted') { dbg('verify.exhausted', { errors: r.diag.count, cmdFail: r.cmdProblem }); ctx.post({ type: 'agentTool', icon: 'warning', text: 'verification still failing — left for your review' }); - return false; + verifiedEdits = ctx.editCount; + return null; } feedbackUsed++; dbg('verify.fail', { round: feedbackUsed, errors: r.diag.count, cmdFail: r.cmdProblem }); - messages.push({ role: 'user', content: formatVerifyFeedback({ cmdFail: r.cmdProblem, cmdTail: r.cmdTail, diag: r.diag }, feedbackUsed, max, cmd) }); - return true; + return formatVerifyFeedback({ cmdFail: r.cmdProblem, cmdTail: r.cmdTail, diag: r.diag }, feedbackUsed, max, cmd); + } + + // --- the hand-back and messages sent mid-run (docs/PLAN-PROGRESS.md) --------------------------- + // wrap_up is the model saying the work is done, so the check above runs THERE: a wrap-up whose edits + // fail it is not shown, and the problems go back as its result. One the chat shows with a question + // ends the run once this step's results are in — the user answers next, not the model. + let wrapped = null; + let endRun = false; + async function wrapUp(tu) { + const r = turnPlan.readWrapUp(tu.input); + dbg('wrapUp.call', { recapChars: r.wrap ? r.wrap.recap.length : 0, ask: !!(r.wrap && r.wrap.ask), actions: r.wrap ? r.wrap.actions.length : 0, blocked: !!(r.wrap && r.wrap.blocked), advice: r.advice.length, error: !!r.error }); + if (r.error || !r.wrap) { return r.error || 'ERROR: recap is required.'; } + const feedback = await verifyFeedback(); + if (ctx.signal.aborted) { return 'Cancelled by the user.'; } + if (feedback !== null) { + return 'Not shown: your changes did not pass verification.\n\n' + feedback + '\n\nFix the problems you introduced, then call wrap_up again.'; + } + wrapped = r.wrap; + if (ctx.plan && ctx.plan.steps && ctx.plan.steps.length) { + ctx.plan.steps = turnPlan.completeAll(ctx.plan.steps); + ctx.post({ type: 'plan', steps: ctx.plan.steps }); + } + ctx.post({ type: 'wrapUp', id: tu.id, recap: r.wrap.recap, ask: r.wrap.ask, actions: r.wrap.actions, blocked: r.wrap.blocked }); + endRun = turnPlan.showsBox(r.wrap); + return turnPlan.wrapUpResult(r.wrap, r.advice); + } + // "Send now" in the chat: the user's message reaches the run at its next step — after this step's tool + // results (in the same message, so every tool call keeps its result) or, after an answer with no tool + // call, as a message of its own that keeps the run going. Whatever is left when the run ends goes back. + function takeSteers() { + const got = typeof ctx.takeSteers === 'function' ? ctx.takeSteers() : []; + return (Array.isArray(got) ? got : []).filter((x) => x && typeof x.text === 'string' && x.text.trim()); + } + function steerText(steers) { + const one = steers.length === 1; + return '[The user sent ' + (one ? 'this message' : 'these messages, in order,') + ' while you were working. Read ' + + (one ? 'it' : 'them') + ' now: say in one line how ' + (one ? 'it changes' : 'they change') + ' what you are doing, update the task list if ' + + (one ? 'it does' : 'they do') + ', and carry on.]\n\n' + steers.map((x) => x.text.trim()).join('\n\n'); + } + function delivered(steers) { + dbg('steer.delivered', { count: steers.length }); + wrapped = null; endRun = false; // the user said more: a hand-back from before it no longer stands + ctx.post({ type: 'steersDelivered', ids: steers.map((x) => x.id) }); } try { @@ -1231,7 +1313,7 @@ async function runAgent(ctx) { dbg('tool.start', { name }); const verb = name === 'edit_file' || name === 'write_file' ? 'preparing edit (' + name + ')…' : name === 'delete_file' ? 'deleting a file…' - : name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' + : name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' : name === 'wrap_up' ? 'wrapping up…' : name === diagramTool.RENDER_DIAGRAM.name ? 'drawing a diagram…' : 'running ' + name + '…'; ctx.post({ type: 'agentStatus', text: verb }); // A diagram's place in the answer is held from the moment the model starts writing it. @@ -1334,6 +1416,10 @@ async function runAgent(ctx) { results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'ERROR: your tool arguments were cut off (truncated JSON). Retry with smaller input — for edits use edit_file with a short snippet.' }); continue; } + if (tu.name === 'wrap_up') { + results.push({ type: 'tool_result', tool_use_id: tu.id, content: await wrapUp(tu) }); + continue; + } dbg('tool.call', { name: tu.name, input: inputPreview(tu.input, !!(ctx.mcpRoutes && ctx.mcpRoutes.has(tu.name))) }); const out = await runTool(tu, ctx); dbg('tool.result', { name: tu.name, chars: String(out).length, error: String(out).startsWith('ERROR') }); @@ -1344,8 +1430,11 @@ async function runAgent(ctx) { ctx.post({ type: 'agentTool', icon: 'warning', text: 'response was cut off — making smaller edits' }); results.push({ type: 'text', text: 'Your previous turn was cut off (length limit). Make smaller, targeted edits (edit_file with short snippets); avoid huge tool inputs.' }); } + const steers = (cancelled || ctx.signal.aborted) ? [] : takeSteers(); + if (steers.length) { results.push({ type: 'text', text: steerText(steers) }); delivered(steers); } messages.push({ role: 'user', content: results }); if (cancelled || ctx.signal.aborted) { reason = 'stopped'; break; } + if (endRun) { reason = 'done'; break; } // the box holds the model's question: the user answers next if (turn.stop_reason === 'max_tokens' && nudges++ >= 3) { reason = 'limit'; break; } if (turn.stop_reason !== 'max_tokens') { nudges = 0; } continue; @@ -1380,7 +1469,11 @@ async function runAgent(ctx) { } reason = 'limit'; break; } - if (saysDone(text)) { + // A message sent mid-run that is still waiting: the run does not end on it — the model reads it next. + const steers = takeSteers(); + if (steers.length) { messages.push({ role: 'user', content: steerText(steers) }); delivered(steers); continue; } + // After wrap_up this is the closing answer, whatever words it uses: the run is over (and was checked there). + if (wrapped || saysDone(text)) { if (await attemptVerify()) { continue; } // verification failed → fix feedback pushed, keep going reason = 'done'; break; } @@ -1391,7 +1484,7 @@ async function runAgent(ctx) { || /\bnext[, ]/i.test(text); if (promisesAction && nudges++ < 3) { dbg('nudge', { n: nudges, stop: turn.stop_reason }); - messages.push({ role: 'user', content: 'Stop planning. You have read enough. Make your next edit_file (or write_file) call NOW — do not describe it, do it. If the goal is truly complete, reply with a one-line summary starting with "Done:".' }); + messages.push({ role: 'user', content: 'Stop planning. You have read enough. Make your next edit_file (or write_file) call NOW — do not describe it, do it. If the goal is truly complete, call wrap_up and reply with a one-line summary starting with "Done:".' }); continue; } dbg(promisesAction ? 'stall.giveup' : 'turn.complete', { nudges, promised: promisesAction }); @@ -1423,7 +1516,15 @@ async function runAgent(ctx) { if (ctx.diagrams) { try { for (const m of ctx.diagrams.endRun()) { ctx.post(m); } } catch (e) { dbg('diagram.settle.error', { msg: String((e && e.message) || e) }); } } - dbg('agent.done', { reason, steps: step - 1, edits: ctx.editCount || 0, costMicros: runCostMicros, creditsLeftMicros: ctx.credits != null ? ctx.credits : null }); + // A run that ended cleanly without wrap_up: a list done but for its last step, still in progress, is done. + if (reason === 'done' && !wrapped && ctx.plan && ctx.plan.steps) { + const steps = turnPlan.completeOnCleanEnd(ctx.plan.steps); + if (steps) { ctx.plan.steps = steps; ctx.post({ type: 'plan', steps }); } + } + // Messages sent with "Send now" that the run never reached go back to the chat's queue. + const left = takeSteers(); + if (left.length) { dbg('steer.returned', { count: left.length }); ctx.post({ type: 'steersReturned', ids: left.map((x) => x.id) }); } + dbg('agent.done', { reason, steps: step - 1, edits: ctx.editCount || 0, wrappedUp: !!wrapped, costMicros: runCostMicros, creditsLeftMicros: ctx.credits != null ? ctx.credits : null }); // [LevelCode] Gateway runs now carry real money: costMicros = what THIS run cost, credits = the // remaining balance — both RETAIL micro-$. BYOK runs send neither (null/0) and the bar omits them. ctx.post({ diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 2a8291e..06cc6ea 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -104,6 +104,16 @@ let review; /** Persistent agent transcript for the session (tool calls + results), so follow-up goals * remember prior runs. Reset by New Chat. */ let agentMessages = []; +/** The agent's task list for this conversation (docs/PLAN-PROGRESS.md). agent.js keeps `steps` current through + * update_plan and wrap_up, and every run of the conversation gets the same object, so an {"advance": true} + * moves the list an earlier run left. A new conversation, and a resumed one, start with none. */ +let agentPlan = { steps: null }; +/** Messages the user pressed "Send now" on while the agent works. The running loop takes them at its next + * step (ctx.takeSteers); what it never reaches it hands back to the chat's queue when the run ends. */ +let steerInbox = []; +/** The agent run going now, as a token only its own agentFlow holds: a late finally from a run that was torn + * down cannot mark a newer run as over. */ +let activeAgentRun = null; function post(msg) { if (activeWebview) { activeWebview.postMessage(msg); } } @@ -1638,6 +1648,7 @@ async function resumeSession(id) { if (!r) { post({ type: 'agentError', message: 'That session could not be opened — it may have been deleted.' }); return; } agentMessages = Array.isArray(r.messages) ? r.messages.slice() : []; // what the model resumes with + agentPlan = { steps: null }; steerInbox = []; // the list is drawn live, never replayed // Tier 2/3: the head didn't fit the window, so only the recent tail is in context. Prepend a factual // note so the model KNOWS earlier turns exist (a real head-summary is a later refinement) — the user // still sees the whole transcript in the replay. Ephemeral: never persisted (recordTurn appends only @@ -1728,6 +1739,7 @@ function resetConversationState() { clearQuestions(); conversation = []; agentMessages = []; + agentPlan = { steps: null }; steerInbox = []; diagrams.reset(); // the next conversation starts at d-1, owing nothing diagramsStubbed = false; checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack @@ -2200,6 +2212,19 @@ async function compactAgentMemory() { return { ok: true, beforeMsgTokens, afterMsgTokens }; } +/** + * "Send now" on a queued message: hand it to the running agent at its next step. With no run going there is + * nothing to hand it to, so it goes straight back to the chat's queue — which sends it as a normal message. + */ +function acceptSteer(msg) { + const id = typeof msg.id === 'string' ? msg.id : ''; + const text = typeof msg.text === 'string' ? msg.text.trim() : ''; + if (!id) { return; } + if (!activeAgentRun || !text) { post({ type: 'steersReturned', ids: [id] }); return; } + steerInbox.push({ id, text }); + dbg('steer.waiting', { chars: text.length, waiting: steerInbox.length }); +} + let lastAgentGoal = null; // remembered so the response bar's Retry can re-run it async function agentFlow(text, imageBlocks) { if (text && text.trim()) { lastAgentGoal = text; } @@ -2253,9 +2278,13 @@ async function agentFlow(text, imageBlocks) { } const diagBaseline = verifyCfg.enabled ? snapshotDiagnostics() : new Map(); dbg('verify.config', { enabled: verifyCfg.enabled, hasCommand: !!verifyCfg.command, maxRounds: verifyCfg.maxRounds, includeWarnings: verifyCfg.includeWarnings }); + const runToken = {}; + activeAgentRun = runToken; try { await runAgent({ messages: withImages(agentMessages), // persists across runs → the agent remembers the session + plan: agentPlan, // the conversation's task list (update_plan / wrap_up keep it) + takeSteers: () => steerInbox.splice(0), // "Send now" messages, taken at the loop's next step providerId: req.providerId, // Anthropic native, or an OpenAI-shaped provider via translation (P2) baseURL: req.baseURL, // for the custom / Ollama endpoints label: req.label, // route name for error attribution — "LevelCode Cloud" on the gateway, @@ -2329,6 +2358,7 @@ async function agentFlow(text, imageBlocks) { signal: abort.signal }); } finally { + if (activeAgentRun === runToken) { activeAgentRun = null; } // Everything below writes to state a teardown may already have replaced. runAgent holds // `agentMessages` BY REFERENCE, so a teardown that rebinds the global leaves this run pushing // into an orphaned array — and then `agentMessages.slice(sessTurnStart)` would slice the NEW, @@ -3118,6 +3148,7 @@ class ChatViewProvider { case 'notice': if (msg.text) { vscode.window.showWarningMessage(String(msg.text)); } break; case 'pickImages': await pickImages(); break; case 'attachImagePaths': await attachImagePaths(msg.paths); break; + case 'steer': acceptSteer(msg); break; 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; case 'stopCommand': { dbg('stopCommand', { id: msg.id }); const s = commandStops.get(msg.id); if (s) { try { s(); } catch (e) { /* gone */ } } break; } case 'approvalResponse': { diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js index c81bd49..cee4327 100644 --- a/extensions/levelcode-ai/mcpConfig.js +++ b/extensions/levelcode-ai/mcpConfig.js @@ -32,7 +32,7 @@ const crypto = require('crypto'); // The agent's built-in tools (agent.js TOOLS). An MCP tool may never shadow one of these. const BUILTIN_TOOL_NAMES = [ - 'list_files', 'read_file', 'search', 'update_plan', 'edit_file', 'write_file', + 'list_files', 'read_file', 'search', 'update_plan', 'wrap_up', 'edit_file', 'write_file', 'delete_file', 'run_command', 'read_command_output', 'ask_user', 'use_skill' ]; diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 926973f..42a9293 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -53,7 +53,7 @@ `calc(100% - 2 * --shell-x)` rather than a bare `width: 100%` + margin: without it these boxes would out-dent the transcript by the log's padding in every panel narrower than the cap, which is most sidebars. Overlays are `position: fixed` and deliberately excluded. */ - #bgTasksBar, #ctxBar, #planBar, #reviewBar, #workbar, #composer, #status { + #bgTasksBar, #ctxBar, #reviewBar, #workbar, #queueBar, #composer, #status { width: calc(100% - 2 * var(--shell-x)); max-width: var(--prose-max); margin-inline: auto; @@ -844,43 +844,91 @@ .bgt-stop:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; border-color: transparent; } .bgt-stop[disabled] { opacity: .6; cursor: default; } .bgt-stop svg { width: 1em; height: 1em; } - #planBar { flex: 0 0 auto; padding: 8px 8px 6px; max-height: 38vh; overflow-y: auto; scrollbar-gutter: stable; - background: var(--vscode-sideBar-background, var(--vscode-editor-background, #1e1e1e)); - border-top: 1px solid var(--border); box-shadow: 0 -8px 14px -8px rgba(0,0,0,.4); } - #planBar .plancard { margin: 0; } - .plancard { border: 1px solid var(--border); border-radius: 12px; margin: 6px 2px; overflow: hidden; background: var(--field-bg); } - .plancard .phead { display: flex; align-items: center; gap: 8px; padding: 9px 12px 8px; font-size: 12px; font-weight: 600; letter-spacing: .01em; cursor: pointer; user-select: none; } - .plancard .ptoggle { flex: 0 0 auto; display: inline-flex; color: var(--muted); opacity: .65; transition: transform .18s cubic-bezier(.4,0,.2,1); } - .plancard .ptoggle .ci { width: 13px; height: 13px; } - .plancard .phead:hover .ptoggle { opacity: 1; } - .plancard.collapsed .ptoggle { transform: rotate(-90deg); } - .plancard .phtitle { flex: 1 1 auto; min-width: 0; } - .plancard .pcount { flex: 0 0 auto; opacity: .75; font-weight: 500; font-variant-numeric: tabular-nums; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11px; padding: 1px 8px; border-radius: 999px; background: rgba(127,127,127,.14); } - .plancard.alldone .pcount { opacity: 1; color: var(--vscode-gitDecoration-addedResourceForeground, #4ec97a); background: color-mix(in srgb, var(--vscode-gitDecoration-addedResourceForeground, #4ec97a) 16%, transparent); } - /* Dismiss (×): reveals on header hover, and stays visible once the plan is done (its primary use). */ - .plancard .pdismiss { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; color: var(--muted); font-size: 14px; line-height: 1; opacity: 0; cursor: pointer; border-radius: 6px; transition: opacity .12s, background .12s; } - .plancard .phead:hover .pdismiss, .plancard.alldone .pdismiss { opacity: .55; } - /* :focus-visible so a keyboard-only user can see AND ring the control even on a not-yet-done plan - — it is opacity:0 by default (revealed on hover/done), so focus must reveal it too. */ - .plancard .pdismiss:hover, .plancard .pdismiss:focus-visible { opacity: 1; background: rgba(127,127,127,.16); } - .plancard .pdismiss:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; } - .plancard .pprog { height: 3px; margin: 0 12px 2px; border-radius: 3px; background: rgba(127,127,127,.16); overflow: hidden; } - .plancard .pprog .pfill { height: 100%; border-radius: 3px; background: var(--accent); transition: width .4s cubic-bezier(.4,0,.2,1); } - .plancard.alldone .pprog .pfill { background: var(--vscode-gitDecoration-addedResourceForeground, #4ec97a); } - .plancard .pitems { padding: 6px 0 7px; } - .plancard.collapsed .pprog, .plancard.collapsed .pitems { display: none; } - .plancard .pitem { display: flex; gap: 9px; align-items: center; padding: 4px 12px; font-size: 12.5px; line-height: 1.45; position: relative; } - .plancard .pitem .pic { flex: 0 0 auto; width: 15px; height: 15px; display: inline-flex; align-items: center; justify-content: center; } - .plancard .pitem .pic .ci { width: 13px; height: 13px; } - .plancard .pitem.done .pic { color: var(--vscode-gitDecoration-addedResourceForeground, #4ec97a); } - .plancard .pitem.done .ptitle { opacity: .5; text-decoration: line-through; text-decoration-thickness: 1px; } - .plancard .pitem.doing { background: color-mix(in srgb, var(--accent) 11%, transparent); } - .plancard .pitem.doing::before { content: ''; position: absolute; left: 0; top: 3px; bottom: 3px; width: 2px; border-radius: 2px; background: var(--accent); } - .plancard .pitem.doing .pic { color: var(--accent); } - .plancard .pitem.doing .pic .ci { animation: spin .9s linear infinite; } - .plancard .pitem.doing .ptitle { font-weight: 600; } - .plancard .pitem.todo .pic { color: var(--muted); opacity: .5; } - .plancard .pitem.todo .ptitle { opacity: .82; } + /* ── The turn box (docs/PLAN-PROGRESS.md) ─────────────────────────────────────────────────────────── + One card, always the LAST thing in the transcript: the agent's task list while a run works, and the + next-step box (recap, question, replies) when a run hands back with a question. Modelled on the Claude + desktop app: a hairline ring rather than a filled panel, finished steps in the secondary colour with no + strike-through, a spinner on the step in progress only while the agent is actually working. */ + .turnbox { border-radius: 10px; box-shadow: inset 0 0 0 1px var(--border); overflow: clip; + font-size: var(--prose-size); line-height: 1.5; animation: tbFade .3s cubic-bezier(.25,.1,.35,1); } + .turnbox[hidden], .tb-plan[hidden], .tb-next[hidden] { display: none; } + .tb-plan { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; } + .tb-note { margin: 0; font-size: 12px; color: var(--muted); } + .tb-note.tb-gap { padding-left: 22px; } + .tb-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } + .tb-step { display: flex; align-items: flex-start; gap: 6px; min-width: 0; } + .tb-step.completed { color: var(--muted); } + /* The icon sits on the FIRST line of a step that wraps: as tall as one line of the text beside it. */ + .tb-ic { flex: 0 0 16px; height: 1.5em; display: inline-flex; align-items: center; justify-content: center; } + .tb-ic .ci { width: 14px; height: 14px; } + .tb-step.pending .tb-ic { color: var(--muted); opacity: .6; } + .tb-spin .ci { animation: spin .9s linear infinite; } + .tb-txt { flex: 1; min-width: 0; overflow-wrap: anywhere; user-select: text; } + .tb-txt code { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: .9em; padding: 0 .3em; border-radius: 4px; background: rgba(127,127,127,.15); } + .tb-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + .tb-next { display: flex; flex-direction: column; gap: 10px; padding: 14px; animation: tbStep .3s ease-out both; } + .tb-line { display: flex; align-items: flex-start; gap: 8px; } + .tb-line p { flex: 1; min-width: 0; margin: 0; overflow-wrap: anywhere; user-select: text; } + .tb-recap { color: var(--muted); } + .tb-x { flex: 0 0 auto; width: 22px; height: 22px; margin: -1px -4px 0 0; display: inline-flex; align-items: center; justify-content: center; + padding: 0; border: 0; border-radius: 6px; background: none; color: var(--muted); cursor: pointer; } + .tb-x:hover { color: var(--vscode-foreground); background: rgba(127,127,127,.16); } + .tb-x .ci { width: 13px; height: 13px; } + .tb-acts { display: flex; flex-wrap: wrap; gap: 6px; } + .tb-act { font: inherit; font-size: 12.5px; line-height: 1.4; padding: 4px 12px; border-radius: 6px; cursor: pointer; max-width: 100%; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 1px solid var(--border); background: transparent; color: var(--vscode-foreground); } + .tb-act:hover { background: rgba(127,127,127,.14); } + .tb-act.primary { border-color: transparent; background: var(--vscode-button-background); color: var(--vscode-button-foreground); } + .tb-act.primary:hover { background: var(--vscode-button-hoverBackground, var(--vscode-button-background)); } + .tb-act:focus-visible, .tb-x:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; } + @keyframes tbFade { from { opacity: 0; } } + @keyframes tbStep { from { opacity: 0; transform: translateY(4px); } } + + /* ── Messages sent while the agent works wait here, just above the composer, and send themselves when + the run is over. One message is one row; several fold into "N messages queued · Next: …". ── */ + #queueBar { flex: 0 0 auto; margin-block: 0 6px; position: relative; } + #queueBar[hidden] { display: none; } + .qb { border-radius: 10px; background: var(--field-bg); box-shadow: inset 0 0 0 1px var(--border); font-size: 12.5px; line-height: 1.45; animation: tbStep .2s ease-out both; } + .qb.leaving { animation: qbLeave .15s ease-out forwards; } + .qb-head { display: flex; align-items: center; gap: 6px; width: 100%; min-height: 36px; padding: 0 6px 0 12px; box-sizing: border-box; + font: inherit; color: inherit; background: none; border: 0; border-radius: 10px; text-align: start; } + button.qb-head { cursor: pointer; } + button.qb-head:hover { background: rgba(127,127,127,.08); } + button.qb-head:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; } + .qb-count { flex: 0 0 auto; font-weight: 600; } + .qb-peek { flex: 1; min-width: 0; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .qb-btn { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; + border: 0; border-radius: 6px; background: none; color: var(--muted); cursor: pointer; } + .qb-btn:hover { color: var(--vscode-foreground); background: rgba(127,127,127,.16); } + .qb-btn:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; } + .qb-btn[disabled] { opacity: .45; cursor: default; background: none; } + .qb-btn .ci { width: 14px; height: 14px; } + .qb-btn.busy .ci { animation: spin .9s linear infinite; } + .qb-list { list-style: none; margin: 0; padding: 4px; max-height: min(40vh, 22rem); overflow-y: auto; overscroll-behavior: contain; border-top: 1px solid var(--border); } + .qb-item { position: relative; display: flex; align-items: flex-start; gap: 6px; padding: 5px 4px 5px 8px; border-radius: 6px; } + .qb-item + .qb-item { border-top: 1px solid var(--border); border-radius: 0; } + .qb-item.dragging { opacity: .6; background: rgba(127,127,127,.14); } + .qb-item.over { box-shadow: inset 0 2px 0 var(--accent); } + .qb-text { flex: 1; min-width: 0; padding-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; user-select: text; } + .qb-item.sending .qb-text, .qb.sending .qb-peek { color: var(--muted); } + .qb-tools { flex: 0 0 auto; display: inline-flex; gap: 2px; opacity: 0; } + .qb-item:hover .qb-tools, .qb-item:focus-within .qb-tools, .qb-item.menu-open .qb-tools { opacity: 1; } + @media (hover: none) { .qb-tools { opacity: 1; } } + .qb-grip { cursor: grab; } + .qb-menu { position: absolute; right: 4px; top: 30px; z-index: 6; min-width: 160px; padding: 4px; border-radius: 8px; + background: var(--vscode-menu-background, var(--vscode-editorWidget-background, var(--vscode-editor-background))); + color: var(--vscode-menu-foreground, var(--vscode-foreground)); border: 1px solid var(--vscode-menu-border, var(--border)); box-shadow: 0 4px 16px rgba(0,0,0,.28); } + .qb-menu[hidden] { display: none; } + .qb-menu button { display: block; width: 100%; padding: 4px 8px; border: 0; border-radius: 4px; background: none; color: inherit; font: inherit; text-align: start; cursor: pointer; } + .qb-menu button:hover, .qb-menu button:focus-visible { outline: none; background: var(--vscode-menu-selectionBackground, rgba(127,127,127,.2)); color: var(--vscode-menu-selectionForeground, inherit); } + .qb-menu button[disabled] { opacity: .45; cursor: default; background: none; } + .qb-menu .danger { color: var(--vscode-errorForeground, #f14c4c); } + .qb-menu hr { border: 0; border-top: 1px solid var(--border); margin: 4px 0; } + .qb-foot { display: flex; justify-content: flex-end; padding: 2px 8px 6px; } + .qb-clear { font: inherit; font-size: 11.5px; padding: 2px 6px; border: 0; border-radius: 4px; background: none; color: var(--vscode-errorForeground, #f14c4c); cursor: pointer; } + .qb-clear:hover { background: rgba(127,127,127,.14); } + @keyframes qbLeave { to { opacity: 0; transform: translateY(6px); } } + @media (prefers-reduced-motion: reduce) { .turnbox, .tb-next, .qb, .qb.leaving { animation: none; } .tb-spin .ci, .qb-btn.busy .ci { animation: none; } } @keyframes agentpulse { 0%,100% { opacity: .45; } 50% { opacity: 1; } } .diff { overflow: auto; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; line-height: 1.5; padding: 4px 0; } .diff .dl { white-space: pre-wrap; overflow-wrap: anywhere; padding: 0 10px 0 22px; text-indent: -16px; } @@ -1505,9 +1553,6 @@ - - -