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
@@ -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" };
}