fix: name each tool once, and stream the reasoning live (#18)
This commit was merged in pull request #18.
This commit is contained in:
+174
-12
@@ -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", () => {
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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: "" };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user