Archived
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.
This commit is contained in:
@@ -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.
|
||||
@@ -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" } } },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(" · ");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user