Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] fb5ffafa51 test(plugin): cover kind-qualified ACP history identities 2026-10-23 19:44:42 +02:00
paseo-bot[bot]andYanlin 2ddff573a2 fix(plugin): keep ACP reasoning separate from replies
Scope explicit timeline identities by chunk kind so a provider sharing a native message ID cannot merge reasoning into the assistant reply. Preserve the native assistant messageId for consumers.

Co-authored-by: Yanlin <127009+cncolder@users.noreply.github.com>
2026-10-23 19:41:02 +02:00
Ni Xiaoandnistream d6fa231894 fix(plugin): restore ACP history replayed during session load (#7717)
ACP agents replay a loaded session's session/update notifications before
session/load resolves, but AcpRuntime.open registered nativeSessionId only
after the response, so the session filter dropped every replayed update and
restored sessions opened with an empty timeline.

Register the native id before the request, drain the notification lane once
the response arrives so the replay completes before session.opened, and
follow the open request's history contract: replayed updates rebuild the
timeline under history "replay" and are dropped under history "skip".
Replayed user_message_chunk updates now restore user messages through the
chunk fallback scheme, so multi-chunk messages keep their boundaries; live
user chunks stay dropped because admitPrompt already echoes the prompt.

Co-authored-by: nistream <nistream@users.noreply.github.com>
2026-10-23 18:05:54 +02:00
paseo-bot[bot] fed19f3d29 fix(app): identify Android DNS connection failures (#7701)
Recognize explicit name-resolution failures before TLS keywords in hostnames, while preserving certificate-not-found handling and raw details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-23 14:57:19 +02:00
paseo-ai[bot] 74dc764f0d fix: update lockfile signatures and Nix hash [skip ci] 2026-10-23 12:38:01 +00:00
5 changed files with 344 additions and 19 deletions
+1 -1
View File
@@ -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) {
+259 -2
View File
@@ -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();
}
});
});