Archived
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:
@@ -542,6 +542,32 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("echoes the user message for direct prompts but not command-forwarded ones", async () => {
|
||||
const fake = fakeRuntime("echo-session", {
|
||||
resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) },
|
||||
});
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("echo-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt(sessionRef("echo-session"), "Build the thing");
|
||||
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
|
||||
|
||||
// The client optimistically renders command-forwarded prompts (e.g. /skill:*),
|
||||
// so the server must not publish a second copy via message.append.
|
||||
await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator");
|
||||
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
|
||||
expect(fake.calls.prompt).toEqual([
|
||||
{ text: "Build the thing", options: undefined },
|
||||
{ text: "/skill:skill-creator", options: undefined },
|
||||
]);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects malformed prompt text before opening the runtime", async () => {
|
||||
const fake = fakeRuntime("prompt-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
|
||||
@@ -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); });
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ describe("SessionCommandService", () => {
|
||||
const service = new SessionCommandService(() => getActive(active), prompt, eventPublisher());
|
||||
|
||||
await expect(service.run("s1", "/missing")).resolves.toEqual({ type: "unsupported", message: "Unknown command: /missing" });
|
||||
await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done", message: "Accepted /ext arg" });
|
||||
// Forwarded runtime commands return a bare done result: the agent streams
|
||||
// back the canonical expanded message, so no synthetic "Accepted" line.
|
||||
await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done" });
|
||||
await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" });
|
||||
await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" });
|
||||
expect(prompt).toHaveBeenCalledTimes(3);
|
||||
|
||||
@@ -80,8 +80,12 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
|
||||
if (!isBuiltinCommand(name)) {
|
||||
if (this.isRuntimeCommand(session, name)) {
|
||||
// The command is forwarded to the agent, which expands it (e.g. /skill:*
|
||||
// into a skill block) and streams the canonical message back. That is the
|
||||
// authoritative feedback, so we don't synthesize an extra "Accepted" line
|
||||
// that would only vanish on reload.
|
||||
await this.prompt(sessionId, text);
|
||||
return { type: "done", message: `Accepted ${text}` };
|
||||
return { type: "done" };
|
||||
}
|
||||
return { type: "unsupported", message: `Unknown command: /${name}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user