mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5ffafa51 | ||
|
|
2ddff573a2 | ||
|
|
d6fa231894 | ||
|
|
fed19f3d29 | ||
|
|
74dc764f0d |
+1
-1
@@ -1 +1 @@
|
||||
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
|
||||
@@ -28,6 +28,23 @@ function failure(message: string) {
|
||||
}
|
||||
|
||||
describe("direct connection failure copy", () => {
|
||||
it.each([
|
||||
"paseo-qa-nonexistent.invalid",
|
||||
"ssl-paseo-qa.invalid",
|
||||
"tls-paseo-qa.invalid",
|
||||
"certificate-paseo-qa.invalid",
|
||||
])(
|
||||
"identifies Android DNS failures for %s without treating the hostname as TLS evidence",
|
||||
(host) => {
|
||||
const message = `Unable to resolve host "${host}": No address associated with hostname`;
|
||||
expect(failure(message)).toEqual({
|
||||
title: "Failed: tcp://example.test:443?ssl=true",
|
||||
detail: labels.hostNotFound,
|
||||
raw: `transport error — ${message}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("identifies Android certificate validation errors without calling them DNS failures", () => {
|
||||
const message =
|
||||
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
|
||||
@@ -43,6 +60,7 @@ describe("direct connection failure copy", () => {
|
||||
["SSL handshake failed", labels.tlsError],
|
||||
["TLS handshake failed", labels.tlsError],
|
||||
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
|
||||
["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound],
|
||||
["Host not found", labels.hostNotFound],
|
||||
["connect ECONNREFUSED", labels.refused],
|
||||
["connection timed out", labels.timedOut],
|
||||
|
||||
@@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: {
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("certpathvalidator") ||
|
||||
@@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: {
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
} else if (rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
|
||||
@@ -374,13 +374,19 @@ class AcpRuntime {
|
||||
/**
|
||||
* 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).
|
||||
* item; a chunk of another kind, a tool call, a plan, or a turn boundary
|
||||
* ends it (#4699).
|
||||
*/
|
||||
private readonly fallbackChunkIds = new Map<
|
||||
"agent_message_chunk" | "agent_thought_chunk",
|
||||
"agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
|
||||
string
|
||||
>();
|
||||
/**
|
||||
* Active while a persisted session loads. ACP agents replay the loaded
|
||||
* session's updates before session/load resolves; the value follows the
|
||||
* open request's history contract.
|
||||
*/
|
||||
private replayWindow: "replay" | "skip" | null = null;
|
||||
private closing = false;
|
||||
private processFailed = false;
|
||||
private configTransaction = false;
|
||||
@@ -499,15 +505,23 @@ class AcpRuntime {
|
||||
};
|
||||
let response: Pick<NewSessionResponse, "modes" | "configOptions">;
|
||||
if (nativeSessionId) {
|
||||
response = await this.call(
|
||||
this.connection.loadSession({
|
||||
sessionId: nativeSessionId,
|
||||
cwd: input.config.cwd,
|
||||
mcpServers,
|
||||
_meta: metadata,
|
||||
}),
|
||||
);
|
||||
// ACP agents replay the loaded session's updates before session/load resolves,
|
||||
// so the native id must be registered first for them to pass the session filter.
|
||||
this.nativeSessionId = nativeSessionId;
|
||||
this.replayWindow = input.history === "replay" ? "replay" : "skip";
|
||||
try {
|
||||
response = await this.call(
|
||||
this.connection.loadSession({
|
||||
sessionId: nativeSessionId,
|
||||
cwd: input.config.cwd,
|
||||
mcpServers,
|
||||
_meta: metadata,
|
||||
}),
|
||||
);
|
||||
await this.drainNotifications();
|
||||
} finally {
|
||||
this.replayWindow = null;
|
||||
}
|
||||
} else {
|
||||
const newSession = await this.call(
|
||||
this.connection.newSession({
|
||||
@@ -898,6 +912,14 @@ class AcpRuntime {
|
||||
|
||||
private sessionUpdate(notification: SessionNotification): void {
|
||||
if (notification.sessionId !== this.nativeSessionId) return;
|
||||
if (this.replayWindow === "skip") return;
|
||||
if (
|
||||
this.replayWindow === "replay" &&
|
||||
notification.update.sessionUpdate === "user_message_chunk"
|
||||
) {
|
||||
this.reduceUserMessageChunk(notification.update);
|
||||
return;
|
||||
}
|
||||
this.reduceUpdate(notification.update);
|
||||
}
|
||||
|
||||
@@ -911,13 +933,14 @@ class AcpRuntime {
|
||||
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.
|
||||
// A chunk of one kind ends every other kind's open 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",
|
||||
);
|
||||
this.fallbackChunkIds.delete("user_message_chunk");
|
||||
if (update.content.type !== "text") return;
|
||||
const id = this.resolveChunkId(update.sessionUpdate, update.messageId);
|
||||
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
|
||||
@@ -945,13 +968,38 @@ class AcpRuntime {
|
||||
this.reduceStateUpdate(update);
|
||||
}
|
||||
|
||||
// Historical user messages arrive only as replayed chunks; a live prompt
|
||||
// already echoes its own user message when it is admitted.
|
||||
private reduceUserMessageChunk(
|
||||
update: Extract<SessionUpdate, { sessionUpdate: "user_message_chunk" }>,
|
||||
): void {
|
||||
this.fallbackChunkIds.delete("agent_message_chunk");
|
||||
this.fallbackChunkIds.delete("agent_thought_chunk");
|
||||
if (update.content.type !== "text") return;
|
||||
const id = this.resolveChunkId("user_message_chunk", update.messageId);
|
||||
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
|
||||
this.messages.set(id, text);
|
||||
this.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id,
|
||||
text,
|
||||
...(update.messageId ? { messageId: update.messageId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveChunkId(
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk",
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
|
||||
messageId: string | null | undefined,
|
||||
): string {
|
||||
if (messageId) {
|
||||
this.fallbackChunkIds.delete(kind);
|
||||
return messageId;
|
||||
// Native IDs identify a message within its chunk kind. Timeline IDs must
|
||||
// also distinguish reasoning from replies and from generated fallback IDs.
|
||||
return JSON.stringify([kind, messageId]);
|
||||
}
|
||||
let id = this.fallbackChunkIds.get(kind);
|
||||
if (!id) {
|
||||
|
||||
@@ -1307,7 +1307,15 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
|
||||
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 }]
|
||||
? [
|
||||
{
|
||||
id: event.item.id,
|
||||
text: event.item.text,
|
||||
...(event.item.type === "assistant_message" && event.item.messageId
|
||||
? { messageId: event.item.messageId }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(items).toEqual([
|
||||
@@ -1315,7 +1323,11 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
|
||||
{ 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: JSON.stringify(["agent_message_chunk", "m-explicit"]),
|
||||
messageId: "m-explicit",
|
||||
text: "say",
|
||||
},
|
||||
{ id: "agent_thought_chunk:4", text: "think B" },
|
||||
]);
|
||||
await connection.close();
|
||||
@@ -1402,6 +1414,21 @@ async function streamChunkTurns(
|
||||
}
|
||||
|
||||
describe("ACP streamed message boundaries", () => {
|
||||
it("keeps reasoning separate from a reply sharing its native message ID", async () => {
|
||||
expect(
|
||||
await streamChunkTurns([
|
||||
textChunk("private ", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("reasoning", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("public ", "agent_message_chunk", "shared-id"),
|
||||
textChunk("reply", "agent_message_chunk", "shared-id"),
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "reasoning", text: "private reasoning" },
|
||||
{ type: "assistant_message", text: "public reply" },
|
||||
{ type: "assistant_message", text: "Next turn" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "tool call",
|
||||
@@ -1500,3 +1527,233 @@ describe("ACP streamed message boundaries", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function userChunk(text: string, messageId?: string): SessionUpdate {
|
||||
return {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text },
|
||||
...(messageId === undefined ? {} : { messageId }),
|
||||
};
|
||||
}
|
||||
|
||||
async function openReplaySession(
|
||||
replay: SessionUpdate[],
|
||||
history: "replay" | "skip",
|
||||
): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> {
|
||||
const harness = connectorHarness({
|
||||
capabilities: { loadSession: true },
|
||||
handleRequest(instance, request) {
|
||||
if (request.method !== "session/load") return false;
|
||||
for (const update of replay) {
|
||||
instance.notify("session/update", { sessionId: "connector-session", update });
|
||||
}
|
||||
instance.respond(request, {
|
||||
sessionId: "connector-session",
|
||||
modes: null,
|
||||
configOptions: [],
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "replay-acp",
|
||||
label: "Replay ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "session.persistence"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
const input = openInput();
|
||||
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
|
||||
input.history = history;
|
||||
await connection.send(input);
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
return { connection, events };
|
||||
}
|
||||
|
||||
function timelineProjection(events: ProviderEvent[]): Array<Record<string, string>> {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== "timeline.item") return [];
|
||||
const item = event.item;
|
||||
if (
|
||||
item.type === "user_message" ||
|
||||
item.type === "assistant_message" ||
|
||||
item.type === "reasoning"
|
||||
) {
|
||||
return [{ type: item.type, id: item.id, text: item.text }];
|
||||
}
|
||||
if (item.type === "tool_call") return [{ type: item.type, id: item.id, status: item.status }];
|
||||
return [{ type: item.type, id: item.id }];
|
||||
});
|
||||
}
|
||||
|
||||
describe("ACP session history replay", () => {
|
||||
it("restores the timeline replayed before session/load resolves", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("What is the ", "user-1"),
|
||||
userChunk("temperature?", "user-1"),
|
||||
textChunk("Checking the sensor", "agent_thought_chunk"),
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "Read sensor",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
},
|
||||
{ sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: "completed" },
|
||||
textChunk("It is 25°C"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: '["user_message_chunk","user-1"]', text: "What is the " },
|
||||
{
|
||||
type: "user_message",
|
||||
id: '["user_message_chunk","user-1"]',
|
||||
text: "What is the temperature?",
|
||||
},
|
||||
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
|
||||
{ type: "tool_call", id: "tool-1", status: "running" },
|
||||
{ type: "tool_call", id: "tool-1", status: "completed" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:2", text: "It is 25°C" },
|
||||
]);
|
||||
const lastReplayed = events.findLastIndex((event) => event.type === "timeline.item");
|
||||
const opened = events.findIndex((event) => event.type === "session.opened");
|
||||
expect(lastReplayed).toBeLessThan(opened);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps replayed user, reasoning and reply chunks separate when their native IDs match", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("What is the ", "shared-id"),
|
||||
userChunk("temperature?", "shared-id"),
|
||||
textChunk("Checking ", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("the sensor", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("It is ", "agent_message_chunk", "shared-id"),
|
||||
textChunk("25°C", "agent_message_chunk", "shared-id"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
const items = new Map(
|
||||
events.flatMap((event) =>
|
||||
event.type === "timeline.item" ? [[event.item.id, event.item] as const] : [],
|
||||
),
|
||||
);
|
||||
expect([...items.values()]).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
id: '["user_message_chunk","shared-id"]',
|
||||
messageId: "shared-id",
|
||||
text: "What is the temperature?",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: '["agent_thought_chunk","shared-id"]',
|
||||
text: "Checking the sensor",
|
||||
},
|
||||
{
|
||||
type: "assistant_message",
|
||||
id: '["agent_message_chunk","shared-id"]',
|
||||
messageId: "shared-id",
|
||||
text: "It is 25°C",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("first "),
|
||||
userChunk("question"),
|
||||
textChunk("answer"),
|
||||
userChunk("second question"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "user_message_chunk:1", text: "first " },
|
||||
{ type: "user_message", id: "user_message_chunk:1", text: "first question" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:2", text: "answer" },
|
||||
{ type: "user_message", id: "user_message_chunk:3", text: "second question" },
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the live prompt echo when the agent echoes it as a user chunk", async () => {
|
||||
const harness = connectorHarness({
|
||||
capabilities: { loadSession: true },
|
||||
promptUpdates: (promptText) => [
|
||||
{ sessionUpdate: "user_message_chunk", content: { type: "text", text: promptText } },
|
||||
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Acknowledged" } },
|
||||
],
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "echo-acp",
|
||||
label: "Echo ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "session.persistence"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
const input = openInput();
|
||||
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
|
||||
input.history = "replay";
|
||||
await connection.send(input);
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "echo-1",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.turn" &&
|
||||
event.turnId === "acp:echo-1" &&
|
||||
event.state === "completed",
|
||||
);
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "echo-1", text: "hello" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:1", text: "Acknowledged" },
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops replayed updates when the open request asks to skip history", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[userChunk("What is in the README?", "user-1"), textChunk("It describes the project.")],
|
||||
"skip",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user