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,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