From c09b67d15a2e9f90a2eeac1b71ce0d216e03aaa8 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 30 Jul 2026 12:55:11 +0200 Subject: [PATCH] feat(sessions): show thinking level in assistant chat bubble metadata 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. --- .changeset/chat-bubble-thinking-level.md | 5 ++ src/client/src/chatMessages.test.ts | 6 ++ src/client/src/chatMessages.ts | 9 +- src/client/src/components/ChatView.test.ts | 11 +++ src/client/src/components/ChatView.ts | 2 +- src/client/src/components/shared.ts | 2 + .../piSessionService.messages.test.ts | 89 +++++++++++++++++++ src/server/sessions/piSessionService.ts | 30 +++++-- 8 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 .changeset/chat-bubble-thinking-level.md create mode 100644 src/server/sessions/piSessionService.messages.test.ts diff --git a/.changeset/chat-bubble-thinking-level.md b/.changeset/chat-bubble-thinking-level.md new file mode 100644 index 0000000..1c94f3a --- /dev/null +++ b/.changeset/chat-bubble-thinking-level.md @@ -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. diff --git a/src/client/src/chatMessages.test.ts b/src/client/src/chatMessages.test.ts index 9c664a4..e958bea 100644 --- a/src/client/src/chatMessages.test.ts +++ b/src/client/src/chatMessages.test.ts @@ -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" } } }, diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 4e24069..3c93a48 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -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 { diff --git a/src/client/src/components/ChatView.test.ts b/src/client/src/components/ChatView.test.ts index 87091e8..27d308b 100644 --- a/src/client/src/components/ChatView.test.ts +++ b/src/client/src/components/ChatView.test.ts @@ -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", () => { diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 367e023..e9abedc 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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(" · "); } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 67d48f3..510c170 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; }; } diff --git a/src/server/sessions/piSessionService.messages.test.ts b/src/server/sessions/piSessionService.messages.test.ts new file mode 100644 index 0000000..be67a33 --- /dev/null +++ b/src/server/sessions/piSessionService.messages.test.ts @@ -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[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(); + }); + }); +}); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 5c93b3a..cc26e1f 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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" }; }