feat(sessions): show thinking level in assistant chat bubble metadata
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s

Attribute the thinking level in effect to each assistant message: from
thinking_level_change branch entries for history, from the live session
level for streamed message.end events and join-time stream snapshots.
The chat metadata row renders it after the model; thinking "off" stays
hidden so non-reasoning bubbles are unchanged.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-30 12:55:11 +02:00
parent c2b7cce2d3
commit c09b67d15a
8 changed files with 146 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show the thinking level in assistant chat bubble metadata next to the model and timestamp, for both history and live messages. Bubbles from turns with thinking off stay unchanged.
+6
View File
@@ -113,6 +113,12 @@ describe("chat message normalization", () => {
]);
});
it("carries the thinking level into assistant message metadata", () => {
expect(normalizeMessage({ role: "assistant", content: [{ type: "text", text: "hi" }], provider: "openai", model: "gpt-4.1", timestamp: "2026-05-09T12:00:00.000Z", thinkingLevel: "max" })).toEqual([
{ role: "assistant", parts: [{ type: "text", text: "hi" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" }, thinkingLevel: "max" } },
]);
});
it("shows assistant model errors as system chat messages", () => {
expect(normalizeMessage({ role: "assistant", content: [], stopReason: "error", errorMessage: "429 rate limit", timestamp: "2026-05-09T12:00:00.000Z", provider: "openai", model: "gpt-4.1" })).toEqual([
{ role: "system", parts: [{ type: "text", text: "Model response failed: 429 rate limit" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" } } },
+7 -2
View File
@@ -109,8 +109,13 @@ function normalizeSource(message: unknown): ChatLine["source"] | undefined {
function normalizeMeta(message: unknown): ChatLine["meta"] | undefined {
const timestamp = normalizeTimestamp(getProperty(message, "timestamp"));
const model = normalizeModel(message);
if (timestamp === undefined && model === undefined) return undefined;
return { ...(timestamp === undefined ? {} : { timestamp }), ...(model === undefined ? {} : { model }) };
const thinkingLevel = getString(message, "thinkingLevel");
if (timestamp === undefined && model === undefined && (thinkingLevel === undefined || thinkingLevel === "")) return undefined;
return {
...(timestamp === undefined ? {} : { timestamp }),
...(model === undefined ? {} : { model }),
...(thinkingLevel === undefined || thinkingLevel === "" ? {} : { thinkingLevel }),
};
}
function normalizeTimestamp(value: unknown): string | undefined {
@@ -259,6 +259,17 @@ describe("chatMessageMetadataLabel", () => {
meta: { timestamp, model: { provider: "provider", id: "model" } },
})).toBe(`${formattedTimestamp} · provider/model`);
});
it("appends the thinking level after the model when present", () => {
const timestamp = "2026-07-10T19:15:30.000Z";
const formattedTimestamp = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(timestamp));
expect(chatMessageMetadataLabel({
role: "assistant",
parts: [],
meta: { timestamp, model: { provider: "provider", id: "model" }, thinkingLevel: "high" },
})).toBe(`${formattedTimestamp} · provider/model · high`);
});
});
describe("chat event-group content seams", () => {
+1 -1
View File
@@ -163,7 +163,7 @@ export function chatMessageMetadataLabel(message: ChatLine): string {
const timestamp = message.meta?.timestamp;
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
const model = chatMessageModelLabel(message);
const parts = [time, model].filter((part): part is string => part !== undefined && part !== "");
const parts = [time, model, message.meta?.thinkingLevel].filter((part): part is string => part !== undefined && part !== "");
return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · ");
}
+2
View File
@@ -68,6 +68,8 @@ export interface ChatLine {
meta?: {
timestamp?: string;
model?: { provider?: string; id?: string; responseId?: string };
/** Thinking level the assistant message was generated with, when known. */
thinkingLevel?: string;
};
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => {
describe("assistant thinking-level attribution", () => {
function messagesService(branch: unknown[], patch: Parameters<typeof fakeRuntime>[1] = {}) {
const fake = fakeRuntime("session-1", {
sessionFile: "/tmp/session-1.jsonl",
sessionManager: fakeSessionManager("/workspace", { getBranch: () => branch }),
...patch,
});
const events = new CapturingSessionEventHub();
const service = new PiSessionService(events, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("session-1")]),
heartbeatIntervalMs: 60_000,
});
return { fake, service, events };
}
it("annotates paged assistant messages with the thinking level in effect from branch entries", async () => {
const branch = [
{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } },
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "before any entry" }] } },
{ type: "thinking_level_change", thinkingLevel: "medium" },
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "first answer" }] } },
{ type: "thinking_level_change", thinkingLevel: "max" },
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "second answer" }] } },
{ type: "thinking_level_change", thinkingLevel: "off" },
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "unthinking answer" }] } },
{ type: "message", message: { role: "toolResult", toolName: "bash", content: [{ type: "text", text: "done" }] } },
];
const { service } = messagesService(branch);
const messages = await service.messages(sessionRef("session-1"));
expect(messages).toEqual([
{ role: "user", content: [{ type: "text", text: "hi" }] },
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "before any entry" }] },
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "first answer" }], thinkingLevel: "medium" },
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "second answer" }], thinkingLevel: "max" },
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "unthinking answer" }] },
{ role: "toolResult", toolName: "bash", content: [{ type: "text", text: "done" }] },
]);
await service.dispose();
});
it("annotates live assistant message.end events with the session's current thinking level", async () => {
const { fake, service, events } = messagesService([], { thinkingLevel: "high" });
await service.status(sessionRef("session-1")); // bring the session online so it publishes events
fake.emit({ type: "message_end", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "answer" }] } });
fake.emit({ type: "message_end", message: { role: "user", content: [{ type: "text", text: "next" }] } });
const messageEnds = events.sessionEvents.map(({ event }) => event).filter((event) => event.type === "message.end");
expect(messageEnds).toEqual([
{ type: "message.end", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "answer" }], thinkingLevel: "high" } },
{ type: "message.end", message: { role: "user", content: [{ type: "text", text: "next" }] } },
]);
await service.dispose();
});
it("annotates the join-time stream snapshot partial with the current thinking level", async () => {
const streamingMessage = {
role: "assistant",
provider: "openai",
model: "gpt-4.1",
content: [{ type: "thinking", thinking: "hmm", thinkingSignature: "provider-signature" }],
};
const { service } = messagesService([], { thinkingLevel: "xhigh", state: { streamingMessage } });
const snapshot = await service.streamSnapshot(sessionRef("session-1"));
expect(snapshot.partial).toEqual({
role: "assistant",
provider: "openai",
model: "gpt-4.1",
content: [{ type: "thinking", thinking: "hmm" }],
thinkingLevel: "xhigh",
});
await service.dispose();
});
});
});
+25 -5
View File
@@ -1910,7 +1910,7 @@ export class PiSessionService implements SessionRouteService {
const streamingMessage = session.state.streamingMessage;
const partial = streamingMessage === undefined || streamingMessage === null
? null
: projectBrowserMessage(streamingMessage);
: annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
return { seq, partial };
}
@@ -3220,7 +3220,7 @@ export class PiSessionService implements SessionRouteService {
}
}
active.unsubscribe = session.subscribe((event) => {
this.events.publish(session.sessionId, toClientEvent(event));
this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
this.publishActivityForEvent(session, event);
const eventType = getString(event, "type");
if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId);
@@ -4119,11 +4119,30 @@ function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
/**
* Attach the thinking level in effect when an assistant message was generated,
* so chat bubbles can show it next to the model. Non-assistant messages pass
* through by reference; assistant messages are copied only when a level is set.
* "off" is the absence of thinking, not a level worth labeling on every bubble.
*/
function annotateAssistantThinkingLevel(message: unknown, thinkingLevel: string | undefined): unknown {
if (thinkingLevel === undefined || thinkingLevel === "" || thinkingLevel === "off") return message;
if (!isRecord(message) || message["role"] !== "assistant") return message;
return { ...message, thinkingLevel };
}
function historyMessages(session: PiAgentSession): unknown[] {
const messages: unknown[] = [];
// Pi records the initial level at session creation and every later change, so
// walking the branch yields the level in effect for each assistant message.
let thinkingLevel: string | undefined;
for (const entry of session.sessionManager.getBranch()) {
if (!isRecord(entry)) continue;
if (entry["type"] === "message") messages.push(entry["message"]);
if (entry["type"] === "message") messages.push(annotateAssistantThinkingLevel(entry["message"], thinkingLevel));
else if (entry["type"] === "thinking_level_change") {
const level = getString(entry, "thinkingLevel");
if (level !== undefined) thinkingLevel = level;
}
else if (entry["type"] === "custom_message" && entry["display"] === true) messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
else if (entry["type"] === "compaction") messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
else if (entry["type"] === "branch_summary") messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
@@ -4167,7 +4186,7 @@ function finalAssistantText(messages: readonly unknown[]): string {
return "";
}
function toClientEvent(event: unknown): SessionUiEvent {
function toClientEvent(event: unknown, thinkingLevel?: string): SessionUiEvent {
const eventType = getString(event, "type");
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
@@ -4192,7 +4211,8 @@ function toClientEvent(event: unknown): SessionUiEvent {
if (eventType === "agent_end") return { type: "agent.end" };
if (eventType === "message_end") {
const message = getProperty(event, "message");
return message === undefined ? { type: "message.end" } : { type: "message.end", message };
if (message === undefined) return { type: "message.end" };
return { type: "message.end", message: annotateAssistantThinkingLevel(message, thinkingLevel) };
}
return { type: "pi.event", eventType: eventType ?? "unknown" };
}