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", () => {
|
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([
|
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" } } },
|
{ 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 {
|
function normalizeMeta(message: unknown): ChatLine["meta"] | undefined {
|
||||||
const timestamp = normalizeTimestamp(getProperty(message, "timestamp"));
|
const timestamp = normalizeTimestamp(getProperty(message, "timestamp"));
|
||||||
const model = normalizeModel(message);
|
const model = normalizeModel(message);
|
||||||
if (timestamp === undefined && model === undefined) return undefined;
|
const thinkingLevel = getString(message, "thinkingLevel");
|
||||||
return { ...(timestamp === undefined ? {} : { timestamp }), ...(model === undefined ? {} : { model }) };
|
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 {
|
function normalizeTimestamp(value: unknown): string | undefined {
|
||||||
|
|||||||
@@ -259,6 +259,17 @@ describe("chatMessageMetadataLabel", () => {
|
|||||||
meta: { timestamp, model: { provider: "provider", id: "model" } },
|
meta: { timestamp, model: { provider: "provider", id: "model" } },
|
||||||
})).toBe(`${formattedTimestamp} · provider/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", () => {
|
describe("chat event-group content seams", () => {
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function chatMessageMetadataLabel(message: ChatLine): string {
|
|||||||
const timestamp = message.meta?.timestamp;
|
const timestamp = message.meta?.timestamp;
|
||||||
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
|
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
|
||||||
const model = chatMessageModelLabel(message);
|
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(" · ");
|
return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface ChatLine {
|
|||||||
meta?: {
|
meta?: {
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
model?: { provider?: string; id?: string; responseId?: 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 streamingMessage = session.state.streamingMessage;
|
||||||
const partial = streamingMessage === undefined || streamingMessage === null
|
const partial = streamingMessage === undefined || streamingMessage === null
|
||||||
? null
|
? null
|
||||||
: projectBrowserMessage(streamingMessage);
|
: annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
|
||||||
return { seq, partial };
|
return { seq, partial };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3220,7 +3220,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
active.unsubscribe = session.subscribe((event) => {
|
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);
|
this.publishActivityForEvent(session, event);
|
||||||
const eventType = getString(event, "type");
|
const eventType = getString(event, "type");
|
||||||
if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId);
|
if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId);
|
||||||
@@ -4119,11 +4119,30 @@ function stringValue(value: unknown): string {
|
|||||||
return typeof value === "string" ? value : "";
|
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[] {
|
function historyMessages(session: PiAgentSession): unknown[] {
|
||||||
const messages: 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()) {
|
for (const entry of session.sessionManager.getBranch()) {
|
||||||
if (!isRecord(entry)) continue;
|
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"] === "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"] === "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"])}` });
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toClientEvent(event: unknown): SessionUiEvent {
|
function toClientEvent(event: unknown, thinkingLevel?: string): SessionUiEvent {
|
||||||
const eventType = getString(event, "type");
|
const eventType = getString(event, "type");
|
||||||
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
||||||
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
|
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 === "agent_end") return { type: "agent.end" };
|
||||||
if (eventType === "message_end") {
|
if (eventType === "message_end") {
|
||||||
const message = getProperty(event, "message");
|
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" };
|
return { type: "pi.event", eventType: eventType ?? "unknown" };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user