fix: name each tool once, and stream the reasoning live (#18)

This commit was merged in pull request #18.
This commit is contained in:
2026-10-06 14:18:50 +00:00
parent ff99778e5d
commit 550a2bbefe
7 changed files with 426 additions and 58 deletions
+105 -34
View File
@@ -1,7 +1,7 @@
import { imageSource } from "./attachments";
import { repairCodeBlocks } from "./code";
import { describeActivity } from "./thread";
import type { Attachment, ChatTurn, ThreadMessage } from "./types";
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
/**
* Markdown rendering for the detail panel.
@@ -58,19 +58,62 @@ export function quote(text: string): string {
.join("\n");
}
/** One italic line, well below the weight of the answer it belongs to. */
function renderTools(message: ThreadMessage): string {
const names = message.toolCalls
.map((call) => {
const name = `\`${call.name}\``;
// A refused repeat did not fail, so it is not called a failure: it is the
// loop noticing it was going in circles, which is worth saying plainly.
if (call.repeated) return `${name} (repeated, stopped)`;
return call.isError ? `${name} (failed)` : name;
})
.join(", ");
/** How many calls failed for a reason other than being refused as a repeat. */
function countFailures(calls: ToolCallRecord[]): number {
// A refused repeat is an `isError` too, but it did not fail: it is the loop
// guard noticing a rut, which is a different thing and is said differently.
return calls.filter((call) => call.isError && !call.repeated).length;
}
return `_Tools: ${names}_`;
/** Whether the turn was cut short because a call kept repeating itself. */
function wasStopped(calls: ToolCallRecord[]): boolean {
return calls.some((call) => call.repeated);
}
/**
* One italic line naming the tools a turn used, each mentioned once.
*
* Listing every call in order said nothing a name does not, and on a research
* turn it said it twenty times: `_Tools: web_search, web_search, web_search,
* web_fetch (failed), web_search…_` ran to three lines of the same two words.
* Names in order of first use, then a count of what went wrong, which is the
* only part of that repetition that carries information.
*/
function renderTools(message: ThreadMessage): string {
const names: string[] = [];
for (const call of message.toolCalls) {
if (!names.includes(call.name)) names.push(call.name);
}
const listed = names.map((name) => `\`${name}\``).join(", ");
const failed = countFailures(message.toolCalls);
const notes: string[] = [];
if (failed > 0) notes.push(`${failed} failed`);
if (wasStopped(message.toolCalls)) notes.push("stopped: the same call kept repeating");
const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
return `_Tools: ${listed}${suffix}_`;
}
/**
* The one line shown when tool calls are hidden but something went wrong.
*
* Hiding the list is the user's choice, and a list of names is what they turned
* off. A turn where a call failed or was refused as a repeat is different: the
* answer alone would not account for it, so it is said in a sentence rather than
* dropped, and the names stay hidden.
*/
function renderToolFailureNote(message: ThreadMessage): string | undefined {
const failed = countFailures(message.toolCalls);
const stopped = wasStopped(message.toolCalls);
if (failed === 0 && !stopped) return undefined;
const parts: string[] = [];
if (failed > 0) parts.push(`${failed} tool call${failed === 1 ? "" : "s"} failed`);
if (stopped) parts.push("a call kept repeating, so the turn was cut short");
return `_${parts.join(" and ")}._`;
}
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
@@ -82,6 +125,19 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
return images ? `${asked}\n\n${images}` : asked;
}
const thinking = message.thinking.trim();
/**
* Live reasoning is shown while a turn is in flight whether or not the
* preference is on: it is the only sign of progress when a model is thinking
* rather than writing, and waiting on a spinner is worse than reading the
* thought. Once the turn ends it stays only if the user asked for it, so
* `Show Reasoning` still governs the finished transcript.
*/
const liveThinking = message.isStreaming && thinking.length > 0;
/** The stored trace, kept as an appendix under the answer it produced. */
const storedThinking = !message.isStreaming && options.showReasoning && thinking.length > 0;
const showThinking = liveThinking || storedThinking;
// While a tool is running there is no answer text yet, so the activity line
// stands in and the user can see what Lam is actually doing. It is shown
// whether or not tokens have arrived, because a tool that starts after the
@@ -103,38 +159,53 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
// printing the placeholder as well put the same sentence on screen
// twice: once as a note above the transcript and once as the stand-in.
undefined
: message.status
? `_${message.status}…_`
: "_Thinking…_";
: liveThinking
? // The reasoning below is already saying the model is working, so
// "Thinking…" on top of it would only repeat it.
undefined
: message.status
? `_${message.status}…_`
: "_Thinking…_";
// The cursor trails whichever of the two is written last, so it always sits at
// the end of the text arriving rather than on a line of its own.
const written = [activityLine, body].filter((line): line is string => Boolean(line));
const last = written.length - 1;
const sections = written.map((line, index) =>
message.isStreaming && index === last ? `${line} ${STREAMING_CURSOR}` : line,
);
const sections: string[] = [];
const activityIndex = activityLine ? sections.push(activityLine) - 1 : -1;
const bodyIndex = body ? sections.push(body) - 1 : -1;
// The one thing allowed to break the plain-text rule, because it means
// something went wrong and should not be skimmed past.
if (message.error) sections.push("", `**Error:** ${message.error}`);
/**
* Tool calls are a display preference, with one exception: a call that failed,
* or that was refused because it kept repeating, is always listed. Those are
* the two cases where hiding the line would leave the user with an answer they
* cannot account for, and a model stuck in a loop is exactly the thing they
* need to see.
* Tool calls are a display preference. When they are on, every tool is named
* once. When they are off, nothing is listed; a failure or a refused repeat
* still gets one quiet sentence, because those are the cases where hiding the
* line would leave the user with an answer they cannot account for.
*/
const showTools =
options.showToolCalls || message.toolCalls.some((call) => call.isError || call.repeated);
if (showTools && message.toolCalls.length > 0) {
if (options.showToolCalls && message.toolCalls.length > 0) {
sections.push("", renderTools(message));
} else if (!options.showToolCalls) {
const note = renderToolFailureNote(message);
if (note) sections.push("", note);
}
if (options.showReasoning && message.thinking.trim().length > 0) {
sections.push("", "_Reasoning_", "", quote(message.thinking.trim()));
if (showThinking) {
// Only live reasoning can be the first thing in a message, so the blank
// separator is conditional: otherwise the transcript opens on an empty line.
if (sections.length > 0) sections.push("");
sections.push("_Reasoning_", "", quote(thinking));
}
/**
* The cursor marks what is arriving. The answer wins when there is one, then
* the live reasoning, and only then the activity line: reasoning on screen
* means the model is thinking, which is newer than the tool it called to get
* there. It is placed before the quiet sections so it never lands on a tool
* list or an appendix.
*/
if (message.isStreaming) {
const reasoningIndex = showThinking && liveThinking ? sections.length - 1 : -1;
const target = bodyIndex >= 0 ? bodyIndex : reasoningIndex >= 0 ? reasoningIndex : activityIndex;
if (target >= 0) sections[target] = `${sections[target]} ${STREAMING_CURSOR}`;
}
return sections.join("\n");