@volter/supercode-frontend-vscode 0.1.62 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/extension.js CHANGED
@@ -11,7 +11,7 @@ import { readCredentialFile } from './credential.js';
11
11
  import { grantedProposals, PROPOSAL_EFFECTS, REQUESTED_PROPOSALS } from './proposals.js';
12
12
  import { observeActiveResponse, sessionStatus } from './native-session.js';
13
13
  import { RequestNotPendingError, VscodeFrontendSession } from './session.js';
14
- import { acceptedDecisions, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, HISTORY_MARKER_PROMPT, resolveParts, resolutionSentence, typedAnswer, } from './view.js';
14
+ import { acceptedDecisions, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, HISTORY_MARKER_PROMPT, PlanTracker, resolveParts, resolutionSentence, turnOutcome, typedAnswer, } from './view.js';
15
15
  const SESSION_SCHEME = 'supercode';
16
16
  /** The harnesses whose model and reasoning effort a new chat can set (the host passes them to the CLI). */
17
17
  const CONFIGURABLE_HARNESSES = ['claude-code', 'codex'];
@@ -204,6 +204,109 @@ class SupercodeFrontends {
204
204
  }
205
205
  }
206
206
  let nativeInTurnApprovalCards = false;
207
+ /** The output channel, for records that are not conversation (`unshown` parts). */
208
+ let outputLog;
209
+ /** Each such record once, however often a history is rebuilt from the same events. */
210
+ const loggedRecords = new Set();
211
+ const logRecord = (value) => {
212
+ if (loggedRecords.has(value))
213
+ return;
214
+ loggedRecords.add(value);
215
+ if (loggedRecords.size > 1000)
216
+ loggedRecords.delete(loggedRecords.values().next().value);
217
+ outputLog?.warn(value);
218
+ };
219
+ /** The requests each response stream drew a card for, so a resolution is said only beneath a card it drew. */
220
+ const drawnRequests = new WeakMap();
221
+ /** Requests answered through their own card in this window (`answerRequest`): such a card shows its answer itself. */
222
+ const answeredByCard = new Set();
223
+ const cardKey = (connectionId, requestId) => JSON.stringify([connectionId, requestId]);
224
+ const drew = (stream, requestId) => {
225
+ const drawn = drawnRequests.get(stream) ?? new Set();
226
+ drawnRequests.set(stream, drawn.add(requestId));
227
+ };
228
+ /**
229
+ * A harness's questions as VS Code's own carousel, inside the running turn; the person's answers are the
230
+ * elicitation's `{action:'accept', content:{<key>: label | [labels] | text}}`, and Skip is a decline. The answer
231
+ * takes the same door as a button's (`supercode.frontend.respond`).
232
+ */
233
+ function askQuestions(stream, part, pending, cancelled) {
234
+ const questions = part.questions.map(field => new vscode.ChatQuestion(field.key, field.options.length === 0 ? vscode.ChatQuestionType.Text
235
+ : field.multiSelect ? vscode.ChatQuestionType.MultiSelect : vscode.ChatQuestionType.SingleSelect, field.title, {
236
+ ...(field.message ? { message: field.message } : {}),
237
+ ...(field.options.length ? {
238
+ options: field.options.map((option, index) => ({ id: `${field.key}-${index}`, label: option.description ? `${option.label} — ${option.description}` : option.label, value: option.label })),
239
+ allowFreeformInput: true,
240
+ } : {}),
241
+ }));
242
+ const respond = (response) => vscode.commands.executeCommand('supercode.frontend.respond', { connectionId: part.connectionId, requestId: part.requestId, response, label: 'Answer' });
243
+ void Promise.resolve(stream.questionCarousel(questions, true)).then(answers => {
244
+ // (the editor also resolves an unanswered carousel when its response ends: a request answered by another door
245
+ // by then is left alone)
246
+ // (and a stopped turn: Stop already cancels its requests)
247
+ if ((pending && !pending(part.requestId)) || cancelled?.())
248
+ return;
249
+ const content = {};
250
+ for (const field of part.questions) {
251
+ const answer = carouselAnswer(answers?.[field.key]);
252
+ if (answer !== undefined)
253
+ content[field.key] = answer;
254
+ }
255
+ // Skip, and a carousel submitted with nothing chosen, decline: a question is never answered with nothing.
256
+ if (Object.keys(content).length === 0)
257
+ return respond({ kind: 'elicitation', request_id: part.requestId, action: 'decline' });
258
+ return respond({ kind: 'elicitation', request_id: part.requestId, action: 'accept', content });
259
+ }, () => undefined);
260
+ }
261
+ /** One carousel answer as the elicitation's value: the text, the selected label (or the person's own words), or the
262
+ * selected labels with any words of their own after them. */
263
+ function carouselAnswer(value) {
264
+ if (typeof value === 'string')
265
+ return value.trim() || undefined;
266
+ if (!value || typeof value !== 'object')
267
+ return undefined;
268
+ const answer = value;
269
+ const own = typeof answer.freeformValue === 'string' && answer.freeformValue.trim() ? answer.freeformValue.trim() : undefined;
270
+ if (Array.isArray(answer.selectedValues)) {
271
+ const picked = [...answer.selectedValues.filter((item) => typeof item === 'string'), ...(own ? [own] : [])];
272
+ return picked.length ? picked : undefined;
273
+ }
274
+ return typeof answer.selectedValue === 'string' && answer.selectedValue ? answer.selectedValue : own;
275
+ }
276
+ /** VS Code's own hook row: which hook, and what it said — a stop reason where it blocked, a warning otherwise. */
277
+ function hookPart(part) {
278
+ return part.blocked
279
+ ? new vscode.ChatResponseHookPart(part.hookType, part.reason || 'The hook gave no reason.')
280
+ : new vscode.ChatResponseHookPart(part.hookType, undefined, part.reason || 'The hook gave no reason.');
281
+ }
282
+ /**
283
+ * What a tool row shows inside it, the way VS Code's own rows do, live and in history alike: a plan call is the todo
284
+ * list as the call left it; a subagent call is the subagent row, with its answer once it has one (none for a subagent
285
+ * started in the background, whose call returns only a launch notice); a shell call is the terminal block; any other
286
+ * finished call its input and output. A subagent's own calls are grouped under the call they were made inside.
287
+ */
288
+ function setToolDetails(invocation, row) {
289
+ if (row.parentToolCallId)
290
+ invocation.subAgentInvocationId = row.parentToolCallId;
291
+ if (row.plan) {
292
+ invocation.toolSpecificData = { todoList: row.plan.map((item, index) => ({
293
+ id: index + 1,
294
+ title: item.title,
295
+ status: item.status === 'completed' ? vscode.ChatTodoStatus.Completed
296
+ : item.status === 'in-progress' ? vscode.ChatTodoStatus.InProgress : vscode.ChatTodoStatus.NotStarted,
297
+ })) };
298
+ }
299
+ else if (row.subagent) {
300
+ invocation.toolSpecificData = new vscode.ChatSubagentToolInvocationData(row.subagent.description, row.subagent.agentName, row.subagent.prompt, row.isComplete && !row.background ? row.resultText ?? '' : undefined);
301
+ }
302
+ else if (row.commandLine) {
303
+ invocation.toolSpecificData = { commandLine: { original: row.commandLine }, language: 'sh',
304
+ ...(row.isComplete ? { output: { text: row.resultText ?? '' } } : {}) };
305
+ }
306
+ else if (row.isComplete) {
307
+ invocation.toolSpecificData = { input: row.inputText ?? '', output: row.resultText ?? '' };
308
+ }
309
+ }
207
310
  function writeProjected(stream, projected, granted, state) {
208
311
  for (const candidate of projected) {
209
312
  const parts = resolveParts([candidate], granted);
@@ -219,7 +322,7 @@ function writeProjected(stream, projected, granted, state) {
219
322
  writePart(stream, fallback, state);
220
323
  }
221
324
  catch {
222
- stream.markdown(opaqueText(fallback));
325
+ // (a stream that refuses even the stable part has closed; a part's JSON is never conversation)
223
326
  }
224
327
  }
225
328
  }
