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
+174 -12
View File
@@ -233,7 +233,7 @@ describe("buildThreadMarkdown", () => {
expect(markdown).toContain("[https://example.com](https://example.com)");
});
it("lists tools on one quiet line, flagging failures", () => {
it("lists tools on one quiet line, each named once, flagging failures", () => {
const markdown = buildThreadMarkdown([
message({
role: "assistant",
@@ -245,11 +245,50 @@ describe("buildThreadMarkdown", () => {
}),
]);
expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (failed)_");
expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (1 failed)_");
// Italic, not a bold header, so it stays below the answer.
expect(markdown).not.toContain("**Tools");
});
it("names a tool once however many times it was called", () => {
// The reported case: a research turn listed the same two tools twenty times
// over three lines, which is a wall of text that says nothing a name does
// not. Names in order of first use, no repetition.
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_search", arguments: {}, result: "ok", isError: false },
{ name: "web_fetch", arguments: {}, result: "ok", isError: false },
{ name: "web_search", arguments: {}, result: "ok", isError: false },
{ name: "web_search", arguments: {}, result: "ok", isError: false },
],
}),
]);
expect(markdown).toContain("_Tools: `web_search`, `web_fetch`_");
expect(markdown.match(/web_search/g)).toHaveLength(1);
});
it("counts every failed call, not just the failed tools", () => {
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_search", arguments: {}, result: "ok", isError: false },
],
}),
]);
expect(markdown).toContain("_Tools: `web_fetch`, `web_search` (3 failed)_");
expect(markdown.match(/web_fetch/g)).toHaveLength(1);
});
it("omits tools and reasoning when the preferences are off", () => {
const messages = [
message({
@@ -307,13 +346,30 @@ describe("buildThreadMarkdown", () => {
}),
]);
expect(markdown).toContain("`web_search` (repeated, stopped)");
expect(markdown).not.toContain("(failed)");
expect(markdown).toContain("_Tools: `web_search` (stopped: the same call kept repeating)_");
expect(markdown).not.toContain("failed");
});
it("shows a failed call even when tool calls are hidden", () => {
// Hiding the line would leave the user with an answer they cannot account
// for, which is worse than a quiet italic line they did not ask for.
it("says a call failed and a call repeated, when both happened", () => {
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true },
],
}),
]);
expect(markdown).toContain(
"_Tools: `web_fetch`, `web_search` (1 failed, stopped: the same call kept repeating)_",
);
});
it("hides a failed call's name, but still says that a call failed", () => {
// Off means the list is off, not that a failure goes unmentioned: the answer
// alone would not account for a tool that did not work.
const markdown = buildThreadMarkdown(
[
message({
@@ -325,12 +381,32 @@ describe("buildThreadMarkdown", () => {
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("`web_fetch` (failed)");
expect(markdown).toContain("_1 tool call failed._");
expect(markdown).not.toContain("web_fetch");
expect(markdown).not.toContain("_Tools:");
});
it("shows a refused repeat even when tool calls are hidden", () => {
// This is the one the user most needs to see: a model going in circles is
// exactly the thing a hidden tool list would conceal.
it("counts the failures rather than naming every failed call when hidden", () => {
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_search", arguments: {}, result: "boom", isError: true },
],
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("_2 tool calls failed._");
});
it("says a turn was cut short by a repeat, without naming the call", () => {
// Still the one the user most needs to know about: a model going in circles
// is exactly the thing a hidden tool list would conceal.
const markdown = buildThreadMarkdown(
[
message({
@@ -344,7 +420,26 @@ describe("buildThreadMarkdown", () => {
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("`web_search` (repeated, stopped)");
expect(markdown).toContain("_a call kept repeating, so the turn was cut short._");
expect(markdown).not.toContain("web_search");
});
it("says both when a call failed and another kept repeating, with tools hidden", () => {
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "a",
toolCalls: [
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
{ name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true },
],
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("_1 tool call failed and a call kept repeating, so the turn was cut short._");
});
it("still hides the ordinary calls when tool calls are off", () => {
@@ -368,6 +463,73 @@ describe("buildThreadMarkdown", () => {
"Reasoning",
);
});
it("streams the reasoning live even when Show Reasoning is off", () => {
// While a turn is in flight the reasoning is the only sign of progress, so
// it is shown whatever the preference says. It is the live view only.
const markdown = buildThreadMarkdown(
[message({ role: "assistant", text: "", isStreaming: true, thinking: "weighing the options" })],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("weighing the options");
expect(markdown).toContain("Reasoning");
// The cursor trails the reasoning, because nothing else is arriving.
expect(markdown).toContain(`> weighing the options ${STREAMING_CURSOR}`);
// And the "Thinking…" stand-in is dropped, since the reasoning says it.
expect(markdown).not.toContain("Thinking…");
});
it("drops the reasoning when the turn ends and Show Reasoning is off", () => {
const markdown = buildThreadMarkdown(
[message({ role: "assistant", text: "the answer", thinking: "weighing the options" })],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain("the answer");
expect(markdown).not.toContain("weighing the options");
expect(markdown).not.toContain("Reasoning");
});
it("keeps the reasoning under the answer when the turn ends and Show Reasoning is on", () => {
const markdown = buildThreadMarkdown([
message({ role: "assistant", text: "the answer", thinking: "weighing the options" }),
]);
expect(markdown).toContain("the answer");
expect(markdown).toContain("weighing the options");
expect(markdown.indexOf("the answer")).toBeLessThan(markdown.indexOf("weighing the options"));
});
it("starts the live reasoning without a leading blank line", () => {
// Nothing else is on screen while the model thinks, so the reasoning is the
// first block, and a stray blank line above it would read as a gap.
const markdown = buildThreadMarkdown(
[message({ role: "assistant", text: "", isStreaming: true, thinking: "weighing the options" })],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown.startsWith("_Reasoning_")).toBe(true);
});
it("rides the answer, not the reasoning, once an answer has arrived", () => {
// The cursor rides the answer once there is one, even while reasoning from
// the round before it is still on screen.
const markdown = buildThreadMarkdown(
[
message({
role: "assistant",
text: "partial answer",
isStreaming: true,
thinking: "weighing the options",
}),
],
{ showToolCalls: false, showReasoning: false },
);
expect(markdown).toContain(`partial answer ${STREAMING_CURSOR}`);
expect(markdown).not.toContain(`weighing the options ${STREAMING_CURSOR}`);
});
});
describe("sessionToPlainText", () => {
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { EMPTY_TRACE, afterToolCall, withFinalTrace, withThinking } from "../lib/thinking";
describe("ThinkingTrace", () => {
it("starts empty", () => {
expect(EMPTY_TRACE).toEqual({ full: "", current: "" });
});
it("appends a delta to both the full trace and the current segment", () => {
const trace = withThinking(withThinking(EMPTY_TRACE, "let me "), "think");
expect(trace).toEqual({ full: "let me think", current: "let me think" });
});
it("keeps the full trace when a tool starts, and clears the current segment", () => {
// The reported case: the model thinks, calls a tool, and thinks again. The
// live view shows only the new thinking; the stored turn keeps both rounds.
const first = withThinking(EMPTY_TRACE, "I should search");
const cleared = afterToolCall(first);
expect(cleared.full).toBe("I should search");
expect(cleared.current).toBe("");
const second = withThinking(cleared, "now I know");
expect(second.full).toBe("I should searchnow I know");
expect(second.current).toBe("now I know");
});
it("does not mutate the trace it was given", () => {
const trace = withThinking(EMPTY_TRACE, "a");
afterToolCall(trace);
withThinking(trace, "b");
expect(trace).toEqual({ full: "a", current: "a" });
});
it("clears each round separately across several tool calls", () => {
let trace = EMPTY_TRACE;
for (const round of ["one", "two", "three"]) {
trace = withThinking(trace, round);
trace = afterToolCall(trace);
}
expect(trace.full).toBe("onetwothree");
expect(trace.current).toBe("");
});
it("adopts the authoritative trace the turn ended with", () => {
const trace = withFinalTrace("the whole thing");
expect(trace.full).toBe("the whole thing");
expect(trace.current).toBe("");
});
});
+24 -8
View File
@@ -7,6 +7,7 @@ import { isSelfHosted } from "../ollama";
import { OllamaError } from "../ollama";
import type { ResolvedPreferences } from "../preferences";
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
import { EMPTY_TRACE, afterToolCall, withFinalTrace, withThinking, type ThinkingTrace } from "../thinking";
import { toolsFor } from "../tools";
import type { PendingAttachment, Session, VisionSupport } from "../types";
import { errorMessage, isAbortError } from "../utils";
@@ -61,6 +62,13 @@ export interface UseChat {
session: Session;
isStreaming: boolean;
streamingResponse: string;
/**
* The reasoning to show right now: only the part since the last tool call.
*
* Deliberately not the whole trace. The full one is carried on the turn for
* storage; this is the live view, and it is cleared each time a tool starts so
* a long research turn does not stack every round's thinking on screen.
*/
streamingThinking: string;
/** Short activity line such as "Searching the web", shown while a turn is in flight. */
status?: string;
@@ -102,7 +110,7 @@ export function useChat(options: UseChatOptions): UseChat {
const abortRef = useRef<AbortController | null>(null);
const liveRef = useRef(false);
const responseRef = useRef("");
const thinkingRef = useRef("");
const thinkingRef = useRef<ThinkingTrace>(EMPTY_TRACE);
const statusRef = useRef<string | undefined>(undefined);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -131,14 +139,14 @@ export function useChat(options: UseChatOptions): UseChat {
flushTimerRef.current = null;
if (!liveRef.current) return;
setStreamingResponse(responseRef.current);
setStreamingThinking(thinkingRef.current);
setStreamingThinking(thinkingRef.current.current);
}, FLUSH_INTERVAL_MS);
}, []);
const flushNow = useCallback(() => {
clearFlushTimer();
setStreamingResponse(responseRef.current);
setStreamingThinking(thinkingRef.current);
setStreamingThinking(thinkingRef.current.current);
}, [clearFlushTimer]);
const setStatusValue = useCallback((value: string | undefined) => {
@@ -185,7 +193,7 @@ export function useChat(options: UseChatOptions): UseChat {
commit(started);
responseRef.current = "";
thinkingRef.current = "";
thinkingRef.current = EMPTY_TRACE;
setStreamingResponse("");
setStreamingThinking("");
setError(undefined);
@@ -242,11 +250,18 @@ export function useChat(options: UseChatOptions): UseChat {
scheduleFlush();
},
onThinkingDelta: (delta) => {
thinkingRef.current += delta;
thinkingRef.current = withThinking(thinkingRef.current, delta);
scheduleFlush();
},
onStatus: (value) => setStatusValue(value),
onToolStart: (_name, label, count) => setActivity({ label, count }),
onToolStart: (_name, label, count) => {
// The reasoning that produced this call is spent, so the live
// segment is cleared and the tool line takes its place. The full
// trace is untouched, because the stored turn keeps all of it.
thinkingRef.current = afterToolCall(thinkingRef.current);
setActivity({ label, count });
flushNow();
},
onToolCall: (record) => {
toolCalls = [...toolCalls, record];
},
@@ -254,7 +269,7 @@ export function useChat(options: UseChatOptions): UseChat {
});
responseRef.current = result.content;
thinkingRef.current = result.thinking;
thinkingRef.current = withFinalTrace(result.thinking);
toolCalls = result.toolCalls;
} catch (thrown) {
if (isAbortError(thrown)) {
@@ -268,7 +283,7 @@ export function useChat(options: UseChatOptions): UseChat {
const finished = {
...turn,
response: responseRef.current,
thinking: thinkingRef.current,
thinking: thinkingRef.current.full,
toolCalls,
error: failure,
};
@@ -306,6 +321,7 @@ export function useChat(options: UseChatOptions): UseChat {
setError(undefined);
setStreamingResponse("");
setStreamingThinking("");
thinkingRef.current = EMPTY_TRACE;
commit(next);
},
[commit, setStatusValue],
+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");
+53
View File
@@ -0,0 +1,53 @@
/**
* The live reasoning trace for a turn in flight.
*
* Ollama streams reasoning a round at a time: the model thinks, calls a tool,
* thinks again, and so on. Two views of that stream are needed at once, which is
* why this is two strings rather than one.
*
* - `full` is every token the model has thought this turn, across every round.
* It is what gets stored on the turn, so `Show Reasoning` can replay the whole
* trace when the conversation is reopened.
* - `current` is only the reasoning since the last tool call. The live transcript
* shows this one. A research turn that thinks before each of a dozen calls
* would otherwise pile every round's reasoning on screen at once, and the tool
* it just asked for would run underneath a wall of text that is no longer
* about anything.
*
* Kept pure and free of React so the reset is testable without mounting the hook.
*/
export interface ThinkingTrace {
/** Every token this turn, across every round. What gets stored on the turn. */
full: string;
/** Just the reasoning since the last tool call. What the live transcript shows. */
current: string;
}
export const EMPTY_TRACE: ThinkingTrace = { full: "", current: "" };
/** Appends one streamed reasoning delta to both views. */
export function withThinking(trace: ThinkingTrace, delta: string): ThinkingTrace {
return { full: trace.full + delta, current: trace.current + delta };
}
/**
* Clears the current view, leaving the full trace alone.
*
* Called when a tool starts: the reasoning that produced the call has served its
* purpose, and whatever the model thinks next is a new segment rather than a
* continuation of the last one.
*/
export function afterToolCall(trace: ThinkingTrace): ThinkingTrace {
return { full: trace.full, current: "" };
}
/**
* Adopts the authoritative trace the turn ended with.
*
* The agent owns the final value, so this is not a reconstruction: it is the
* one the stream produced, handed back whole.
*/
export function withFinalTrace(full: string): ThinkingTrace {
return { full, current: "" };
}
+8
View File
@@ -12,6 +12,14 @@ export interface LiveState {
isStreaming: boolean;
/** Partial answer for the turn currently in flight. */
response: string;
/**
* Reasoning to show for the turn in flight.
*
* Only the part since the last tool call, not the whole trace: the live
* transcript clears it each time a tool starts, so a research turn does not
* stack every round's thinking on screen. The stored turn keeps the whole
* trace separately.
*/
thinking: string;
/** Activity line, e.g. "Searching the web". Only meaningful mid-turn. */
status?: string;