Archived
Improve queued message handling
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
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";
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("API parsers", () => {
|
||||
isCompacting: true,
|
||||
isBashRunning: false,
|
||||
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 },
|
||||
cost: 0.12,
|
||||
model: { provider: "p", id: "m", contextWindow: 100, reasoning: { effort: "low" } },
|
||||
@@ -25,6 +26,7 @@ describe("API parsers", () => {
|
||||
isCompacting: true,
|
||||
isBashRunning: false,
|
||||
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 },
|
||||
cost: 0.12,
|
||||
model: { provider: "p", id: "m", contextWindow: 100, reasoning: { effort: "low" } },
|
||||
|
||||
@@ -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> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -100,6 +100,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
|
||||
isCompacting: requireBoolean(record, "isCompacting"),
|
||||
isBashRunning: requireBoolean(record, "isBashRunning"),
|
||||
pendingMessageCount: requireNumber(record, "pendingMessageCount"),
|
||||
queuedMessages: record["queuedMessages"] === undefined ? [] : arrayOf(parseQueuedSessionMessage)(record["queuedMessages"]),
|
||||
tokens: parseTokens(record["tokens"]),
|
||||
cost: requireNumber(record, "cost"),
|
||||
...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"] {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
|
||||
@@ -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" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,19 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
|
||||
if (ended === undefined) return undefined;
|
||||
const last = messages.at(-1);
|
||||
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[] {
|
||||
|
||||
@@ -70,6 +70,7 @@ export class ChatView extends LitElement {
|
||||
${groupChatMessages(this.messages, this.messageStart).map((group) => group.kind === "message"
|
||||
? this.renderMessage(group.message, group.index)
|
||||
: this.renderMessageGroup(group.messages, group.startIndex))}
|
||||
${this.renderQueuedMessages()}
|
||||
${this.renderSessionActivity()}
|
||||
</div>
|
||||
${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() {
|
||||
if (this.isReceivingPartialStream) return html`
|
||||
<aside class="session-activity receiving" aria-live="polite">
|
||||
|
||||
@@ -253,7 +253,7 @@ export class PiWebApp extends LitElement {
|
||||
<div class="mobile-navigation-panel">${this.renderNavigationPanel(true)}</div>
|
||||
${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>
|
||||
<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>
|
||||
${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>`}
|
||||
|
||||
@@ -57,7 +57,7 @@ export class PromptEditor extends LitElement {
|
||||
<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>
|
||||
${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>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
@@ -145,6 +145,13 @@ export const chatStyles = css`
|
||||
.group-msg.system { color: #ff7b72; }
|
||||
.group-msg.bash { color: #3fb950; }
|
||||
.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.compacting { border-color: #a371f7; background: #21132f; }
|
||||
.session-activity.receiving { border-color: #238636; background: #0f1b12; }
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("SessionEventHub", () => {
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ function asRuntimeFactoryResult(runtime: AgentSessionRuntime): RuntimeFactoryRes
|
||||
|
||||
function fakeRuntime(sessionId = "session-1") {
|
||||
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 = {
|
||||
sessionId,
|
||||
sessionFile: `/tmp/${sessionId}.jsonl`,
|
||||
@@ -57,6 +57,12 @@ function fakeRuntime(sessionId = "session-1") {
|
||||
calls.abort += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
clearQueue: () => {
|
||||
calls.clearQueue += 1;
|
||||
return { steering: [], followUp: [] };
|
||||
},
|
||||
getSteeringMessages: () => [],
|
||||
getFollowUpMessages: () => [],
|
||||
} as unknown as AgentSession;
|
||||
const runtime = {
|
||||
session,
|
||||
@@ -145,4 +151,117 @@ describe("PiSessionService", () => {
|
||||
expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,9 +170,15 @@ export class PiSessionService {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
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.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) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "error", "error", message);
|
||||
@@ -246,6 +252,7 @@ export class PiSessionService {
|
||||
async abort(sessionId: string): Promise<void> {
|
||||
const active = this.active.get(sessionId);
|
||||
if (!active) return;
|
||||
clearSessionQueue(active.runtime.session);
|
||||
await active.runtime.session.abort();
|
||||
this.publishActivity(active.runtime.session, "stopped", "idle");
|
||||
this.publishStatus(active.runtime.session);
|
||||
@@ -254,6 +261,7 @@ export class PiSessionService {
|
||||
stop(sessionId: string): void {
|
||||
const active = this.active.get(sessionId);
|
||||
if (!active) return;
|
||||
clearSessionQueue(active.runtime.session);
|
||||
active.unsubscribe();
|
||||
void active.runtime.session.abort().finally(() => active.runtime.dispose());
|
||||
this.active.delete(sessionId);
|
||||
@@ -416,6 +424,7 @@ export class PiSessionService {
|
||||
isCompacting: session.isCompacting,
|
||||
isBashRunning: session.isBashRunning,
|
||||
pendingMessageCount: session.pendingMessageCount,
|
||||
queuedMessages: queuedMessagesFromSession(session),
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
...(contextUsage === undefined ? {} : { contextUsage }),
|
||||
@@ -435,6 +444,23 @@ async function clearParentSession(sessionFile: string): Promise<void> {
|
||||
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 } {
|
||||
return { role: "user", content: text };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ export interface SessionActivity {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface QueuedSessionMessage {
|
||||
kind: "steer" | "followUp";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SessionStatus {
|
||||
sessionId: string;
|
||||
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
|
||||
@@ -46,6 +51,7 @@ export interface SessionStatus {
|
||||
isCompacting: boolean;
|
||||
isBashRunning: boolean;
|
||||
pendingMessageCount: number;
|
||||
queuedMessages: QueuedSessionMessage[];
|
||||
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
||||
cost: number;
|
||||
contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
|
||||
|
||||
Reference in New Issue
Block a user