fix(sessions): stop duplicating user message for slash commands

A slash command sent as the first (or any idle) message showed the raw
command text twice until reload: once from the client's optimistic insert
and once from the server's message.append echo, neither of which converges
with the agent's canonical expanded message (e.g. a /skill:* block).

Make commands obey the same source-of-truth contract as prompts:
- client no longer inserts the raw command text optimistically; it shows
  the existing per-session sending indicator instead
- forwarded runtime/skill commands return a bare done result rather than a
  synthetic "Accepted ..." line
- server suppresses the raw message.append echo for command-forwarded
  prompts (threaded through the compaction queue too)

Result: pre-reload state matches reload, with no transient duplicate.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-17 00:02:04 +02:00
parent dd23b3e054
commit e77ab98361
6 changed files with 90 additions and 13 deletions
+16 -10
View File
@@ -76,6 +76,7 @@ interface QueuedPrompt {
kind: QueuedPromptKind;
text: string;
images?: ImageContent[];
echoUserMessage?: boolean;
}
function requirePromptText(value: unknown): string {
@@ -300,7 +301,7 @@ export class PiSessionService {
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
this.commandService = new SessionCommandService(
(sessionId) => this.getActive(sessionId),
(sessionId, text) => this.prompt(sessionId, text),
(sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }),
events,
{
onCompactionStart: (session) => {
@@ -478,8 +479,13 @@ export class PiSessionService {
return commands.sort((a, b) => a.name.localeCompare(b.name));
}
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown, options?: { echoUserMessage?: boolean }): Promise<void> {
const promptText = requirePromptText(text);
// Command-forwarded prompts (e.g. /skill:*) are expanded by the agent, which
// streams the canonical message back. The client doesn't render the raw
// command text, so the server must not echo it either, or it would show up
// as a transient line that vanishes on reload.
const echoUserMessage = options?.echoUserMessage !== false;
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
const parsedAttachments = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
@@ -494,15 +500,15 @@ export class PiSessionService {
return;
}
if (session.isCompacting) {
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images);
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
return;
}
void this.submitPrompt(session, promptText, behavior, images);
void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
}
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined, images: ImageContent[] = []): Promise<void> {
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined, images: ImageContent[] = [], echoUserMessage = true): Promise<void> {
this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
if (behavior === undefined) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
if (behavior === undefined && echoUserMessage) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
const promptOptions = buildPromptOptions(behavior, images);
const promptPromise = session.prompt(text, promptOptions).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
@@ -513,9 +519,9 @@ export class PiSessionService {
return promptPromise;
}
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind, images: ImageContent[] = []): void {
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind, images: ImageContent[] = [], echoUserMessage = true): void {
const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
queue.push({ kind, text, ...(images.length > 0 ? { images } : {}) });
queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
this.compactionPromptQueues.set(session.sessionId, queue);
this.publishActivity(session, "message queued during compaction", "active");
this.publishStatus(session);
@@ -859,14 +865,14 @@ export class PiSessionService {
const queued = this.takeCompactionPromptQueue(sessionId);
if (queued.length === 0) return;
this.publishStatus(session);
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images);
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images, prompt.echoUserMessage ?? true);
return;
}
const prompt = this.shiftCompactionPrompt(sessionId);
if (prompt === undefined) return;
this.publishStatus(session);
const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images);
const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images, prompt.echoUserMessage ?? true);
void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
}