@@ -244,6 +347,16 @@ function writePart(stream, part, state) {
244
347
  case 'thinking':
245
348
  stream.push(new vscode.ChatResponseThinkingProgressPart(part.value));
246
349
  return;
350
+ case 'hook':
351
+ stream.push(hookPart(part));
352
+ return;
353
+ case 'questions':
354
+ drew(stream, part.requestId);
355
+ if (state.carousel)
356
+ askQuestions(stream, part, state.pending, state.cancelled);
357
+ else
358
+ writePart(stream, part.fallback, state);
359
+ return;
247
360
  case 'reference': {
248
361
  // Only a path the runtime's own payload named, resolved against the
249
362
  // workspace this window already has. Nothing is created or fabricated.
@@ -254,9 +367,18 @@ function writePart(stream, part, state) {
254
367
  stream.markdown(`\`${part.path}\``);
255
368
  return;
256
369
  }
370
+ case 'resolution':
371
+ if (!drawnRequests.get(stream)?.has(part.requestId) || answeredByCard.has(cardKey(part.connectionId, part.requestId)))
372
+ return;
373
+ stream.markdown(`\n\n${part.value}\n\n`);
374
+ return;
375
+ case 'unshown':
376
+ logRecord(part.value);
377
+ return;
257
378
  case 'answers':
258
- if (part.approval && part.answers.length > 0 && state.quiet?.(part.answers[0].requestId))
379
+ if (part.approval && part.answers.length > 0 && state.quiet?.(part.requestId))
259
380
  return;
381
+ drew(stream, part.requestId);
260
382
  if (part.approval && nativeInTurnApprovalCards && part.answers.length > 0) {
261
383
  stream.confirmation(part.title, part.message, {
262
384
  kind: 'supercode.inTurnApproval', version: 1, answers: part.answers,
@@ -299,17 +421,22 @@ function writePart(stream, part, state) {
299
421
  invocation.isError = part.isError;
300
422
  if (part.inputText !== undefined)
301
423
  state.toolInputs.set(part.toolCallId, part.inputText);
302
- if (commandLine) {
303
- // VS Code's own terminal block: the command, and its output once it has run.
304
- invocation.toolSpecificData = { commandLine: { original: commandLine }, language: 'sh',
305
- ...(part.isComplete ? { output: { text: part.resultText ?? '' } } : {}) };
306
- }
307
- else if (part.isComplete) {
308
- invocation.toolSpecificData = {
309
- input: state.toolInputs.get(part.toolCallId) ?? '',
310
- output: part.resultText ?? '',
311
- };
424
+ if (part.plan || part.subagent || part.parentToolCallId || part.background) {
425
+ const kept = state.toolKinds.get(part.toolCallId) ?? {};
426
+ state.toolKinds.set(part.toolCallId, {
427
+ ...kept,
428
+ ...(part.plan ? { plan: part.plan } : {}),
429
+ ...(part.subagent ? { subagent: part.subagent } : {}),
430
+ ...(part.parentToolCallId ? { parentToolCallId: part.parentToolCallId } : {}),
431
+ ...(part.background ? { background: true } : {}),
432
+ });
312
433
  }
434
+ setToolDetails(invocation, {
435
+ ...part,
436
+ ...state.toolKinds.get(part.toolCallId),
437
+ commandLine,
438
+ inputText: state.toolInputs.get(part.toolCallId),
439
+ });
313
440
  stream.push(invocation);
314
441
  return;
315
442
  }
@@ -338,6 +465,7 @@ function unavailableMarkdown(reason) {
338
465
  }
339
466
  export async function activate(context) {
340
467
  const log = vscode.window.createOutputChannel('Volter Harness', { log: true });
468
+ outputLog = log;
341
469
  context.subscriptions.push(log);
342
470
  // The HOST decides which proposals this extension got; its own manifest only
343
471
  // asks. `context.extension.packageJSON` is the filtered extension
@@ -674,7 +802,6 @@ export async function activate(context) {
674
802
  if (state.activeSession !== connection.id)
675
803
  throw new Error('The conversation changed before its history could be saved.');
676
804
  connection.persistedHistory = state.history;
677
- connection.historyTruncated = state.historyTruncated;
678
805
  savedConnections.set(connection.id, connection);
679
806
  };
680
807
  const connectManaged = async (state = controls.state) => {
@@ -991,7 +1118,6 @@ export async function activate(context) {
991
1118
  throw new Error('The host did not restore the requested conversation.');
992
1119
  const connection = await connectManaged(state);
993
1120
  connection.persistedHistory = state.history;
994
- connection.historyTruncated = state.historyTruncated;
995
1121
  savedConnections.set(id, connection);
996
1122
  if (active !== connection)
997
1123
  controlsChanged.fire();
@@ -1251,22 +1377,6 @@ export async function activate(context) {
1251
1377
  // prevents the submit command from reaching session materialization at all.
1252
1378
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.sendRefusal', () => draftChoiceRefusal()
1253
1379
  ?? (controls?.state.setup?.ready === false ? 'Finish setting up your coding agent before sending. Your draft is saved.' : undefined)));
1254
- // A background task the chat named (sayTasks in runTurnInto), stopped through the harness's own control.
1255
- context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.stopBackgroundTask', async (connectionId, taskId) => {
1256
- const connection = frontends.get(connectionId);
1257
- const task = connection?.session.backgroundTasks.find(candidate => candidate.id === taskId);
1258
- if (!connection || !task) {
1259
- void vscode.window.showInformationMessage('That background task is no longer running.');
1260
- return;
1261
- }
1262
- try {
1263
- const stopped = await connection.session.stopTask(taskId);
1264
- void vscode.window.showInformationMessage(stopped ? `Stopped ${task.description || taskId}.` : `The harness did not stop ${task.description || taskId}.`);
1265
- }
1266
- catch (error) {
1267
- void vscode.window.showErrorMessage(`Could not stop ${task.description || taskId}: ${String(error?.message ?? error)}`);
1268
- }
1269
- }));
1270
1380
  // The host's native focus event is authoritative even when a cached model
1271
1381
  // skips content resolution. Untitled/non-harness focus never starts a runtime.
1272
1382
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.focusSession', (resource) => {
@@ -1451,8 +1561,8 @@ export async function activate(context) {
1451
1561
  // never both answer the same request (the second answer would be refused and noted as a failure).
1452
1562
  const answered = answeredRequests.get(connection.id) ?? new Set();
1453
1563
  answeredRequests.set(connection.id, answered);
1454
- // Requests whose card was not shown because this watch was going to answer them.
1455
- const unshown = new Set();
1564
+ // Requests whose card an automatic answer that failed or did not settle has drawn again: once each.
1565
+ const redrawn = new Set();
1456
1566
  const answers = (requestId) => {
1457
1567
  if (approval() !== 'autoApprove' || token.isCancellationRequested || !canAnswer(connection))
1458
1568
  return false;
@@ -1463,7 +1573,6 @@ export async function activate(context) {
1463
1573
  const decisions = acceptedDecisions(pending);
1464
1574
  if (decisions && !decisions.includes('allow'))
1465
1575
  return false;
1466
- unshown.add(requestId);
1467
1576
  return true;
1468
1577
  };
1469
1578
  const sweep = () => {
@@ -1476,45 +1585,40 @@ export async function activate(context) {
1476
1585
  if (decisions && !decisions.includes('allow'))
1477
1586
  continue;
1478
1587
  answered.add(pending.id);
1479
- // Its card is already in the stream (the event that raised it was written first), so say it was answered.
1480
1588
  const payload = pending.payload && typeof pending.payload === 'object' && !Array.isArray(pending.payload) ? pending.payload : {};
1481
1589
  const named = [payload.title, payload.tool, payload.tool_name].find((value) => typeof value === 'string');
1482
- // The answer can settle after the turn's response has closed, when writing to it throws: the note is then dropped.
1483
- const note = (text) => { try {
1484
- stream.markdown(text);
1485
- }
1486
- catch {
1487
- log.info(text.trim());
1488
- } };
1489
1590
  const label = opaqueText(named ?? `request #${pending.id}`, 200);
1490
- // A card this watch hid is shown when its automatic answer fails or does not settle: a typed reply cannot
1491
- // reach a running turn, its buttons can. With the response closed, the person is told an approval waits.
1591
+ // An automatic answer that fails or does not settle leaves the approval waiting: its card is drawn (again,
1592
+ // when it was already shown) as the one that still needs an answer, once: the settle timer and a later
1593
+ // failure of the same answer draw it one time between them. With the response closed, writing to it throws,
1594
+ // and the person is told an approval waits.
1492
1595
  const showCard = () => {
1493
- if (!unshown.delete(pending.id) || !connection.session.pendingRequests.has(pending.id))
1596
+ if (redrawn.has(pending.id) || !connection.session.pendingRequests.has(pending.id))
1494
1597
  return;
1598
+ redrawn.add(pending.id);
1495
1599
  try {
1496
- writeProjected(stream, projectPendingRequests([pending], {
1600
+ const state = { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map(), toolKinds: new Map() };
1601
+ for (const part of resolveParts(projectPendingRequests([pending], {
1497
1602
  connectionId: connection.id,
1498
- canRespond: connection.session.descriptor?.actions.respond !== false,
1499
- }), granted, { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map() });
1603
+ canRespond: canAnswer(connection),
1604
+ }), granted))
1605
+ writePart(stream, part, state);
1500
1606
  }
1501
1607
  catch {
1502
1608
  void vscode.window.showWarningMessage(`${connection.label}: ${label} is waiting for approval. Open its chat to answer it.`);
1503
1609
  }
1504
1610
  };
1611
+ // An approval answered automatically adds no line of its own (Code-OSS says "Auto approved" on the call's row,
1612
+ // which the extension API cannot), and a card it answered after the card was shown gets its resolution line.
1505
1613
  const unsettled = setTimeout(() => {
1506
- note(`\nAuto approve has had no answer for ${label} for ${AUTO_ANSWER_SETTLE_MS / 1000} s. Use the approval buttons to answer.\n`);
1614
+ log.warn(`Auto approve has had no answer for ${label} for ${AUTO_ANSWER_SETTLE_MS / 1000} s.`);
1507
1615
  showCard();
1508
1616
  }, AUTO_ANSWER_SETTLE_MS);
1509
1617
  void connection.session.respond({ kind: 'approval', request_id: pending.id, decision: 'allow' }).then(() => {
1510
1618
  clearTimeout(unsettled);
1511
- // A card that was never shown needs no note: its resolution line says it was allowed.
1512
- if (unshown.has(pending.id))
1513
- return;
1514
- note(`\n_auto-approved: ${label}_\n`);
1515
1619
  }, error => {
1516
1620
  clearTimeout(unsettled);
1517
- note(`\nApproval failed: ${String(error.message || error)}. Use the approval buttons to answer.\n`);
1621
+ log.warn(`Auto approve could not answer ${label}: ${String(error.message || error)}`);
1518
1622
  showCard();
1519
1623
  });
1520
1624
  }
@@ -1538,11 +1642,16 @@ export async function activate(context) {
1538
1642
  if (approval() === 'autoApprove' && !canAnswer(connection)) {
1539
1643
  throw new Error('This harness does not support automatic approval through Chat. Select Harness permissions.');
1540
1644
  }
1541
- const state = { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map() };
1645
+ const state = { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map(), toolKinds: new Map(),
1646
+ carousel: true, pending: (requestId) => connection.session.pendingRequests.has(requestId),
1647
+ cancelled: () => token.isCancellationRequested };
1542
1648
  const projection = {
1543
1649
  connectionId: connection.id,
1544
1650
  pendingRequest: (requestId) => connection.session.pendingRequests.get(requestId) ?? connection.session.resolvedRequests.get(requestId),
1545
- canRespond: connection.session.descriptor?.actions.respond !== false,
1651
+ // (as history: a control appears only where the runtime offers `respond` and this attachment may approve)
1652
+ canRespond: canAnswer(connection),
1653
+ plan: connection.plan ??= new PlanTracker(),
1654
+ notices: new Set(),
1546
1655
  };
1547
1656
  const cancellation = token.onCancellationRequested(() => {
1548
1657
  void stopConnection(connection).catch(() => {
@@ -1552,27 +1661,8 @@ export async function activate(context) {
1552
1661
  });
1553
1662
  const watch = watchApprovals(connection, stream, token, approval);
1554
1663
  state.quiet = watch.answers;
1555
- // The harness's own background tasks (a Monitor, a background shell): each event one reports is a turn of its own,
1556
- // which this message waits behind, and nothing here would show it. Said once per change of the set, each with a
1557
- // way to stop it.
1558
- let saidTasks = '';
1559
- const sayTasks = () => {
1560
- const tasks = connection.session.backgroundTasks;
1561
- const key = tasks.map(task => task.id).join(',');
1562
- if (key === saidTasks)
1563
- return;
1564
- saidTasks = key;
1565
- if (!tasks.length) {
1566
- stream.markdown('\n\n_No background task is running any more._\n\n');
1567
- return;
1568
- }
1569
- stream.markdown(`\n\n_${tasks.length === 1 ? 'A background task is' : `${tasks.length} background tasks are`} still running in this session. The harness answers each of its events as a turn of its own, so this message can wait behind them:_\n\n`);
1570
- for (const task of tasks) {
1571
- stream.button({ command: 'supercode.frontend.stopBackgroundTask', title: `Stop ${opaqueText(task.description || task.id, 80)}`, arguments: [connection.id, task.id] });
1572
- }
1573
- };
1574
- sayTasks();
1575
- const unwatchTasks = connection.session.onChange(sayTasks);
1664
+ // How the turn ended when it did not succeed: the response's own error, as VS Code's agent host ends one.
1665
+ let outcome;
1576
1666
  let completed = false;
1577
1667
  let turnStarted = false;
1578
1668
  try {
@@ -1581,14 +1671,18 @@ export async function activate(context) {
1581
1671
  // events arrive; no save refusal submits input again or aborts its turn.
1582
1672
  throwIfCancelled();
1583
1673
  await connection.session.runTurn(prompt, (event) => {
1584
- // A turn the person stopped is marked stopped by VS Code itself; only an interruption from elsewhere is said.
1674
+ // A turn the person stopped is marked stopped by VS Code itself; any other end is this response's result.
1585
1675
  if (event.kind === 'turn_interrupted' && token.isCancellationRequested)
1586
1676
  return;
1587
1677
  // Native submit publishes its initial user echo before `turn_started`.
1588
1678
  // Subsequent input belongs in the response while this turn is visible.
1589
1679
  if (event.kind === 'turn_started')
1590
1680
  turnStarted = true;
1591
- writeProjected(stream, projectEvent(event, { ...projection, liveUserMessages: turnStarted }), granted, state);
1681
+ const ended = turnOutcome(event);
1682
+ if (ended)
1683
+ outcome = ended;
1684
+ else
1685
+ writeProjected(stream, projectEvent(event, { ...projection, liveUserMessages: turnStarted }), granted, state);
1592
1686
  watch.sweep();
1593
1687
  rememberIdentity(connection);
1594
1688
  }, { throwIfCancelled, onSubmitted: () => {
@@ -1597,7 +1691,6 @@ export async function activate(context) {
1597
1691
  completed = true;
1598
1692
  }
1599
1693
  finally {
1600
- unwatchTasks();
1601
1694
  watch.dispose();
1602
1695
  cancellation.dispose();
1603
1696
  await finishIdentitySave(connection);
@@ -1608,7 +1701,14 @@ export async function activate(context) {
1608
1701
  const effective = active ? controls?.state.effective : undefined;
1609
1702
  const ran = effective?.modelName || (active ? controls?.state.actualModel : undefined) || connection.session.descriptor?.model;
1610
1703
  const effort = effective?.effort || controls?.state.sessions.find(session => session.id === connection.id)?.selection.effort;
1611
- return completed && ran && !token.isCancellationRequested ? `${opaqueText(ran, 120)}${effort ? ` · ${opaqueText(effort, 24)}` : ''}` : undefined;
1704
+ const details = completed && ran && !token.isCancellationRequested ? `${opaqueText(ran, 120)}${effort ? ` · ${opaqueText(effort, 24)}` : ''}` : undefined;
1705
+ // (an interruption is the expected, warning-level error: `isExpectedError` and `level` are
1706
+ // `chatParticipantPrivate`'s, which the ext host checks for `isExpectedError`, so a host without it gets the message)
1707
+ const errorDetails = outcome && (outcome.interrupted && granted.has('chatParticipantPrivate')
1708
+ ? { message: outcome.message, isExpectedError: true, level: 1 /* ChatErrorLevel.Warning */ }
1709
+ : { message: outcome.message });
1710
+ // The model the turn ran on, in VS Code's own response footer rather than as a line of the conversation.
1711
+ return { ...(details ? { details } : {}), ...(errorDetails ? { errorDetails } : {}) };
1612
1712
  };
1613
1713
  const handler = async (request, chatContext, stream, token) => {
1614
1714
  await applying;
@@ -1621,9 +1721,10 @@ export async function activate(context) {
1621
1721
  // thrown as is: the workbench writes a handler's error into the chat
1622
1722
  throw new Error(`${refusal} Nothing was sent; send it again in a new chat.`);
1623
1723
  }
1724
+ // A request the handler cannot run is refused as VS Code refuses one: the request's own error (thrown, which
1725
+ // the workbench writes as the response's errorDetails), not an answer in the transcript.
1624
1726
  if (controls?.state.setup && !controls.state.setup.ready) {
1625
- stream.markdown('Sign in before sending a message. Use **New Chat: Choose Agent** to get started.');
1626
- return {};
1727
+ throw new Error('Sign in before sending a message. Use **New Chat: Choose Agent** to get started.');
1627
1728
  }
1628
1729
  const connection = requestedResource
1629
1730
  ? await connectionFor(requestedResource)
@@ -1635,10 +1736,8 @@ export async function activate(context) {
1635
1736
  throw new Error('This conversation has fixed model settings. Start a new chat to choose different settings; no message was sent.');
1636
1737
  }
1637
1738
  }
1638
- if (!connection) {
1639
- stream.markdown(unavailableMarkdown(frontends.unavailable));
1640
- return {};
1641
- }
1739
+ if (!connection)
1740
+ throw new Error(unavailableMarkdown(frontends.unavailable));
1642
1741
  const pending = pendingRequestOf(connection);
1643
1742
  // A message is never taken as an approval: one sent while an approval waits would otherwise allow the tool and
1644
1743
  // never reach the agent. It is refused, with what to do, like a send during any running turn.
@@ -1670,9 +1769,7 @@ export async function activate(context) {
1670
1769
  attached.push({ name: uri.toString(), text: document.getText(location?.range) });
1671
1770
  }
1672
1771
  const prompt = withTextContext(request.prompt, attached);
1673
- const details = await runTurnInto(connection, prompt, stream, token, approval);
1674
- // The model the turn ran on, in VS Code's own response footer rather than as a line of the conversation.
1675
- return details ? { details } : {};
1772
+ return runTurnInto(connection, prompt, stream, token, approval);
1676
1773
  };
1677
1774
  const participants = [];
1678
1775
  for (const id of granted.has('defaultChatParticipant') ? [PARTICIPANT_ID, DEFAULT_PARTICIPANT_ID] : [PARTICIPANT_ID]) {
@@ -1792,7 +1889,17 @@ export async function activate(context) {
1792
1889
  return { status: 'cancelled' };
1793
1890
  response = { ...response, content };
1794
1891
  }
1795
- await connection.session.respond(response);
1892
+ const key = cardKey(connection.id, answer.requestId);
1893
+ answeredByCard.add(key);
1894
+ if (answeredByCard.size > 500)
1895
+ answeredByCard.delete(answeredByCard.values().next().value);
1896
+ try {
1897
+ await connection.session.respond(response);
1898
+ }
1899
+ catch (error) {
1900
+ answeredByCard.delete(key);
1901
+ throw error;
1902
+ }
1796
1903
  void vscode.window.setStatusBarMessage(`Volter Harness: ${resolutionSentence(response, request, answer.requestId)}`, 4000);
1797
1904
  return { status: 'responded' };
1798
1905
  }
@@ -2124,7 +2231,7 @@ function oneRowPerToolCall(parts) {
2124
2231
  * between tools is markdown, consecutive text joined into one part. A turn
2125
2232
  * looks the same however late the Chat was opened.
2126
2233
  */
2127
- function historyParts(parts) {
2234
+ function historyParts(parts, granted) {
2128
2235
  const out = [];
2129
2236
  let text = [];
2130
2237
  const flush = () => {
@@ -2143,15 +2250,21 @@ function historyParts(parts) {
2143
2250
  invocation.isComplete = part.isComplete;
2144
2251
  if (part.isError !== undefined)
2145
2252
  invocation.isError = part.isError;
2146
- if (part.commandLine) {
2147
- invocation.toolSpecificData = { commandLine: { original: part.commandLine }, language: 'sh', output: { text: part.resultText ?? '' } };
2148
- }
2149
- else if (part.isComplete) {
2150
- invocation.toolSpecificData = { input: part.inputText ?? '', output: part.resultText ?? '' };
2151
- }
2253
+ setToolDetails(invocation, part);
2152
2254
  out.push(invocation);
2153
2255
  continue;
2154
2256
  }
2257
+ if (part.part === 'hook') {
2258
+ flush();
2259
+ out.push(hookPart(part));
2260
+ continue;
2261
+ }
2262
+ // A warning and the agent's reasoning are VS Code's own parts in history too, where the host granted them.
2263
+ if ((part.part === 'warning' || part.part === 'thinking') && granted.has('chatParticipantAdditions')) {
2264
+ flush();
2265
+ out.push(part.part === 'warning' ? new vscode.ChatResponseWarningPart(part.value) : new vscode.ChatResponseThinkingProgressPart(part.value));
2266
+ continue;
2267
+ }
2155
2268
  const value = historyText(part);
2156
2269
  if (value)
2157
2270
  text.push(value);
@@ -2159,6 +2272,11 @@ function historyParts(parts) {
2159
2272
  flush();
2160
2273
  return out.length ? out : [new vscode.ChatResponseMarkdownPart('')];
2161
2274
  }
2275
+ /** A conversation that failed, as a history response shows it: VS Code's warning part where the host granted it (the
2276
+ * extension API does not carry a history response's own error), else the words alone. */
2277
+ function failurePart(message, granted) {
2278
+ return granted.has('chatParticipantAdditions') ? new vscode.ChatResponseWarningPart(message) : new vscode.ChatResponseMarkdownPart(message);
2279
+ }
2162
2280
  /**
2163
2281
  * One text part as the markdown a HISTORY turn carries (tool calls are drawn
2164
2282
  * as tool rows by `historyParts`).
@@ -2172,6 +2290,9 @@ function historyText(part) {
2172
2290
  return `> ⚠ ${part.value}`;
2173
2291
  case 'thinking':
2174
2292
  return `> ${part.value}`;
2293
+ case 'hook':
2294
+ // Drawn as VS Code's own hook row by `historyParts`.
2295
+ return '';
2175
2296
  case 'codeblock':
2176
2297
  return part.value;
2177
2298
  case 'reference':
@@ -2181,6 +2302,14 @@ function historyText(part) {
2181
2302
  return '';
2182
2303
  case 'answers':
2183
2304
  return `**${part.title}**\n\n${part.message}`;
2305
+ case 'questions':
2306
+ return `**${part.fallback.title}**\n\n${part.fallback.message}`;
2307
+ case 'resolution':
2308
+ // (a saved card is words, which nothing retires, so its answer is said beneath it)
2309
+ return part.value;
2310
+ case 'unshown':
2311
+ // (`projectReplayTurns` hands these to `unshown`; none reaches a history turn)
2312
+ return '';
2184
2313
  }
2185
2314
  }
2186
2315
  function registerSessions(context, frontends, participant, granted, handler, historyParticipant, controls) {
@@ -2298,7 +2427,7 @@ function registerSessions(context, frontends, participant, granted, handler, his
2298
2427
  return {
2299
2428
  history: [
2300
2429
  new turns.ChatRequestTurn(HISTORY_MARKER_PROMPT, undefined, [], 'supercode', []),
2301
- new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart(`**Conversation recovery failed**\n\n${message}`)], {}, 'supercode'),
2430
+ new turns.ChatResponseTurn([failurePart(message, granted)], {}, 'supercode'),
2302
2431
  ],
2303
2432
  requestHandler: boundHandler,
2304
2433
  };
@@ -2366,6 +2495,10 @@ function registerSessions(context, frontends, participant, granted, handler, his
2366
2495
  pendingToolNames.add(name);
2367
2496
  }
2368
2497
  }
2498
+ // The task list is replayed with the history it was built in, and live turns carry on from it; so is the
2499
+ // set of background tasks whose finishing has been drawn.
2500
+ const plan = new PlanTracker();
2501
+ const notices = new Set();
2369
2502
  const projected = projectReplayTurns(replay.filter(event => {
2370
2503
  if (event.kind !== 'request')
2371
2504
  return true;
@@ -2375,7 +2508,11 @@ function registerSessions(context, frontends, participant, granted, handler, his
2375
2508
  connectionId: connection.id,
2376
2509
  pendingRequest: (id) => connection.session.pendingRequests.get(id) ?? connection.session.resolvedRequests.get(id),
2377
2510
  canRespond: Boolean(connection.session.descriptor?.actions.respond && connection.session.permissions.has('approve')),
2378
- }, projectHistory(canonicalHistory, granted, pendingToolNames));
2511
+ plan,
2512
+ notices,
2513
+ unshown: logRecord,
2514
+ }, projectHistory(canonicalHistory, granted, pendingToolNames, plan));
2515
+ connection.plan = plan;
2379
2516
  const live = connection.session.descriptor?.turn_state === 'busy' || connection.session.pendingRequests.size > 0;
2380
2517
  controls?.rememberIdentity(connection);
2381
2518
  if (!live)
@@ -2401,26 +2538,26 @@ function registerSessions(context, frontends, participant, granted, handler, his
2401
2538
  // responses can be stale; their command buttons are disabled.
2402
2539
  const last = projected.at(-1);
2403
2540
  if (last?.role === 'assistant') {
2404
- activeParts.push(...(projected.pop()?.parts ?? []).map(part => part.part === 'answers'
2405
- ? { part: 'markdown', value: `**${part.title}**\n\n${part.message}` }
2406
- : part));
2541
+ activeParts.push(...(projected.pop()?.parts ?? []).map(part => part.part === 'answers' || part.part === 'questions'
2542
+ ? { part: 'markdown', value: `**${(part.part === 'questions' ? part.fallback : part).title}**\n\n${(part.part === 'questions' ? part.fallback : part).message}` }
2543
+ // (its card is words here, so its answer is said beneath it)
2544
+ : part.part === 'resolution' ? { part: 'markdown', value: `\n\n${part.value}\n\n` }
2545
+ : part));
2407
2546
  }
2408
2547
  }
2409
- if (usingSavedHistory && connection.historyTruncated)
2410
- history.push(new turns.ChatRequestTurn('[Earlier conversation history]', undefined, [], authorId, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart('_Showing the latest 120 saved messages. Earlier context remains in the harness session._')], {}, authorId));
2411
- if (connection.session.historyEventsDropped > 0)
2412
- history.push(new turns.ChatRequestTurn(HISTORY_MARKER_PROMPT, undefined, [], authorId, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart(`_${connection.session.historyEventsDropped} earlier runtime events are outside this display snapshot._`)], {}, authorId));
2548
+ // (saved history and retained events past their bounds are left out without a word, as VS Code's own agent
2549
+ // host shows a transcript its harness truncated)
2413
2550
  for (const turn of projected) {
2414
2551
  if (turn.role === 'user') {
2415
2552
  history.push(new turns.ChatRequestTurn(turn.prompt ?? '', undefined, [], authorId, []));
2416
2553
  continue;
2417
2554
  }
2418
- history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, authorId));
2555
+ history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? []), granted), {}, authorId));
2419
2556
  }
2420
2557
  if (connection.session.failure) {
2421
2558
  // An explicit runtime marker pairs this notice without presenting
2422
2559
  // it as a person's prompt. The native busy state stays unchanged.
2423
- history.push(new turns.ChatRequestTurn(HISTORY_MARKER_PROMPT, undefined, [], authorId, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart(`**Conversation recovery failed**\n\n${opaqueText(connection.session.failure.message, 2048)}`)], {}, authorId));
2560
+ history.push(new turns.ChatRequestTurn(HISTORY_MARKER_PROMPT, undefined, [], authorId, []), new turns.ChatResponseTurn([failurePart(opaqueText(connection.session.failure.message, 2048), granted)], {}, authorId));
2424
2561
  }
2425
2562
  const renderedCursor = connection.session.cursor;
2426
2563
  const pendingAtCursor = [...connection.session.pendingRequests.values()];
@@ -2438,7 +2575,7 @@ function registerSessions(context, frontends, participant, granted, handler, his
2438
2575
  // already waiting and later ones; Ask leaves them for the person, as before.
2439
2576
  const watch = controls && !controls.attached(connection) ? controls.watchApprovals(connection, stream, token) : undefined;
2440
2577
  try {
2441
- const state = { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map(), quiet: watch?.answers };
2578
+ const state = { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map(), toolKinds: new Map(), quiet: watch?.answers };
2442
2579
  for (const part of oneRowPerToolCall(activeParts))
2443
2580
  writePart(stream, part, state);
2444
2581
  writeProjected(stream, projectPendingRequests(pendingAtCursor.filter(request => connection.session.pendingRequests.has(request.id)), {
@@ -2451,6 +2588,8 @@ function registerSessions(context, frontends, participant, granted, handler, his
2451
2588
  writeProjected(stream, projectEvent(event, {
2452
2589
  connectionId: connection.id,
2453
2590
  liveUserMessages: true,
2591
+ plan,
2592
+ notices,
2454
2593
  pendingRequest: (id) => connection.session.pendingRequests.get(id) ?? connection.session.resolvedRequests.get(id),
2455
2594
  canRespond: Boolean(connection.session.descriptor?.actions.respond && connection.session.permissions.has('approve')),
2456
2595
  }), granted, state);
@@ -2461,7 +2600,13 @@ function registerSessions(context, frontends, participant, granted, handler, his
2461
2600
  catch (error) {
2462
2601
  if (token.isCancellationRequested || error instanceof vscode.CancellationError)
2463
2602
  throw error;
2464
- stream.markdown(`\n\n**Conversation stream failed**\n\n${opaqueText(String(error?.message ?? error), 2048)}\n\n`);
2603
+ // The response's own error is what VS Code shows, and a re-joined response cannot set one (the
2604
+ // ext host ignores what `activeResponseCallback` returns), so it is the warning part.
2605
+ const message = opaqueText(String(error?.message ?? error), 2048);
2606
+ if (granted.has('chatParticipantAdditions'))
2607
+ stream.warning(message);
2608
+ else
2609
+ stream.markdown(`\n\n${message}\n\n`);
2465
2610
  }
2466
2611
  finally {
2467
2612
  connection.viewReady = connection.session.descriptor?.turn_state !== 'busy' && !connection.session.pendingRequests.size;