Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 1f49732c63 fix(app): open slash-prefixed Windows drive links
Normalize a slash before the drive only when the workspace identifies Windows path syntax. Otherwise the file preview selects a POSIX root and loses the workspace scope. Keep POSIX paths and existing decoding and line parsing intact.
2026-10-23 14:49:49 +02:00
6 changed files with 67 additions and 267 deletions
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import { resolveWorkspaceFilePaths } from "@/workspace/file-open";
import {
classifyAssistantFileLink,
normalizeInlinePathTarget,
@@ -611,3 +613,43 @@ describe("encoded filename line markers", () => {
},
);
});
describe("slash-prefixed drive links in Windows workspaces", () => {
it.each([
["/E:/demo/repo/tools/tool.vbs", "E:/demo/repo/tools/tool.vbs", undefined],
["/E:/demo/repo/docs/my%20file.md#L12", "E:/demo/repo/docs/my file.md", 12],
["/E:/demo/repo/docs/guide.md:12", "E:/demo/repo/docs/guide.md", 12],
["E:/demo/repo/tools/tool.vbs", "E:/demo/repo/tools/tool.vbs", undefined],
["file:///E:/demo/repo/tools/tool.vbs", "E:/demo/repo/tools/tool.vbs", undefined],
])("resolves %s to the workspace read target", (href, path, lineStart) => {
const workspaceRoot = "E:\\demo\\repo";
const target = parseAssistantFileLink(href, {
workspaceRoot,
decodeHref: true,
explicitLink: true,
});
expect(target).toMatchObject({ raw: href, path });
expect(target?.lineStart).toBe(lineStart);
expect(resolveFilePreviewReadTarget({ path: target!.path, workspaceRoot })).toEqual({
cwd: workspaceRoot,
path,
});
expect(resolveWorkspaceFilePaths({ path: target!.path, workspaceRoot })).toEqual({
absolutePath: path,
relativePath: path.slice("E:/demo/repo/".length),
});
});
it.each(["/home/demo/repo", undefined])(
"preserves POSIX paths with workspace %s",
(workspaceRoot) => {
expect(
parseAssistantFileLink("/E:/demo/repo/tools/tool.vbs", {
workspaceRoot,
decodeHref: true,
explicitLink: true,
})?.path,
).toBe("/E:/demo/repo/tools/tool.vbs");
},
);
});
+10 -2
View File
@@ -260,7 +260,7 @@ export function parseAssistantFileLink(
return fileUrlTarget;
}
const trimmed = value?.trim();
const trimmed = normalizeWindowsLinkPath(value?.trim(), options.workspaceRoot);
if (!trimmed) {
return null;
}
@@ -271,7 +271,7 @@ export function parseAssistantFileLink(
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
if (inlinePathTarget) {
return inlinePathTarget;
return { ...inlinePathTarget, raw: value };
}
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
@@ -350,6 +350,14 @@ export function isFileLookingAssistantToken(value: string): boolean {
return isPlausibleAssistantLocalPath(path);
}
// A drive-based workspace identifies the target host's path syntax, even when
// the client runs elsewhere. Preserve the same spelling on POSIX hosts.
function normalizeWindowsLinkPath(value: string, workspaceRoot?: string): string {
return /^[A-Za-z]:[\\/]/.test(workspaceRoot?.trim() ?? "") && /^\/[A-Za-z]:[\\/]/.test(value)
? value.slice(1)
: value;
}
function parseWindowsFileLink(
trimmed: string,
raw: string,
@@ -28,23 +28,6 @@ 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.";
@@ -60,7 +43,6 @@ 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,8 +83,6 @@ 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") ||
@@ -93,7 +91,7 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("not found")) {
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
@@ -374,19 +374,13 @@ 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 another kind, a tool call, a plan, or a turn boundary
* ends it (#4699).
* 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" | "user_message_chunk",
"agent_message_chunk" | "agent_thought_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;
@@ -505,23 +499,15 @@ class AcpRuntime {
};
let response: Pick<NewSessionResponse, "modes" | "configOptions">;
if (nativeSessionId) {
// 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.
response = await this.call(
this.connection.loadSession({
sessionId: nativeSessionId,
cwd: input.config.cwd,
mcpServers,
_meta: metadata,
}),
);
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({
@@ -912,14 +898,6 @@ 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);
}
@@ -933,14 +911,13 @@ class AcpRuntime {
this.fallbackChunkIds.clear();
return;
}
// 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.
// 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",
);
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}`;
@@ -968,31 +945,8 @@ 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" | "user_message_chunk",
kind: "agent_message_chunk" | "agent_thought_chunk",
messageId: string | null | undefined,
): string {
if (messageId) {
-184
View File
@@ -1500,187 +1500,3 @@ 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-1", text: "What is the " },
{ type: "user_message", id: "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("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();
}
});
});