mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(plugin): continue one timeline item across ACP chunks without messageId (#4701)
* fix(plugin): continue one timeline item across ACP chunks without messageId The plugin ACP reducer minted a new fallback id for every agent_message_chunk / agent_thought_chunk that carried no messageId, so a streamed reply rendered as one block per chunk, splitting words and Markdown delimiters (#4699). Keep one fallback id per chunk kind and end it on a chunk of the other kind, a tool call, a plan, a user message, or a turn boundary, matching the server-side ACP provider. * fix(plugin): end the other chunk kind's item on any chunk A chunk of one kind now ends the other kind's fallback item before the content or id is looked at, so an explicit-id text chunk between two id-less thoughts yields three items instead of merging the thoughts. The test declares the streamed updates through the connector harness instead of handling protocol frames itself. * test(plugin): verify ACP message boundaries and rendered Markdown * test(app): synchronize rewind setup and menu selection --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
7d74916f8e
commit
4be34f05ef
@@ -26,6 +26,13 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
||||
await expect(userMessage(page, text)).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectRewindOption(page: Page, name: string): Promise<void> {
|
||||
const option = page.getByRole("menuitem", { name, exact: true });
|
||||
// Menus mount offscreen while their anchor and content are measured.
|
||||
await expect(option).toBeInViewport({ ratio: 1 });
|
||||
await option.click();
|
||||
}
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(
|
||||
page: Page,
|
||||
input: { prompt: string; agentId: string; workspaceId: string },
|
||||
@@ -190,6 +197,7 @@ test.describe("Rewind sheet", () => {
|
||||
await expectUserMessageVisible(page, secondPrompt);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 2);
|
||||
await session.client.waitForFinish(session.agentId);
|
||||
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
@@ -202,7 +210,7 @@ test.describe("Rewind sheet", () => {
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
await selectRewindOption(page, "Rewind conversation");
|
||||
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
|
||||
@@ -221,7 +229,7 @@ test.describe("Rewind sheet", () => {
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await selectRewindOption(page, "Rewind files");
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, "");
|
||||
await expectUserMessageCount(page, 2);
|
||||
@@ -232,7 +240,7 @@ test.describe("Rewind sheet", () => {
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await selectRewindOption(page, "Rewind files");
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectUserMessageCount(page, 2);
|
||||
@@ -242,7 +250,7 @@ test.describe("Rewind sheet", () => {
|
||||
await userMessage(page, replacementPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-both").click();
|
||||
await selectRewindOption(page, "Rewind conversation and files");
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, replacementPrompt);
|
||||
await expectUserMessageCount(page, 1);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withAcpStreamingMarkdown,
|
||||
requestAcpMarkdown,
|
||||
expectAcpMarkdown,
|
||||
reloadAcpMarkdown,
|
||||
} from "../support/helpers/acp-streaming-markdown";
|
||||
import {
|
||||
expectFinishedMarkdown,
|
||||
expectReloadedMarkdown,
|
||||
@@ -19,3 +25,18 @@ test("formats unfinished Markdown while streaming and preserves the completed re
|
||||
await expectReloadedMarkdown(page);
|
||||
});
|
||||
});
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
test(`ACP plugin chunks preserve Markdown and separate turns at ${width}px`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await withAcpStreamingMarkdown(page, async (agent) => {
|
||||
await requestAcpMarkdown(agent);
|
||||
await expectAcpMarkdown(page, 1);
|
||||
await requestAcpMarkdown(agent);
|
||||
await expectAcpMarkdown(page, 2);
|
||||
await reloadAcpMarkdown(page, testInfo);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { runAcpProvider, type AcpStreamMessage } from "@getpaseo/plugin/server/acp";
|
||||
|
||||
function connect() {
|
||||
let controller: ReadableStreamDefaultController<AcpStreamMessage>;
|
||||
return {
|
||||
readable: new ReadableStream<AcpStreamMessage>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
},
|
||||
}),
|
||||
writable: new WritableStream<AcpStreamMessage>({
|
||||
write(message) {
|
||||
if (!("method" in message) || !("id" in message)) return;
|
||||
let result: unknown = {};
|
||||
if (message.method === "initialize") result = { protocolVersion: 1, agentCapabilities: {} };
|
||||
if (message.method === "session/new") result = { sessionId: "chunks" };
|
||||
if (message.method === "session/prompt") {
|
||||
for (const text of [
|
||||
"- **Current tem",
|
||||
"perature**: 25°C. Read [Paseo ",
|
||||
"docs](https://example.com/docs).",
|
||||
]) {
|
||||
controller.enqueue({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "chunks",
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
|
||||
},
|
||||
});
|
||||
}
|
||||
result = { stopReason: "end_turn" };
|
||||
}
|
||||
controller.enqueue({ jsonrpc: "2.0", id: message.id, result });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(
|
||||
runAcpProvider({ id: "acp-chunks", label: "ACP chunks", connector: connect }),
|
||||
);
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "acp-chunks-test", "requirements": { "paseo": ">=0.8.0" } }
|
||||
@@ -0,0 +1,72 @@
|
||||
import path from "node:path";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
interface AcpMarkdownAgent {
|
||||
client: SeedDaemonClient;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export async function withAcpStreamingMarkdown(
|
||||
page: Page,
|
||||
run: (agent: AcpMarkdownAgent) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "acp-markdown-" });
|
||||
const { client } = workspace;
|
||||
const pluginClient = await connectNewWorkspaceDaemonClient();
|
||||
const previous = await pluginClient.getDaemonConfig();
|
||||
try {
|
||||
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await pluginClient.installDirectoryPlugin(
|
||||
path.resolve(__dirname, "../fixtures/acp-chunks-plugin"),
|
||||
);
|
||||
const agent = await client.createAgent({
|
||||
provider: "acp-chunks",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "ACP Markdown",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await run({ client, agentId: agent.id });
|
||||
} finally {
|
||||
try {
|
||||
await pluginClient.removePlugin("acp-chunks-test");
|
||||
await pluginClient.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
} finally {
|
||||
await pluginClient.close();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestAcpMarkdown(agent: AcpMarkdownAgent): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the temperature and docs.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
export async function expectAcpMarkdown(page: Page, count: number): Promise<void> {
|
||||
// The message container has no semantic role; text and links inside it are user-facing assertions.
|
||||
const messages = page.getByTestId("assistant-message");
|
||||
await expect(messages).toHaveCount(count);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const message = messages.nth(index);
|
||||
await expect(message).toHaveText("•Current temperature: 25°C. Read Paseo docs.");
|
||||
await expect(
|
||||
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
|
||||
).toHaveAttribute("href", "https://example.com/docs");
|
||||
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveText(
|
||||
"Current temperature",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadAcpMarkdown(page: Page, testInfo: TestInfo): Promise<void> {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expectAcpMarkdown(page, 2);
|
||||
await testInfo.attach("acp-markdown-after-reload", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("acp-markdown-after-reload.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
@@ -360,6 +360,16 @@ class AcpRuntime {
|
||||
private modes: NewSessionResponse["modes"] = null;
|
||||
private commandWaiter: (() => void) | null = null;
|
||||
private messageSequence = 0;
|
||||
/**
|
||||
* Fallback ids for streamed chunks that carry no `messageId`, one per
|
||||
* chunk kind. Consecutive chunks of a kind continue the same timeline
|
||||
* item; a chunk of the other kind, a tool call, a plan, a user message,
|
||||
* or a turn boundary ends it (#4699).
|
||||
*/
|
||||
private readonly fallbackChunkIds = new Map<
|
||||
"agent_message_chunk" | "agent_thought_chunk",
|
||||
string
|
||||
>();
|
||||
private closing = false;
|
||||
private processFailed = false;
|
||||
private configTransaction = false;
|
||||
@@ -544,6 +554,7 @@ class AcpRuntime {
|
||||
}
|
||||
const prompt = toAcpPrompt(input.prompt);
|
||||
const turnId = `acp:${input.prompt.clientMessageId}`;
|
||||
this.fallbackChunkIds.clear();
|
||||
this.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
@@ -574,6 +585,7 @@ class AcpRuntime {
|
||||
).then(
|
||||
(response): void => {
|
||||
const state = response.stopReason === "cancelled" ? "canceled" : "completed";
|
||||
this.fallbackChunkIds.clear();
|
||||
this.terminalizeTransientItems(state);
|
||||
this.emit({
|
||||
type: "session.turn",
|
||||
@@ -584,6 +596,7 @@ class AcpRuntime {
|
||||
return undefined;
|
||||
},
|
||||
(error): void => {
|
||||
this.fallbackChunkIds.clear();
|
||||
this.terminalizeTransientItems("failed");
|
||||
this.emit({
|
||||
type: "session.turn",
|
||||
@@ -868,10 +881,19 @@ class AcpRuntime {
|
||||
update.sessionUpdate === "agent_thought_chunk" ||
|
||||
update.sessionUpdate === "user_message_chunk"
|
||||
) {
|
||||
if (update.sessionUpdate === "user_message_chunk") return;
|
||||
if (update.sessionUpdate === "user_message_chunk") {
|
||||
this.fallbackChunkIds.clear();
|
||||
return;
|
||||
}
|
||||
// A chunk of one kind ends the other kind's item, whatever its content
|
||||
// or id: reasoning, then text, then reasoning again are three items.
|
||||
this.fallbackChunkIds.delete(
|
||||
update.sessionUpdate === "agent_message_chunk"
|
||||
? "agent_thought_chunk"
|
||||
: "agent_message_chunk",
|
||||
);
|
||||
if (update.content.type !== "text") return;
|
||||
const fallbackId = `${update.sessionUpdate}:${++this.messageSequence}`;
|
||||
const id = update.messageId ?? fallbackId;
|
||||
const id = this.resolveChunkId(update.sessionUpdate, update.messageId);
|
||||
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
|
||||
this.messages.set(id, text);
|
||||
this.emit({
|
||||
@@ -889,12 +911,30 @@ class AcpRuntime {
|
||||
return;
|
||||
}
|
||||
if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
|
||||
if (update.sessionUpdate === "tool_call") this.fallbackChunkIds.clear();
|
||||
this.reduceToolCall(update);
|
||||
return;
|
||||
}
|
||||
if (update.sessionUpdate === "plan") this.fallbackChunkIds.clear();
|
||||
this.reduceStateUpdate(update);
|
||||
}
|
||||
|
||||
private resolveChunkId(
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk",
|
||||
messageId: string | null | undefined,
|
||||
): string {
|
||||
if (messageId) {
|
||||
this.fallbackChunkIds.delete(kind);
|
||||
return messageId;
|
||||
}
|
||||
let id = this.fallbackChunkIds.get(kind);
|
||||
if (!id) {
|
||||
id = `${kind}:${++this.messageSequence}`;
|
||||
this.fallbackChunkIds.set(kind, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private reduceStateUpdate(update: SessionUpdate): void {
|
||||
if (update.sessionUpdate === "plan") {
|
||||
this.emit({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { AgentSideConnection, PROTOCOL_VERSION, type Agent } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
AgentSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent,
|
||||
type SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runAcpProvider, type AcpStreamMessage } from "./acp.js";
|
||||
@@ -68,6 +73,7 @@ interface ConnectorInstance {
|
||||
closeFromAgent(): void;
|
||||
request(method: string, params: unknown): Promise<unknown>;
|
||||
respond(request: AcpRequestMessage, result: unknown): void;
|
||||
reject(request: AcpRequestMessage, message: string): void;
|
||||
notify(method: string, params: unknown): void;
|
||||
}
|
||||
|
||||
@@ -79,6 +85,8 @@ interface ConnectorResponse {
|
||||
interface ConnectorHarnessOptions {
|
||||
capabilities?: Record<string, unknown>;
|
||||
configOptions?: unknown[];
|
||||
/** Session updates the agent streams for a prompt (keyed by its text) before ending the turn. */
|
||||
promptUpdates?(promptText: string): unknown[];
|
||||
handleMessage?(instance: ConnectorInstance, message: AcpStreamMessage): boolean;
|
||||
handleRequest?(instance: ConnectorInstance, request: AcpRequestMessage): boolean;
|
||||
}
|
||||
@@ -120,6 +128,11 @@ function respondToConnectorRequest(
|
||||
configOptions: options?.configOptions ?? [],
|
||||
});
|
||||
} else if (request.method === "session/prompt") {
|
||||
const { prompt } = request.params as { prompt: Array<{ type: string; text?: string }> };
|
||||
const promptText = prompt.find((part) => part.type === "text")?.text ?? "";
|
||||
for (const update of options?.promptUpdates?.(promptText) ?? []) {
|
||||
instance.notify("session/update", { sessionId: "connector-session", update });
|
||||
}
|
||||
instance.respond(request, { stopReason: "end_turn" });
|
||||
} else if (request.method === "session/close") {
|
||||
instance.respond(request, {});
|
||||
@@ -156,6 +169,8 @@ function connectorHarness(options?: ConnectorHarnessOptions) {
|
||||
return response;
|
||||
},
|
||||
respond: (request, result) => controller.enqueue({ jsonrpc: "2.0", id: request.id, result }),
|
||||
reject: (request, message) =>
|
||||
controller.enqueue({ jsonrpc: "2.0", id: request.id, error: { code: -32603, message } }),
|
||||
notify: (method, params) => controller.enqueue({ jsonrpc: "2.0", method, params }),
|
||||
};
|
||||
instances.push(instance);
|
||||
@@ -1003,3 +1018,265 @@ lines.on("line", (line) => {
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
|
||||
interface Chunk {
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk";
|
||||
text: string;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
it("continues one timeline item per turn instead of one per chunk", async () => {
|
||||
const chunksByPrompt: Record<string, Chunk[]> = {
|
||||
first: [
|
||||
{ kind: "agent_message_chunk", text: "- **Current tem" },
|
||||
{ kind: "agent_message_chunk", text: "perature**: 25°C" },
|
||||
],
|
||||
second: [{ kind: "agent_message_chunk", text: "next turn" }],
|
||||
// An explicit-id text chunk between two id-less thoughts separates them.
|
||||
third: [
|
||||
{ kind: "agent_thought_chunk", text: "think A" },
|
||||
{ kind: "agent_message_chunk", text: "say", messageId: "m-explicit" },
|
||||
{ kind: "agent_thought_chunk", text: "think B" },
|
||||
],
|
||||
};
|
||||
const harness = connectorHarness({
|
||||
promptUpdates: (promptText) =>
|
||||
(chunksByPrompt[promptText] ?? []).map((chunk) => {
|
||||
const update: Record<string, unknown> = {
|
||||
sessionUpdate: chunk.kind,
|
||||
content: { type: "text", text: chunk.text },
|
||||
};
|
||||
if (chunk.messageId) update.messageId = chunk.messageId;
|
||||
return update;
|
||||
}),
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "chunk-acp",
|
||||
label: "Chunk ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
for (const clientMessageId of ["first", "second", "third"]) {
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId,
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: clientMessageId }] },
|
||||
},
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.turn" &&
|
||||
event.turnId === `acp:${clientMessageId}` &&
|
||||
event.state === "completed",
|
||||
);
|
||||
}
|
||||
|
||||
const items = events.flatMap((event) =>
|
||||
event.type === "timeline.item" &&
|
||||
(event.item.type === "assistant_message" || event.item.type === "reasoning")
|
||||
? [{ id: event.item.id, text: event.item.text }]
|
||||
: [],
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{ id: "agent_message_chunk:1", text: "- **Current tem" },
|
||||
{ id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" },
|
||||
{ id: "agent_message_chunk:2", text: "next turn" },
|
||||
{ id: "agent_thought_chunk:3", text: "think A" },
|
||||
{ id: "m-explicit", text: "say" },
|
||||
{ id: "agent_thought_chunk:4", text: "think B" },
|
||||
]);
|
||||
await connection.close();
|
||||
});
|
||||
});
|
||||
|
||||
function textChunk(
|
||||
text: string,
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk" = "agent_message_chunk",
|
||||
messageId?: string,
|
||||
): SessionUpdate {
|
||||
return {
|
||||
sessionUpdate: kind,
|
||||
content: { type: "text", text },
|
||||
...(messageId === undefined ? {} : { messageId }),
|
||||
};
|
||||
}
|
||||
|
||||
async function streamChunkTurns(
|
||||
updates: SessionUpdate[],
|
||||
ending: "completed" | "canceled" | "failed" = "completed",
|
||||
) {
|
||||
let promptCount = 0;
|
||||
const harness = connectorHarness({
|
||||
handleRequest(instance, request) {
|
||||
if (request.method !== "session/prompt") return false;
|
||||
promptCount++;
|
||||
for (const update of promptCount === 1 ? updates : [textChunk("Next turn")]) {
|
||||
instance.notify("session/update", { sessionId: "connector-session", update });
|
||||
}
|
||||
if (promptCount === 1 && ending === "failed") instance.reject(request, "Prompt failed");
|
||||
else
|
||||
instance.respond(request, {
|
||||
stopReason: promptCount === 1 && ending === "canceled" ? "cancelled" : "end_turn",
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "chunk-boundaries",
|
||||
label: "Chunk boundaries",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
try {
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
for (const clientMessageId of ["first", "second"]) {
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId,
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: clientMessageId }] },
|
||||
},
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.turn" &&
|
||||
event.turnId === `acp:${clientMessageId}` &&
|
||||
event.state === (clientMessageId === "first" ? ending : "completed"),
|
||||
);
|
||||
}
|
||||
const messages = new Map<string, { type: string; text: string }>();
|
||||
for (const event of events) {
|
||||
if (
|
||||
event.type === "timeline.item" &&
|
||||
(event.item.type === "assistant_message" || event.item.type === "reasoning")
|
||||
) {
|
||||
messages.set(event.item.id, { type: event.item.type, text: event.item.text });
|
||||
}
|
||||
}
|
||||
return [...messages.values()];
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe("ACP streamed message boundaries", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "tool call",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool",
|
||||
title: "Read",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
{ name: "plan", update: { sessionUpdate: "plan", entries: [] } },
|
||||
{
|
||||
name: "user message",
|
||||
update: { sessionUpdate: "user_message_chunk", content: { type: "text", text: "User" } },
|
||||
},
|
||||
] satisfies Array<{ name: string; update: SessionUpdate }>)(
|
||||
"starts a new message after a $name",
|
||||
async ({ update }) => {
|
||||
expect(
|
||||
await streamChunkTurns([
|
||||
textChunk("Before"),
|
||||
update,
|
||||
textChunk("After "),
|
||||
textChunk("boundary"),
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "assistant_message", text: "Before" },
|
||||
{ type: "assistant_message", text: "After boundary" },
|
||||
{ type: "assistant_message", text: "Next turn" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("continues text while an existing tool receives a status update", async () => {
|
||||
expect(
|
||||
await streamChunkTurns([
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool",
|
||||
title: "Read",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
},
|
||||
textChunk("Still "),
|
||||
{ sessionUpdate: "tool_call_update", toolCallId: "tool", status: "completed" },
|
||||
textChunk("speaking"),
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "assistant_message", text: "Still speaking" },
|
||||
{ type: "assistant_message", text: "Next turn" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps explicit messages separate from neighboring anonymous chunks", async () => {
|
||||
expect(
|
||||
await streamChunkTurns([
|
||||
textChunk("Before"),
|
||||
textChunk("Explicit ", "agent_message_chunk", "message"),
|
||||
textChunk("message", "agent_message_chunk", "message"),
|
||||
textChunk("After"),
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "assistant_message", text: "Before" },
|
||||
{ type: "assistant_message", text: "Explicit message" },
|
||||
{ type: "assistant_message", text: "After" },
|
||||
{ type: "assistant_message", text: "Next turn" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("continues reasoning but starts a new segment after nontext assistant content", async () => {
|
||||
expect(
|
||||
await streamChunkTurns([
|
||||
textChunk("Think ", "agent_thought_chunk"),
|
||||
textChunk("first", "agent_thought_chunk"),
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "image", data: "", mimeType: "image/png" },
|
||||
},
|
||||
textChunk("Then think", "agent_thought_chunk"),
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "reasoning", text: "Think first" },
|
||||
{ type: "reasoning", text: "Then think" },
|
||||
{ type: "assistant_message", text: "Next turn" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["completed", "canceled", "failed"] as const)(
|
||||
"keeps the next turn separate after a %s prompt",
|
||||
async (ending) => {
|
||||
expect(await streamChunkTurns([textChunk("First "), textChunk("turn")], ending)).toEqual([
|
||||
{ type: "assistant_message", text: "First turn" },
|
||||
{ type: "assistant_message", text: "Next turn" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user