fix(coding-agent): properly queue extension messages (#2674)
This commit is contained in:
@@ -112,6 +112,11 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null {
|
||||
/** Session-specific events that extend the core AgentEvent */
|
||||
export type AgentSessionEvent =
|
||||
| AgentEvent
|
||||
| {
|
||||
type: "queue_update";
|
||||
steering: readonly string[];
|
||||
followUp: readonly string[];
|
||||
}
|
||||
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
|
||||
| {
|
||||
type: "compaction_end";
|
||||
@@ -414,6 +419,14 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private _emitQueueUpdate(): void {
|
||||
this._emit({
|
||||
type: "queue_update",
|
||||
steering: [...this._steeringMessages],
|
||||
followUp: [...this._followUpMessages],
|
||||
});
|
||||
}
|
||||
|
||||
// Track last assistant message for auto-compaction check
|
||||
private _lastAssistantMessage: AssistantMessage | undefined = undefined;
|
||||
|
||||
@@ -476,11 +489,13 @@ export class AgentSession {
|
||||
const steeringIndex = this._steeringMessages.indexOf(messageText);
|
||||
if (steeringIndex !== -1) {
|
||||
this._steeringMessages.splice(steeringIndex, 1);
|
||||
this._emitQueueUpdate();
|
||||
} else {
|
||||
// Check follow-up queue
|
||||
const followUpIndex = this._followUpMessages.indexOf(messageText);
|
||||
if (followUpIndex !== -1) {
|
||||
this._followUpMessages.splice(followUpIndex, 1);
|
||||
this._emitQueueUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1155,6 +1170,7 @@ export class AgentSession {
|
||||
*/
|
||||
private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {
|
||||
this._steeringMessages.push(text);
|
||||
this._emitQueueUpdate();
|
||||
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
||||
if (images) {
|
||||
content.push(...images);
|
||||
@@ -1171,6 +1187,7 @@ export class AgentSession {
|
||||
*/
|
||||
private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
|
||||
this._followUpMessages.push(text);
|
||||
this._emitQueueUpdate();
|
||||
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
||||
if (images) {
|
||||
content.push(...images);
|
||||
@@ -1282,7 +1299,7 @@ export class AgentSession {
|
||||
expandPromptTemplates: false,
|
||||
streamingBehavior: options?.deliverAs,
|
||||
images,
|
||||
source: "extension",
|
||||
source: "interactive",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1297,6 +1314,7 @@ export class AgentSession {
|
||||
this._steeringMessages = [];
|
||||
this._followUpMessages = [];
|
||||
this.agent.clearAllQueues();
|
||||
this._emitQueueUpdate();
|
||||
return { steering, followUp };
|
||||
}
|
||||
|
||||
|
||||
@@ -2244,6 +2244,11 @@ export class InteractiveMode {
|
||||
this.ui.requestRender();
|
||||
break;
|
||||
|
||||
case "queue_update":
|
||||
this.updatePendingMessagesDisplay();
|
||||
this.ui.requestRender();
|
||||
break;
|
||||
|
||||
case "message_start":
|
||||
if (event.message.role === "custom") {
|
||||
this.addMessageToChat(event.message);
|
||||
|
||||
@@ -6,7 +6,14 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEvent,
|
||||
EventStream,
|
||||
getModel,
|
||||
type ImageContent,
|
||||
type TextContent,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentSession } from "../src/core/agent-session.js";
|
||||
@@ -14,7 +21,7 @@ import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createTestResourceLoader } from "./utilities.js";
|
||||
import { createTestExtensionsResult, createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
// Mock stream that mimics AssistantMessageEventStream
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
@@ -60,6 +67,8 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete (globalThis as typeof globalThis & { testExtensionApi?: unknown }).testExtensionApi;
|
||||
delete (globalThis as typeof globalThis & { testCommandRuns?: unknown }).testCommandRuns;
|
||||
if (session) {
|
||||
session.dispose();
|
||||
}
|
||||
@@ -171,6 +180,116 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("should queue extension-origin steering messages while streaming", async () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
let sawSteeringMessage = false;
|
||||
let lastInputSource: string | undefined;
|
||||
const queueEvents: Array<{ steering: readonly string[]; followUp: readonly string[] }> = [];
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFn: (_model, context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const userTexts = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
}
|
||||
return message.content
|
||||
.filter((part): part is TextContent | ImageContent => typeof part === "object" && part !== null)
|
||||
.filter((part): part is TextContent => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
if (userTexts.includes("Steer from extension")) {
|
||||
sawSteeringMessage = true;
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Steered") });
|
||||
return;
|
||||
}
|
||||
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
const checkAbort = () => {
|
||||
if (abortSignal?.aborted) {
|
||||
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
|
||||
} else {
|
||||
setTimeout(checkAbort, 5);
|
||||
}
|
||||
};
|
||||
checkAbort();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
const modelRegistry = new ModelRegistry(authStorage, tempDir);
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
|
||||
const extensionsResult = await createTestExtensionsResult([
|
||||
(pi) => {
|
||||
(globalThis as typeof globalThis & { testExtensionApi?: unknown }).testExtensionApi = pi;
|
||||
},
|
||||
(pi) => {
|
||||
pi.on("input", async (event) => {
|
||||
lastInputSource = event.source;
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader({ extensionsResult }),
|
||||
});
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "queue_update") {
|
||||
queueEvents.push({ steering: event.steering, followUp: event.followUp });
|
||||
}
|
||||
});
|
||||
|
||||
const firstPrompt = session.prompt("First message");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(session.isStreaming).toBe(true);
|
||||
|
||||
const pi = (
|
||||
globalThis as typeof globalThis & {
|
||||
testExtensionApi?: {
|
||||
sendUserMessage: (content: string, options?: { deliverAs?: "steer" | "followUp" }) => void;
|
||||
};
|
||||
}
|
||||
).testExtensionApi;
|
||||
expect(pi).toBeDefined();
|
||||
|
||||
pi!.sendUserMessage("Steer from extension", { deliverAs: "steer" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
expect(session.pendingMessageCount).toBe(1);
|
||||
expect(session.getSteeringMessages()).toContain("Steer from extension");
|
||||
expect(lastInputSource).toBe("interactive");
|
||||
expect(queueEvents.some((event) => event.steering.includes("Steer from extension"))).toBe(true);
|
||||
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
|
||||
expect(sawSteeringMessage).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow prompt() after previous completes", async () => {
|
||||
// Create session with a stream that completes immediately
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
|
||||
Reference in New Issue
Block a user