Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andJacek Tomaszewski 3dcf64a0b8 fix(app): open outside folder links without breaking Files
Outside folder links were routed to the workspace-scoped Files explorer,
which kept an access error until Retry. Route explicit outside folders
to the local file manager or show an outside-workspace message, while
preserving existing file and inside-folder behavior.

Refs #7513

Co-authored-by: Jacek Tomaszewski <jacek@fullstack.house>
2026-10-23 18:22:29 +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
23 changed files with 404 additions and 21 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
+4 -1
View File
@@ -88,6 +88,7 @@ import {
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import {
createWorkspaceFileTabTarget,
type WorkspaceFileLocation,
@@ -448,6 +449,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
view: "files",
});
});
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -455,8 +457,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
openOutsideDirectory: openInlineOutsideDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory],
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
@@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
@@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel();
@@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => {
await pending;
expect(opened).toEqual([]);
});
it("sends folders outside the workspace away from the workspace Files panel", async () => {
const { createInlinePathOpener } = await import("./path-open");
const workspaceFolders: string[] = [];
const outsideFolders: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/Users/me/repo",
client: null,
openFile: () => {},
openDirectory: (directory) => {
workspaceFolders.push(directory);
},
openOutsideDirectory: (directory) => {
outsideFolders.push(directory);
},
});
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
await opener.open({ raw, path: raw }, "preferred");
}
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]);
});
@@ -14,12 +14,23 @@ interface PathOpenInput {
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
type PathOpenTarget =
| { kind: "file" }
| { kind: "directory"; directoryPath: string }
| { kind: "outsideDirectory"; directoryPath: string };
// Workspace paths arrive relative to the root; anything still absolute or
// home-relative lives outside it, where the Files panel cannot list it.
function isOutsideWorkspacePath(pathValue: string): boolean {
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
}
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return { kind: "directory", directoryPath: input.directory };
return isOutsideWorkspacePath(input.directory)
? { kind: "outsideDirectory", directoryPath: input.directory }
: { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
@@ -42,6 +53,7 @@ export function createInlinePathOpener(input: {
client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
}) {
let generation = 0;
return {
@@ -58,6 +70,8 @@ export function createInlinePathOpener(input: {
if (ownGeneration !== generation) return;
if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath);
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
@@ -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;
+1
View File
@@ -67,6 +67,7 @@ export const ar: TranslationResources = {
daemonClientUnavailable: "عميل Daemon غير متوفر",
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
},
connectionStatus: {
+1
View File
@@ -63,6 +63,7 @@ export const en = {
daemonClientUnavailable: "Daemon client unavailable",
daemonClientDisconnected: "Daemon client is disconnected",
noFileFound: "No file found for {{token}}",
folderOutsideWorkspace: "{{path}} is outside this workspace",
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const es: TranslationResources = {
daemonClientUnavailable: "ClienteDaemonno disponible",
daemonClientDisconnected: "El clienteDaemonestá desconectado",
noFileFound: "No se encontró ningún archivo para{{token}}",
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const fr: TranslationResources = {
daemonClientUnavailable: "Client du daemon indisponible",
daemonClientDisconnected: "Le client du daemon est déconnecté",
noFileFound: "Aucun fichier trouvé pour {{token}}",
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const ja: TranslationResources = {
daemonClientUnavailable: "デーモンクライアントが利用できません",
daemonClientDisconnected: "デーモンクライアントが切断されています",
noFileFound: "{{token}}のファイルが見つかりません",
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
},
connectionStatus: {
+1
View File
@@ -67,6 +67,7 @@ export const ko: TranslationResources = {
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const ptBR: TranslationResources = {
daemonClientUnavailable: "Cliente do daemon indisponível",
daemonClientDisconnected: "O cliente do daemon está desconectado",
noFileFound: "Nenhum arquivo encontrado para {{token}}",
folderOutsideWorkspace: "{{path}} está fora deste workspace",
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const ru: TranslationResources = {
daemonClientUnavailable: "Daemon клиента недоступен",
daemonClientDisconnected: "Daemon клиента отключен",
noFileFound: "Файл для {{token}} не найден",
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
},
connectionStatus: {
+1
View File
@@ -67,6 +67,7 @@ export const zhCN: TranslationResources = {
daemonClientUnavailable: "Daemon client 不可用",
daemonClientDisconnected: "Daemon client 已断开连接",
noFileFound: "未找到 {{token}} 对应的文件",
folderOutsideWorkspace: "{{path}} 不在此工作区内",
unexpectedDictationError: "处理听写时发生意外错误。",
},
connectionStatus: {
@@ -63,6 +63,7 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
import { useToast } from "@/contexts/toast-context";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
@@ -3681,6 +3682,7 @@ function WorkspaceScreenContent({
view: "files",
});
});
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -3688,8 +3690,15 @@ function WorkspaceScreenContent({
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
openOutsideDirectory: openMarkdownOutsideDirectory,
}),
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
[
workspaceDirectory,
client,
openMarkdownFile,
openMarkdownDirectory,
openMarkdownOutsideDirectory,
],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
@@ -0,0 +1,36 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/contexts/toast-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
/**
* Opens a folder outside the workspace in the system file manager. The Files
* panel only lists the workspace, so hosts without a local file manager get a
* toast instead.
*/
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
const { t } = useTranslation();
const toast = useToast();
const isLocalExecution = useIsLocalDaemon(serverId);
const { targets } = useDesktopOpenTargets({ isLocalExecution });
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
return useCallback(
(folderPath: string) => {
const showOutsideWorkspace = () =>
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
if (!fileManagerTarget) {
showOutsideWorkspace();
return;
}
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
(error: unknown) => {
console.warn("[open-outside-workspace-folder] open failed", error);
showOutsideWorkspace();
},
);
},
[fileManagerTarget, t, toast],
);
}
@@ -51,6 +51,10 @@ class FakeEditorTargets implements EditorTargetRuntime {
return this.paths.has(targetPath);
}
expandHomePath(targetPath: string): string {
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
}
isAbsolutePath(targetPath: string): boolean {
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
}
@@ -309,6 +313,19 @@ describe("editor target registry", () => {
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
});
it("opens a home-relative folder in the file manager", async () => {
const runtime = new FakeEditorTargets();
runtime.addPath("/home/me/Downloads/contracts");
await openEditorTarget(
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
runtime,
[fileManagerTarget],
);
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
});
it("keeps the platform file-manager ids used by stored preferences", async () => {
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
finderTarget,
@@ -77,10 +77,16 @@ export function getEditorTarget(
}
export async function openEditorTarget(
input: EditorTargetLaunchInput & { editorId: string },
request: EditorTargetLaunchInput & { editorId: string },
runtime: EditorTargetRuntime,
targets: readonly EditorTarget[] = EDITOR_TARGETS,
): Promise<void> {
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
const input = {
...request,
workspacePath: runtime.expandHomePath(request.workspacePath),
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
};
if (!runtime.isAbsolutePath(input.workspacePath)) {
throw new Error("Editor target workspace path must be an absolute local path");
}
@@ -56,6 +56,14 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
function expandHomePath(value: string, homeDirectory: string): string {
if (value === "~") return homeDirectory;
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(homeDirectory, value.slice(2));
}
return value;
}
function resolveExecutable(
commands: readonly string[],
input: {
@@ -140,6 +148,7 @@ export function createEditorTargetRuntime(
env,
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform);
@@ -24,6 +24,8 @@ export interface EditorTargetRuntime {
pathExists(path: string): boolean;
isAbsolutePath(path: string): boolean;
/** Resolves a leading `~` to the user's home directory. */
expandHomePath(path: string): string;
resolveCommand(commands: readonly string[]): string | null;
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
openPath(path: string): Promise<void>;
@@ -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,8 +968,31 @@ 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) {
+184
View File
@@ -1500,3 +1500,187 @@ 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();
}
});
});