Improve queued message handling

This commit is contained in:
Federico Jaramillo Martinez
2026-05-10 00:46:47 +02:00
parent 224eef2765
commit 63ef03f8f6
13 changed files with 226 additions and 8 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, Workspace } from "../../shared/apiTypes"; export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, Workspace } from "../../shared/apiTypes";
+2
View File
@@ -14,6 +14,7 @@ describe("API parsers", () => {
isCompacting: true, isCompacting: true,
isBashRunning: false, isBashRunning: false,
pendingMessageCount: 2, pendingMessageCount: 2,
queuedMessages: [{ kind: "steer", text: "adjust this" }, { kind: "followUp", text: "then do that" }],
tokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 }, tokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 },
cost: 0.12, cost: 0.12,
model: { provider: "p", id: "m", contextWindow: 100, reasoning: { effort: "low" } }, model: { provider: "p", id: "m", contextWindow: 100, reasoning: { effort: "low" } },
@@ -25,6 +26,7 @@ describe("API parsers", () => {
isCompacting: true, isCompacting: true,
isBashRunning: false, isBashRunning: false,
pendingMessageCount: 2, pendingMessageCount: 2,
queuedMessages: [{ kind: "steer", text: "adjust this" }, { kind: "followUp", text: "then do that" }],
tokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 }, tokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 },
cost: 0.12, cost: 0.12,
model: { provider: "p", id: "m", contextWindow: 100, reasoning: { effort: "low" } }, model: { provider: "p", id: "m", contextWindow: 100, reasoning: { effort: "low" } },
+9 -1
View File
@@ -1,4 +1,4 @@
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, TerminalInfo, Workspace } from "../../../shared/apiTypes"; import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, QueuedSessionMessage, SessionInfo, SessionStatus, SlashCommand, TerminalInfo, Workspace } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
@@ -100,6 +100,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
isCompacting: requireBoolean(record, "isCompacting"), isCompacting: requireBoolean(record, "isCompacting"),
isBashRunning: requireBoolean(record, "isBashRunning"), isBashRunning: requireBoolean(record, "isBashRunning"),
pendingMessageCount: requireNumber(record, "pendingMessageCount"), pendingMessageCount: requireNumber(record, "pendingMessageCount"),
queuedMessages: record["queuedMessages"] === undefined ? [] : arrayOf(parseQueuedSessionMessage)(record["queuedMessages"]),
tokens: parseTokens(record["tokens"]), tokens: parseTokens(record["tokens"]),
cost: requireNumber(record, "cost"), cost: requireNumber(record, "cost"),
...optionalModel(record["model"]), ...optionalModel(record["model"]),
@@ -108,6 +109,13 @@ export function parseSessionStatus(value: unknown): SessionStatus {
}; };
} }
function parseQueuedSessionMessage(value: unknown): QueuedSessionMessage {
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "steer" && kind !== "followUp") throw new Error("Invalid queued message kind");
return { kind, text: requireString(record, "text") };
}
function parseTokens(value: unknown): SessionStatus["tokens"] { function parseTokens(value: unknown): SessionStatus["tokens"] {
const record = requireRecord(value); const record = requireRecord(value);
return { return {
+17
View File
@@ -41,4 +41,21 @@ describe("applyTranscriptEvent", () => {
}, },
]); ]);
}); });
it("does not merge different finalized user messages", () => {
const messages = [textMessage("user", "first queued prompt")];
expect(applyTranscriptEvent(messages, { type: "message.end", message: { role: "user", content: "second queued prompt" } })).toEqual([
textMessage("user", "first queued prompt"),
textMessage("user", "second queued prompt"),
]);
});
it("replaces an optimistic user message when the finalized text matches", () => {
const messages = [textMessage("user", "sent prompt")];
expect(applyTranscriptEvent(messages, { type: "message.end", message: { role: "user", content: "sent prompt", timestamp: "2026-05-09T12:00:00.000Z" } })).toEqual([
{ ...textMessage("user", "sent prompt"), meta: { timestamp: "2026-05-09T12:00:00.000Z" } },
]);
});
}); });
+13 -1
View File
@@ -23,7 +23,19 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
if (ended === undefined) return undefined; if (ended === undefined) return undefined;
const last = messages.at(-1); const last = messages.at(-1);
if (last?.role !== ended.role) return [...messages, ended]; if (last?.role !== ended.role) return [...messages, ended];
return [...messages.slice(0, -1), ended]; if (ended.role === "assistant" || sameMessageText(last, ended)) return [...messages.slice(0, -1), ended];
return [...messages, ended];
}
function sameMessageText(left: ChatLine, right: ChatLine): boolean {
return messageText(left) === messageText(right);
}
function messageText(message: ChatLine): string {
return message.parts
.filter((part): part is Extract<ChatLine["parts"][number], { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("\n\n");
} }
function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] { function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] {
+20
View File
@@ -70,6 +70,7 @@ export class ChatView extends LitElement {
${groupChatMessages(this.messages, this.messageStart).map((group) => group.kind === "message" ${groupChatMessages(this.messages, this.messageStart).map((group) => group.kind === "message"
? this.renderMessage(group.message, group.index) ? this.renderMessage(group.message, group.index)
: this.renderMessageGroup(group.messages, group.startIndex))} : this.renderMessageGroup(group.messages, group.startIndex))}
${this.renderQueuedMessages()}
${this.renderSessionActivity()} ${this.renderSessionActivity()}
</div> </div>
${this.renderActivityDock()} ${this.renderActivityDock()}
@@ -89,6 +90,25 @@ export class ChatView extends LitElement {
`; `;
} }
private renderQueuedMessages() {
const queued = this.status?.queuedMessages ?? [];
if (queued.length === 0) return null;
return html`
<aside class="queued-messages" aria-live="polite">
<div class="queued-header">
<strong>Queued messages</strong>
<small>${queued.length} pending · Stop clears the queue</small>
</div>
${queued.map((message, index) => html`
<div class="queued-message">
<span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span>
<formatted-text .text=${message.text}></formatted-text>
</div>
`)}
</aside>
`;
}
private renderSessionActivity() { private renderSessionActivity() {
if (this.isReceivingPartialStream) return html` if (this.isReceivingPartialStream) return html`
<aside class="session-activity receiving" aria-live="polite"> <aside class="session-activity receiving" aria-live="polite">
+1 -1
View File
@@ -253,7 +253,7 @@ export class PiWebApp extends LitElement {
<div class="mobile-navigation-panel">${this.renderNavigationPanel(true)}</div> <div class="mobile-navigation-panel">${this.renderNavigationPanel(true)}</div>
${state.selectedSession ? html` ${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view> <chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}></prompt-editor> <prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}></prompt-editor>
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar> <status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null} ${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
` : html`<div class="empty">Select or start a session.</div>`} ` : html`<div class="empty">Select or start a session.</div>`}
+1 -1
View File
@@ -57,7 +57,7 @@ export class PromptEditor extends LitElement {
<div class="actions"> <div class="actions">
<button ?disabled=${this.disabled} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}</button> <button ?disabled=${this.disabled} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}</button>
${this.canSteer && !this.isCompacting ? html`<button ?disabled=${this.disabled} title="Steer the current response before the next model call" @click=${() => { this.send("steer"); }}>Steer</button>` : null} ${this.canSteer && !this.isCompacting ? html`<button ?disabled=${this.disabled} title="Steer the current response before the next model call" @click=${() => { this.send("steer"); }}>Steer</button>` : null}
<button ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work" : "Nothing running"} @click=${() => this.onStop?.()}>Stop</button> <button ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work and clear queued messages" : "Nothing running"} @click=${() => this.onStop?.()}>Stop</button>
</div> </div>
</footer> </footer>
`; `;
+7
View File
@@ -145,6 +145,13 @@ export const chatStyles = css`
.group-msg.system { color: #ff7b72; } .group-msg.system { color: #ff7b72; }
.group-msg.bash { color: #3fb950; } .group-msg.bash { color: #3fb950; }
.history-boundary { display: grid; gap: 3px; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; } .history-boundary { display: grid; gap: 3px; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; }
.queued-messages { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid #6e5200; border-radius: 10px; background: #1f1a10; color: #e6edf3; overflow: auto; -webkit-overflow-scrolling: touch; }
.queued-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.queued-header strong { color: #d29922; }
.queued-header small { color: #8b949e; }
.queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid #30363d; }
.queued-message:first-of-type { padding-top: 0; border-top: 0; }
.queued-kind { color: #8b949e; font-size: 12px; text-transform: uppercase; }
.session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; color: #e6edf3; overflow: auto; -webkit-overflow-scrolling: touch; } .session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; color: #e6edf3; overflow: auto; -webkit-overflow-scrolling: touch; }
.session-activity.compacting { border-color: #a371f7; background: #21132f; } .session-activity.compacting { border-color: #a371f7; background: #21132f; }
.session-activity.receiving { border-color: #238636; background: #0f1b12; } .session-activity.receiving { border-color: #238636; background: #0f1b12; }
@@ -51,6 +51,7 @@ describe("SessionEventHub", () => {
isCompacting: false, isCompacting: false,
isBashRunning: false, isBashRunning: false,
pendingMessageCount: 0, pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0, cost: 0,
}; };
+120 -1
View File
@@ -33,7 +33,7 @@ function asRuntimeFactoryResult(runtime: AgentSessionRuntime): RuntimeFactoryRes
function fakeRuntime(sessionId = "session-1") { function fakeRuntime(sessionId = "session-1") {
const promptCalls: { text: string; options: unknown }[] = []; const promptCalls: { text: string; options: unknown }[] = [];
const calls = { abort: 0, dispose: 0, prompt: promptCalls }; const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls };
const session = { const session = {
sessionId, sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`, sessionFile: `/tmp/${sessionId}.jsonl`,
@@ -57,6 +57,12 @@ function fakeRuntime(sessionId = "session-1") {
calls.abort += 1; calls.abort += 1;
return Promise.resolve(); return Promise.resolve();
}, },
clearQueue: () => {
calls.clearQueue += 1;
return { steering: [], followUp: [] };
},
getSteeringMessages: () => [],
getFollowUpMessages: () => [],
} as unknown as AgentSession; } as unknown as AgentSession;
const runtime = { const runtime = {
session, session,
@@ -145,4 +151,117 @@ describe("PiSessionService", () => {
expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]); expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]);
await service.dispose(); await service.dispose();
}); });
it("includes queued message details in session status", async () => {
const fake = fakeRuntime("status-session");
(fake.runtime.session as unknown as { pendingMessageCount: number; getSteeringMessages: () => string[]; getFollowUpMessages: () => string[] }).pendingMessageCount = 2;
(fake.runtime.session as unknown as { getSteeringMessages: () => string[] }).getSteeringMessages = () => ["adjust this turn"];
(fake.runtime.session as unknown as { getFollowUpMessages: () => string[] }).getFollowUpMessages = () => ["then do this"];
const service = new PiSessionService(new CapturingSessionEventHub(), {
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
createAgentRuntime: () => Promise.resolve(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([{ id: "status-session", path: "/sessions/status-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await expect(service.status("status-session")).resolves.toMatchObject({
pendingMessageCount: 2,
queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }],
});
await service.dispose();
});
it("does not enqueue duplicate queued message text", async () => {
const fake = fakeRuntime("dedupe-session");
(fake.runtime.session as unknown as { isStreaming: boolean; pendingMessageCount: number; getFollowUpMessages: () => string[] }).isStreaming = true;
(fake.runtime.session as unknown as { pendingMessageCount: number }).pendingMessageCount = 1;
(fake.runtime.session as unknown as { getFollowUpMessages: () => string[] }).getFollowUpMessages = () => ["already queued"];
const service = new PiSessionService(new CapturingSessionEventHub(), {
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
createAgentRuntime: () => Promise.resolve(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([{ id: "dedupe-session", path: "/sessions/dedupe-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.prompt("dedupe-session", "already queued", "followUp");
expect(fake.calls.prompt).toEqual([]);
await service.dispose();
});
it("does not append queued prompts to the transcript before delivery", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("queued-session");
(fake.runtime.session as unknown as { isStreaming: boolean }).isStreaming = true;
const service = new PiSessionService(hub, {
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
createAgentRuntime: () => Promise.resolve(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([{ id: "queued-session", path: "/sessions/queued-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.prompt("queued-session", "Wait for the current turn", "followUp");
expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]);
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
await service.dispose();
});
it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
createAgentRuntime: () => Promise.resolve(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([{ id: "abort-session", path: "/sessions/abort-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status("abort-session");
await service.abort("abort-session");
expect(fake.calls.clearQueue).toBe(1);
expect(fake.calls.abort).toBe(1);
await service.dispose();
});
it("clears queued messages when stopping a session runtime", async () => {
const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
createAgentRuntime: () => Promise.resolve(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([{ id: "stop-session", path: "/sessions/stop-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status("stop-session");
service.stop("stop-session");
expect(fake.calls.clearQueue).toBe(1);
await service.dispose();
});
}); });
+28 -2
View File
@@ -170,9 +170,15 @@ export class PiSessionService {
await this.assertWritable(sessionId); await this.assertWritable(sessionId);
const session = await this.getOrOpen(sessionId); const session = await this.getOrOpen(sessionId);
this.maybeGenerateSessionName(session, text); this.maybeGenerateSessionName(session, text);
const behavior = session.isStreaming || session.isCompacting ? streamingBehavior ?? "followUp" : undefined; const isQueued = session.isStreaming || session.isCompacting;
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
if (isQueued && hasQueuedMessageText(session, text)) {
this.publishActivity(session, "duplicate queued message ignored", "active");
this.publishStatus(session);
return;
}
this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active"); this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
this.events.publish(sessionId, { type: "message.append", message: userTextMessage(text) }); if (!isQueued) this.events.publish(sessionId, { type: "message.append", message: userTextMessage(text) });
void session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => { void session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "error", "error", message); this.publishActivity(session, "error", "error", message);
@@ -246,6 +252,7 @@ export class PiSessionService {
async abort(sessionId: string): Promise<void> { async abort(sessionId: string): Promise<void> {
const active = this.active.get(sessionId); const active = this.active.get(sessionId);
if (!active) return; if (!active) return;
clearSessionQueue(active.runtime.session);
await active.runtime.session.abort(); await active.runtime.session.abort();
this.publishActivity(active.runtime.session, "stopped", "idle"); this.publishActivity(active.runtime.session, "stopped", "idle");
this.publishStatus(active.runtime.session); this.publishStatus(active.runtime.session);
@@ -254,6 +261,7 @@ export class PiSessionService {
stop(sessionId: string): void { stop(sessionId: string): void {
const active = this.active.get(sessionId); const active = this.active.get(sessionId);
if (!active) return; if (!active) return;
clearSessionQueue(active.runtime.session);
active.unsubscribe(); active.unsubscribe();
void active.runtime.session.abort().finally(() => active.runtime.dispose()); void active.runtime.session.abort().finally(() => active.runtime.dispose());
this.active.delete(sessionId); this.active.delete(sessionId);
@@ -416,6 +424,7 @@ export class PiSessionService {
isCompacting: session.isCompacting, isCompacting: session.isCompacting,
isBashRunning: session.isBashRunning, isBashRunning: session.isBashRunning,
pendingMessageCount: session.pendingMessageCount, pendingMessageCount: session.pendingMessageCount,
queuedMessages: queuedMessagesFromSession(session),
tokens: stats.tokens, tokens: stats.tokens,
cost: stats.cost, cost: stats.cost,
...(contextUsage === undefined ? {} : { contextUsage }), ...(contextUsage === undefined ? {} : { contextUsage }),
@@ -435,6 +444,23 @@ async function clearParentSession(sessionFile: string): Promise<void> {
await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8"); await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
} }
function clearSessionQueue(session: AgentSession): void {
const candidate = session as AgentSession & { clearQueue?: () => unknown };
candidate.clearQueue?.();
}
function hasQueuedMessageText(session: AgentSession, text: string): boolean {
return queuedMessagesFromSession(session).some((message) => message.text === text);
}
function queuedMessagesFromSession(session: AgentSession): { kind: "steer" | "followUp"; text: string }[] {
const candidate = session as AgentSession & { getSteeringMessages?: () => string[]; getFollowUpMessages?: () => string[] };
return [
...(candidate.getSteeringMessages?.() ?? []).map((text) => ({ kind: "steer" as const, text })),
...(candidate.getFollowUpMessages?.() ?? []).map((text) => ({ kind: "followUp" as const, text })),
];
}
function userTextMessage(text: string): { role: "user"; content: string } { function userTextMessage(text: string): { role: "user"; content: string } {
return { role: "user", content: text }; return { role: "user", content: text };
} }
+6
View File
@@ -38,6 +38,11 @@ export interface SessionActivity {
at: string; at: string;
} }
export interface QueuedSessionMessage {
kind: "steer" | "followUp";
text: string;
}
export interface SessionStatus { export interface SessionStatus {
sessionId: string; sessionId: string;
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown }; model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
@@ -46,6 +51,7 @@ export interface SessionStatus {
isCompacting: boolean; isCompacting: boolean;
isBashRunning: boolean; isBashRunning: boolean;
pendingMessageCount: number; pendingMessageCount: number;
queuedMessages: QueuedSessionMessage[];
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
cost: number; cost: number;
contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null }; contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };