diff --git a/docs/developers/daemon-client-adapters/ide.md b/docs/developers/daemon-client-adapters/ide.md new file mode 100644 index 00000000000..4084fce619e --- /dev/null +++ b/docs/developers/daemon-client-adapters/ide.md @@ -0,0 +1,122 @@ +# IDE Daemon Adapter Draft + +## Goal + +Let the VS Code companion extension dogfood Mode B by connecting from the +extension host to `qwen serve` through `DaemonSessionClient`. + +The webview must not call the daemon directly. The extension host owns daemon +URL, token, session id, and SSE replay state, then forwards sanitized app events +to the webview. + +## Proposed Entry Point + +VS Code settings: + +```json +{ + "qwen-code.experimentalDaemon.enabled": true, + "qwen-code.experimentalDaemon.url": "http://127.0.0.1:4170", + "qwen-code.experimentalDaemon.token": "" +} +``` + +Environment fallback for local dogfood: + +```bash +QWEN_IDE_DAEMON_URL=http://127.0.0.1:4170 code . +``` + +## Minimal Flow + +1. Extension host creates `DaemonClient`. +2. Fetch `/capabilities` and verify workspace compatibility. +3. Create or attach with `DaemonSessionClient.createOrAttach()`. +4. Subscribe to `session.events()` in the extension host. +5. Translate daemon events into existing webview messages. +6. Send user prompts through `session.prompt()`. +7. Route cancel/model switch through `session.cancel()` and + `session.setModel()`. +8. Route permission decisions through `session.respondToPermission()`. + +## Relationship To Existing ACP Connection + +The first implementation introduces a sibling connection path, not replace +`AcpConnection`: + +```text +QwenAgentManager + current default -> AcpConnection -> qwen --acp child + experimental -> DaemonIdeConnection -> qwen serve HTTP/SSE +``` + +Both paths should feed the same higher-level webview callbacks where practical. +If an event cannot be faithfully mapped yet, the daemon path should surface a +clear unsupported-state warning rather than silently pretending parity. + +This PR adds `DaemonIdeConnection` as the locally verifiable extension-host +adapter spike. It is not wired into the default `QwenAgentManager` path yet, so +existing VS Code behavior remains ACP subprocess based. + +## Event Mapping Contract + +| Daemon event | IDE handling | +| ---------------------------------------- | -------------------------------------------- | +| `session_update` / `agent_message_chunk` | Existing assistant stream callback | +| `session_update` / `agent_thought_chunk` | Existing thinking stream callback | +| `session_update` / `tool_call` | Existing tool-call update callback | +| `permission_request` | Existing approval UI callback | +| `permission_resolved` | Close/update approval UI | +| `model_switched` | Existing model-state callback where possible | +| `session_died` | Disconnect UI + reconnect affordance | + +Unknown events must be ignored or logged as debug metadata. + +## Runtime Locality UX + +The extension must make daemon locality visible: + +- workspace/files are daemon-host paths +- MCP servers run on the daemon host +- skills load from the daemon filesystem +- provider credentials are resolved in the daemon process environment + +Do not imply that local VS Code extensions, local browser profile, local +localhost services, or local SSH/kube credentials are automatically available to +the daemon. + +## Explicit Non-Goals + +- No default migration away from `AcpConnection`. +- No webview direct-to-daemon transport. +- No daemon-side file CRUD through the IDE until file service boundaries land. +- No reverse RPC for editor/browser/clipboard yet. +- No full remote-control integration. + +## Merge Safety + +- Default off behind setting/env. +- Additive sibling connection path. +- Existing VS Code ACP subprocess path unchanged. +- Daemon token never crosses into webview JavaScript. + +## Validation Plan + +- Unit-test daemon session factory connection and SSE event consumption. +- Unit-test daemon event to existing extension-host callback mapping. +- Unit-test prompt, cancel, model switch, and permission response forwarding. +- Unit-test settings/env resolution when the feature flag is wired. +- Smoke-test local extension host against `qwen serve`: + - prompt streams into chat + - cancel works + - permission UI can resolve a request + - SSE reconnect uses tracked `Last-Event-ID` + +## Blockers Before Default Migration + +- Typed daemon event schema. +- Daemon-stamped client identity. +- Session-scoped permission route. +- Read-only runtime diagnostics. +- FileSystemService boundary and safe file read routes. +- Output sink refactor for CLI/TUI parity. diff --git a/package-lock.json b/package-lock.json index a29a872a61b..25cb88f01e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21458,6 +21458,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@modelcontextprotocol/sdk": "^1.25.1", + "@qwen-code/sdk": "*", "@qwen-code/webui": "*", "cors": "^2.8.5", "dotenv": "^17.1.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 7bb9813b000..5ea9944f14c 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -310,6 +310,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", + "@qwen-code/sdk": "*", "@qwen-code/webui": "*", "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 4629ae932fd..3f83a67942f 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -21,6 +21,10 @@ import { registerNewCommands } from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; +// Keep the dormant daemon IDE adapter on the VSIX bundle path without wiring it +// into the active extension flow yet. +export { createSdkDaemonSessionFactory as __daemonIdeSessionFactoryForBundle } from './services/daemonIdeConnection.js'; + const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; export const DIFF_SCHEME = 'qwen-diff'; diff --git a/packages/vscode-ide-companion/src/services/daemonIdeConnection.test.ts b/packages/vscode-ide-companion/src/services/daemonIdeConnection.test.ts new file mode 100644 index 00000000000..670e9f010f3 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/daemonIdeConnection.test.ts @@ -0,0 +1,917 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { + ContentBlock, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import { + DaemonIdeConnection, + type DaemonIdeEvent, + type DaemonIdeSessionClient, +} from './daemonIdeConnection.js'; + +class EventQueue implements AsyncGenerator { + private events: DaemonIdeEvent[] = []; + private waiters: Array<{ + resolve: (value: IteratorResult) => void; + reject: (error: unknown) => void; + }> = []; + private closed = false; + private failure: unknown; + + async next(): Promise> { + if (this.failure) { + throw this.failure; + } + const event = this.events.shift(); + if (event) { + return { done: false, value: event }; + } + if (this.closed) { + return { done: true, value: undefined }; + } + return await new Promise((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } + + async return(): Promise> { + this.close(); + return { done: true, value: undefined }; + } + + async throw(error?: unknown): Promise> { + this.close(); + throw error; + } + + [Symbol.asyncIterator](): AsyncGenerator { + return this; + } + + push(event: DaemonIdeEvent): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve({ done: false, value: event }); + return; + } + this.events.push(event); + } + + close(): void { + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter.resolve({ done: true, value: undefined }); + } + } + + fail(error: unknown): void { + this.failure = error; + for (const waiter of this.waiters.splice(0)) { + waiter.reject(error); + } + } +} + +interface FakeSession extends DaemonIdeSessionClient { + prompt: ReturnType; + cancel: ReturnType; + setModel: ReturnType; + respondToPermission: ReturnType; +} + +function createFakeSession( + events: EventQueue, + sessionId = 'session-1', +): FakeSession { + return { + sessionId, + workspaceCwd: '/tmp/workspace', + lastEventId: undefined, + prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + events: vi.fn((opts?: { signal?: AbortSignal }) => { + opts?.signal?.addEventListener('abort', () => events.close(), { + once: true, + }); + return events; + }), + cancel: vi.fn().mockResolvedValue(undefined), + setModel: vi.fn().mockResolvedValue({}), + respondToPermission: vi.fn().mockResolvedValue(true), + }; +} + +async function waitFor(assertion: () => void): Promise { + let lastError: unknown; + for (let i = 0; i < 20; i += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + +describe('DaemonIdeConnection', () => { + it('connects through a daemon session factory and forwards session updates', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const factory = vi.fn().mockResolvedValue(session); + const connection = new DaemonIdeConnection(); + const onSessionUpdate = vi.fn(); + connection.onSessionUpdate = onSessionUpdate; + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + workspaceCwd: '/tmp/workspace', + lastEventId: 10, + sessionFactory: factory, + }); + + const update: SessionNotification = { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + } as SessionNotification; + events.push({ id: 11, v: 1, type: 'session_update', data: update }); + + await waitFor(() => expect(onSessionUpdate).toHaveBeenCalledWith(update)); + expect(factory).toHaveBeenCalledWith({ + baseUrl: 'http://127.0.0.1:4170/', + token: undefined, + workspaceCwd: '/tmp/workspace', + modelServiceId: undefined, + lastEventId: 10, + }); + expect(connection.currentSessionId).toBe('session-1'); + expect(connection.lastEventId).toBe(11); + + expect(session.events).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + lastEventId: 10, + resume: true, + }); + + events.close(); + await connection.disconnect(); + }); + + it('serializes concurrent connects without orphaning the first session', async () => { + const firstEvents = new EventQueue(); + const secondEvents = new EventQueue(); + const firstSession = createFakeSession(firstEvents, 'session-1'); + const secondSession = createFakeSession(secondEvents, 'session-2'); + const factory = vi + .fn() + .mockResolvedValueOnce(firstSession) + .mockResolvedValueOnce(secondSession); + const connection = new DaemonIdeConnection(); + + await Promise.all([ + connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: factory, + }), + connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: factory, + }), + ]); + + expect(factory).toHaveBeenCalledTimes(2); + expect(connection.currentSessionId).toBe('session-2'); + expect(connection.isConnected).toBe(true); + + secondEvents.close(); + await connection.disconnect(); + }); + + it('proceeds with a second connect after the first one fails', async () => { + const events = new EventQueue(); + const session = createFakeSession(events, 'session-2'); + const factory = vi + .fn() + .mockRejectedValueOnce(new Error('first connect failed')) + .mockResolvedValueOnce(session); + const connection = new DaemonIdeConnection(); + + const firstConnect = connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: factory, + }); + const secondConnect = connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: factory, + }); + + await expect(firstConnect).rejects.toThrow('first connect failed'); + await secondConnect; + + expect(factory).toHaveBeenCalledTimes(2); + expect(connection.currentSessionId).toBe('session-2'); + expect(connection.isConnected).toBe(true); + + events.close(); + await connection.disconnect(); + }); + + it('sends prompts through the bound daemon session', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + const onEndTurn = vi.fn(); + connection.onEndTurn = onEndTurn; + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + await connection.sendPrompt('summarize this'); + + expect(session.prompt).toHaveBeenCalledWith( + { + prompt: [{ type: 'text', text: 'summarize this' }], + }, + expect.any(AbortSignal), + ); + expect(onEndTurn).toHaveBeenCalledWith('end_turn'); + + const blocks: ContentBlock[] = [ + { type: 'text', text: 'inspect' }, + { + type: 'resource_link', + name: 'image.png', + uri: 'file:///tmp/image.png', + }, + ]; + await connection.sendPrompt(blocks); + expect(session.prompt).toHaveBeenLastCalledWith( + { prompt: blocks }, + expect.any(AbortSignal), + ); + + session.prompt.mockRejectedValueOnce(new Error('prompt failed')); + await expect(connection.sendPrompt('will fail')).rejects.toThrow( + 'prompt failed', + ); + expect(onEndTurn).toHaveBeenLastCalledWith('error'); + + events.close(); + await connection.disconnect(); + }); + + it('responds to daemon permission requests with the selected option id', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + const onPermissionRequest = vi + .fn() + .mockResolvedValue({ optionId: 'proceed_once' }); + connection.onPermissionRequest = onPermissionRequest; + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + const request: RequestPermissionRequest & { requestId: string } = { + requestId: 'request-1', + sessionId: 'session-1', + toolCall: { + toolCallId: 'tool-1', + title: 'Edit file', + kind: 'edit', + rawInput: {}, + }, + options: [ + { optionId: 'proceed_once', kind: 'allow_once', name: 'Allow' }, + { optionId: 'reject_once', kind: 'reject_once', name: 'Reject' }, + ], + } as unknown as RequestPermissionRequest & { requestId: string }; + + events.push({ + id: 12, + v: 1, + type: 'permission_request', + data: request, + }); + + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith('request-1', { + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + } satisfies RequestPermissionResponse), + ); + expect(onPermissionRequest).toHaveBeenCalledWith(request); + + events.close(); + await connection.disconnect(); + }); + + it('cancels permission requests by default and for reject options', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + const request: RequestPermissionRequest & { requestId: string } = { + requestId: 'request-1', + sessionId: 'session-1', + toolCall: { + toolCallId: 'tool-1', + title: 'Edit file', + kind: 'edit', + rawInput: {}, + }, + options: [ + { optionId: 'proceed_once', kind: 'allow_once', name: 'Allow' }, + { optionId: 'reject_once', kind: 'reject_once', name: 'Reject' }, + ], + } as unknown as RequestPermissionRequest & { requestId: string }; + + events.push({ id: 1, v: 1, type: 'permission_request', data: request }); + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith('request-1', { + outcome: { outcome: 'cancelled' }, + } satisfies RequestPermissionResponse), + ); + + connection.onPermissionRequest = vi + .fn() + .mockResolvedValue({ optionId: 'reject_once' }); + events.push({ + id: 2, + v: 1, + type: 'permission_request', + data: { ...request, requestId: 'request-2' }, + }); + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith('request-2', { + outcome: { outcome: 'cancelled' }, + } satisfies RequestPermissionResponse), + ); + + connection.onPermissionRequest = vi + .fn() + .mockResolvedValue({ optionId: 'stale-option' }); + events.push({ + id: 3, + v: 1, + type: 'permission_request', + data: { ...request, requestId: 'request-3' }, + }); + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith('request-3', { + outcome: { outcome: 'cancelled' }, + } satisfies RequestPermissionResponse), + ); + + events.close(); + await connection.disconnect(); + }); + + it('cancels permission requests when the handler throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + connection.onPermissionRequest = vi + .fn() + .mockRejectedValue(new Error('permission UI failed')); + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + const request: RequestPermissionRequest & { requestId: string } = { + requestId: 'request-throws', + sessionId: 'session-1', + toolCall: { + toolCallId: 'tool-1', + title: 'Edit file', + kind: 'edit', + rawInput: {}, + }, + options: [ + { optionId: 'proceed_once', kind: 'allow_once', name: 'Allow' }, + { optionId: 'reject_once', kind: 'reject_once', name: 'Reject' }, + ], + } as unknown as RequestPermissionRequest & { requestId: string }; + + events.push({ id: 4, v: 1, type: 'permission_request', data: request }); + + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith( + 'request-throws', + { + outcome: { outcome: 'cancelled' }, + } satisfies RequestPermissionResponse, + ), + ); + expect(warn).toHaveBeenCalledWith( + '[DaemonIdeConnection] Permission handler failed:', + 'permission UI failed', + ); + + events.close(); + await connection.disconnect(); + warn.mockRestore(); + }); + + it('cancels permission requests when no option id is preferred', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + connection.onPermissionRequest = vi.fn().mockResolvedValue({}); + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + const request: RequestPermissionRequest & { requestId: string } = { + requestId: 'request-fallback', + sessionId: 'session-1', + toolCall: { + toolCallId: 'tool-1', + title: 'Edit file', + kind: 'edit', + rawInput: {}, + }, + options: [ + { optionId: 'allow-by-kind', kind: 'allow_once', name: 'Allow' }, + { optionId: 'reject_once', kind: 'reject_once', name: 'Reject' }, + ], + } as unknown as RequestPermissionRequest & { requestId: string }; + + events.push({ id: 4, v: 1, type: 'permission_request', data: request }); + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith( + 'request-fallback', + { + outcome: { outcome: 'cancelled' }, + } satisfies RequestPermissionResponse, + ), + ); + + events.close(); + await connection.disconnect(); + }); + + it('disconnects without waiting for an in-flight permission callback', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + const onPermissionRequest = vi.fn( + () => new Promise<{ optionId: string }>(() => {}), + ); + connection.onPermissionRequest = onPermissionRequest; + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + const request: RequestPermissionRequest & { requestId: string } = { + requestId: 'request-hangs', + sessionId: 'session-1', + toolCall: { + toolCallId: 'tool-1', + title: 'Edit file', + kind: 'edit', + rawInput: {}, + }, + options: [ + { optionId: 'proceed_once', kind: 'allow_once', name: 'Allow' }, + ], + } as unknown as RequestPermissionRequest & { requestId: string }; + + events.push({ id: 5, v: 1, type: 'permission_request', data: request }); + await waitFor(() => expect(onPermissionRequest).toHaveBeenCalledOnce()); + + await expect( + Promise.race([ + connection.disconnect().then(() => 'disconnected'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 50)), + ]), + ).resolves.toBe('disconnected'); + expect(session.respondToPermission).not.toHaveBeenCalled(); + expect(connection.isConnected).toBe(false); + }); + + it('forwards ask-user-question answers and cancels invalid selections', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + connection.onAskUserQuestion = vi.fn().mockResolvedValue({ + optionId: 'proceed_once', + answers: { q1: 'A' }, + }); + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + const request: RequestPermissionRequest & { requestId: string } = { + requestId: 'ask-1', + sessionId: 'session-1', + toolCall: { + toolCallId: 'tool-ask', + title: 'Ask', + kind: 'ask_user_question', + rawInput: { + questions: [ + { + question: 'Pick one', + header: 'Choice', + options: [{ label: 'A', description: 'A' }], + multiSelect: false, + }, + ], + metadata: { source: 'test' }, + }, + }, + options: [ + { optionId: 'proceed_once', kind: 'allow_once', name: 'Submit' }, + { optionId: 'cancel', kind: 'reject_once', name: 'Cancel' }, + ], + } as unknown as RequestPermissionRequest & { requestId: string }; + + events.push({ id: 3, v: 1, type: 'permission_request', data: request }); + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith('ask-1', { + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { q1: 'A' }, + } as RequestPermissionResponse), + ); + + connection.onAskUserQuestion = vi.fn().mockResolvedValue({ optionId: '' }); + events.push({ + id: 4, + v: 1, + type: 'permission_request', + data: { ...request, requestId: 'ask-2' }, + }); + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith('ask-2', { + outcome: { outcome: 'cancelled' }, + } satisfies RequestPermissionResponse), + ); + + connection.onAskUserQuestion = vi + .fn() + .mockResolvedValue({ optionId: 'stale-option' }); + events.push({ + id: 5, + v: 1, + type: 'permission_request', + data: { ...request, requestId: 'ask-3' }, + }); + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith('ask-3', { + outcome: { outcome: 'cancelled' }, + } satisfies RequestPermissionResponse), + ); + + events.close(); + await connection.disconnect(); + }); + + it('does not route non-question permission requests through ask-user-question UI', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + const onAskUserQuestion = vi.fn(); + const onPermissionRequest = vi + .fn() + .mockResolvedValue({ optionId: 'proceed_once' }); + connection.onAskUserQuestion = onAskUserQuestion; + connection.onPermissionRequest = onPermissionRequest; + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + const request: RequestPermissionRequest & { requestId: string } = { + requestId: 'tool-approval-1', + sessionId: 'session-1', + toolCall: { + toolCallId: 'tool-edit', + title: 'Edit', + kind: 'edit', + rawInput: { + questions: [ + { + question: 'Fake prompt', + header: 'Fake', + options: [{ label: 'Allow', description: 'Allow' }], + multiSelect: false, + }, + ], + }, + }, + options: [ + { optionId: 'proceed_once', kind: 'allow_once', name: 'Allow' }, + { optionId: 'reject_once', kind: 'reject_once', name: 'Reject' }, + ], + } as unknown as RequestPermissionRequest & { requestId: string }; + + events.push({ id: 6, v: 1, type: 'permission_request', data: request }); + + await waitFor(() => + expect(session.respondToPermission).toHaveBeenCalledWith( + 'tool-approval-1', + { + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + } satisfies RequestPermissionResponse, + ), + ); + expect(onAskUserQuestion).not.toHaveBeenCalled(); + expect(onPermissionRequest).toHaveBeenCalledWith(request); + + events.close(); + await connection.disconnect(); + }); + + it('ignores malformed permission events', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + events.push({ + id: 5, + v: 1, + type: 'permission_request', + data: { requestId: 'bad' }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.respondToPermission).not.toHaveBeenCalled(); + + events.close(); + await connection.disconnect(); + }); + + it('forwards cancel and model changes to the daemon session', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + await connection.cancelSession(); + await connection.setModel('qwen3-coder-plus'); + + expect(session.cancel).toHaveBeenCalledOnce(); + expect(session.setModel).toHaveBeenCalledWith('qwen3-coder-plus'); + + events.close(); + await connection.disconnect(); + }); + + it('surfaces session_died as a disconnect', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + const onDisconnected = vi.fn(); + connection.onDisconnected = onDisconnected; + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + events.push({ + id: 13, + v: 1, + type: 'session_died', + data: { sessionId: 'session-1', reason: 'agent exited' }, + }); + + await waitFor(() => + expect(onDisconnected).toHaveBeenCalledWith(null, 'agent exited'), + ); + expect(connection.isConnected).toBe(false); + + events.close(); + }); + + it('ignores stale session_died events from another session', async () => { + const events = new EventQueue(); + const session = createFakeSession(events, 'session-current'); + const connection = new DaemonIdeConnection(); + const onDisconnected = vi.fn(); + connection.onDisconnected = onDisconnected; + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + events.push({ + id: 14, + v: 1, + type: 'session_died', + data: { sessionId: 'session-stale', reason: 'old replay' }, + }); + + await waitFor(() => expect(connection.lastEventId).toBe(14)); + expect(connection.currentSessionId).toBe('session-current'); + expect(onDisconnected).not.toHaveBeenCalled(); + + events.push({ + id: 15, + v: 1, + type: 'session_died', + data: { reason: 'malformed replay' }, + }); + + await waitFor(() => expect(connection.lastEventId).toBe(15)); + expect(connection.currentSessionId).toBe('session-current'); + expect(onDisconnected).not.toHaveBeenCalled(); + + events.close(); + await connection.disconnect(); + }); + + it('does not advance replay state when permission responses fail', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const events = new EventQueue(); + const session = createFakeSession(events); + session.respondToPermission.mockRejectedValueOnce( + new Error('permission response failed'), + ); + const connection = new DaemonIdeConnection(); + connection.onPermissionRequest = vi + .fn() + .mockResolvedValue({ optionId: 'proceed_once' }); + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + const request: RequestPermissionRequest & { requestId: string } = { + requestId: 'request-fails', + sessionId: 'session-1', + toolCall: { + toolCallId: 'tool-1', + title: 'Edit file', + kind: 'edit', + rawInput: {}, + }, + options: [ + { optionId: 'proceed_once', kind: 'allow_once', name: 'Allow' }, + ], + } as unknown as RequestPermissionRequest & { requestId: string }; + + events.push({ id: 31, v: 1, type: 'permission_request', data: request }); + + await waitFor(() => + expect(warn).toHaveBeenCalledWith( + '[DaemonIdeConnection] Permission response failed:', + 'permission response failed', + ), + ); + expect(connection.lastEventId).toBeUndefined(); + + events.close(); + await connection.disconnect(); + warn.mockRestore(); + }); + + it('surfaces event stream failures and normal stream completion', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const failedEvents = new EventQueue(); + failedEvents.fail(new Error('network down')); + const failedSession = createFakeSession(failedEvents); + const failedConnection = new DaemonIdeConnection(); + const failedDisconnected = vi.fn(); + failedConnection.onDisconnected = failedDisconnected; + + await failedConnection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(failedSession), + }); + await waitFor(() => + expect(failedDisconnected).toHaveBeenCalledWith(null, 'daemon_error'), + ); + expect(failedConnection.isConnected).toBe(false); + expect(warn).toHaveBeenCalledWith( + '[DaemonIdeConnection] Event stream failed:', + 'network down', + ); + + const endedEvents = new EventQueue(); + const endedSession = createFakeSession(endedEvents); + const endedConnection = new DaemonIdeConnection(); + const endedDisconnected = vi.fn(); + endedConnection.onDisconnected = endedDisconnected; + + await endedConnection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(endedSession), + }); + endedEvents.close(); + await waitFor(() => + expect(endedDisconnected).toHaveBeenCalledWith(null, 'stream_ended'), + ); + expect(endedConnection.isConnected).toBe(false); + warn.mockRestore(); + }); + + it('continues after handler failures while advancing replay state', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const events = new EventQueue(); + const session = createFakeSession(events); + const connection = new DaemonIdeConnection(); + connection.onSessionUpdate = () => { + throw new Error('handler failed'); + }; + + await connection.connect({ + baseUrl: 'http://127.0.0.1:4170', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + events.push({ + id: 20, + v: 1, + type: 'session_update', + data: { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }, + }); + + await waitFor(() => expect(connection.lastEventId).toBe(20)); + expect(warn).toHaveBeenCalledWith( + '[DaemonIdeConnection] Event handler failed:', + { + sessionId: 'session-1', + eventType: 'session_update', + eventId: 20, + error: 'handler failed', + }, + ); + + events.close(); + await connection.disconnect(); + warn.mockRestore(); + }); + + it('validates daemon base URLs before connecting', async () => { + const connection = new DaemonIdeConnection(); + await expect( + connection.connect({ + baseUrl: 'file:///tmp/daemon.sock', + sessionFactory: vi.fn(), + }), + ).rejects.toThrow('Daemon baseUrl must use http or https scheme'); + + await expect( + connection.connect({ + baseUrl: 'http://user:pass@127.0.0.1:4170', + sessionFactory: vi.fn(), + }), + ).rejects.toThrow('Daemon baseUrl must not contain credentials'); + + await expect( + connection.connect({ + baseUrl: 'http://example.com:4170', + sessionFactory: vi.fn(), + }), + ).rejects.toThrow( + 'Daemon baseUrl must target a loopback address, got "example.com"', + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/daemonIdeConnection.ts b/packages/vscode-ide-companion/src/services/daemonIdeConnection.ts new file mode 100644 index 00000000000..a5f0e3c2c91 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/daemonIdeConnection.ts @@ -0,0 +1,631 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Daemon-backed IDE connection spike. It mirrors the ACP process connection + * shape while replacing the local child process with a qwen serve session. + */ + +import type { + ContentBlock, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, +} from '@agentclientprotocol/sdk'; +// This SDK is intentionally statically imported so the VSIX bundling path can +// include it; keep it pure JS with no native runtime dependency assumptions. +import { + DaemonClient, + DaemonSessionClient as SdkDaemonSessionClient, +} from '@qwen-code/sdk'; +import type { AskUserQuestionRequest } from '../types/acpTypes.js'; + +export interface DaemonIdeEvent { + id?: number; + v: 1; + type: string; + data: unknown; + originatorClientId?: string; +} + +export interface DaemonIdePromptResult { + stopReason?: string; + [key: string]: unknown; +} + +export interface DaemonIdeSetModelResult { + [key: string]: unknown; +} + +export interface DaemonIdeSessionClient { + readonly sessionId: string; + readonly workspaceCwd: string; + readonly lastEventId?: number; + setLastEventId?(lastEventId: number | undefined): void; + prompt( + req: { prompt: ContentBlock[] }, + signal?: AbortSignal, + ): Promise; + events(opts?: { + signal?: AbortSignal; + lastEventId?: number; + resume?: boolean; + }): AsyncGenerator; + cancel(): Promise; + setModel(modelId: string): Promise; + respondToPermission( + requestId: string, + response: RequestPermissionResponse, + ): Promise; +} + +export interface DaemonIdeSessionFactoryOptions { + baseUrl: string; + token?: string; + workspaceCwd?: string; + modelServiceId?: string; + lastEventId?: number; +} + +export type DaemonIdeSessionFactory = ( + opts: DaemonIdeSessionFactoryOptions, +) => Promise; + +export interface DaemonIdeConnectionOptions + extends DaemonIdeSessionFactoryOptions { + sessionFactory?: DaemonIdeSessionFactory; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isLoopbackHostname(hostname: string): boolean { + // Keep this client-side policy aligned with + // packages/cli/src/serve/loopbackBinds.ts when daemon bind rules change. + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === 'localhost' || normalized === '::1') { + return true; + } + if (normalized.startsWith('::ffff:')) { + return isLoopbackHostname(normalized.slice('::ffff:'.length)); + } + const parts = normalized.split('.'); + return ( + parts.length === 4 && + parts[0] === '127' && + parts.every((part) => { + const value = Number(part); + return /^\d+$/.test(part) && value >= 0 && value <= 255; + }) + ); +} + +function validateDaemonBaseUrl(baseUrl: string): string { + const url = new URL(baseUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Daemon baseUrl must use http or https scheme'); + } + if (url.username || url.password) { + throw new Error('Daemon baseUrl must not contain credentials'); + } + if (!isLoopbackHostname(url.hostname)) { + throw new Error( + `Daemon baseUrl must target a loopback address, got "${url.hostname}"`, + ); + } + return url.href; +} + +function normalizePrompt(prompt: string | ContentBlock[]): ContentBlock[] { + return typeof prompt === 'string' + ? ([{ type: 'text', text: prompt }] as ContentBlock[]) + : prompt; +} + +function toSafeErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isPermissionRequestData( + value: unknown, +): value is RequestPermissionRequest & { requestId: string } { + return ( + isRecord(value) && + typeof value['requestId'] === 'string' && + isRecord(value['toolCall']) && + Array.isArray(value['options']) + ); +} + +export function createSdkDaemonSessionFactory(): DaemonIdeSessionFactory { + return async (opts: DaemonIdeSessionFactoryOptions) => { + const daemon = new DaemonClient({ + baseUrl: validateDaemonBaseUrl(opts.baseUrl), + token: opts.token, + }); + const session = await SdkDaemonSessionClient.createOrAttach(daemon, { + workspaceCwd: opts.workspaceCwd, + modelServiceId: opts.modelServiceId, + }); + if (opts.lastEventId !== undefined) { + session.setLastEventId?.(opts.lastEventId); + } + return session; + }; +} + +export class DaemonIdeConnection { + private session: DaemonIdeSessionClient | null = null; + private eventController: AbortController | null = null; + private eventPump: Promise | null = null; + // Authoritative replay cursor for IDE processing. It may intentionally lag + // behind the SDK cursor when permission responses fail, preserving replay. + private lastSeenEventId: number | undefined; + private connectPromise: Promise | null = null; + private pumpGeneration = 0; + + onSessionUpdate: (data: SessionNotification) => void = () => {}; + onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ + optionId?: string; + }> = () => Promise.resolve({ optionId: 'cancel' }); + onAskUserQuestion: (data: AskUserQuestionRequest) => Promise<{ + optionId: string; + answers?: Record; + }> = () => Promise.resolve({ optionId: 'cancel' }); + onEndTurn: (reason?: string) => void = () => {}; + onDisconnected: (code: number | null, signal: string | null) => void = + () => {}; + + async connect(options: DaemonIdeConnectionOptions): Promise { + while (this.connectPromise) { + try { + await this.connectPromise; + } catch (previousError) { + // Let this connect attempt proceed with its own options after the + // in-flight attempt reports its failure to its caller. + console.debug('[DaemonIdeConnection] Previous connect failed:', { + error: toSafeErrorMessage(previousError), + }); + } + } + + const connectPromise = this.connectInternal(options); + this.connectPromise = connectPromise; + try { + await connectPromise; + } finally { + if (this.connectPromise === connectPromise) { + this.connectPromise = null; + } + } + } + + private async connectInternal( + options: DaemonIdeConnectionOptions, + ): Promise { + if (this.session) { + await this.disconnect(); + } + + const factory = options.sessionFactory ?? createSdkDaemonSessionFactory(); + this.session = await factory({ + baseUrl: validateDaemonBaseUrl(options.baseUrl), + token: options.token, + workspaceCwd: options.workspaceCwd, + modelServiceId: options.modelServiceId, + lastEventId: options.lastEventId, + }); + this.lastSeenEventId = options.lastEventId ?? this.session.lastEventId; + + this.eventController = new AbortController(); + const generation = ++this.pumpGeneration; + this.eventPump = this.pumpEvents( + this.session, + this.eventController.signal, + generation, + ); + } + + async sendPrompt( + prompt: string | ContentBlock[], + ): Promise { + const session = this.ensureSession(); + const promptBlocks = normalizePrompt(prompt); + console.debug('[DaemonIdeConnection] Sending prompt:', { + sessionId: session.sessionId, + }); + try { + const response = await session.prompt( + { prompt: promptBlocks }, + this.eventController?.signal, + ); + console.debug('[DaemonIdeConnection] Prompt completed:', { + sessionId: session.sessionId, + stopReason: response.stopReason, + }); + this.onEndTurn(response.stopReason); + return response; + } catch (error) { + console.warn('[DaemonIdeConnection] Prompt failed:', { + sessionId: session.sessionId, + error: toSafeErrorMessage(error), + }); + if (!isAbortError(error)) { + this.onEndTurn('error'); + } + throw error; + } + } + + async cancelSession(): Promise { + const session = this.session; + if (!session) { + console.debug( + '[DaemonIdeConnection] cancelSession ignored without active session', + ); + return; + } + await session.cancel(); + } + + async setModel(modelId: string): Promise { + return await this.ensureSession().setModel(modelId); + } + + async disconnect(): Promise { + const session = this.session; + this.eventController?.abort(); + if (this.eventPump) { + try { + await this.eventPump; + } catch { + /* pump errors are converted into callbacks */ + } + } + this.eventController = null; + this.eventPump = null; + if (session && this.session === session) { + this.session = null; + this.onDisconnected(null, 'disconnected'); + } + } + + get isConnected(): boolean { + return this.session !== null; + } + + get hasActiveSession(): boolean { + return this.session !== null; + } + + get currentSessionId(): string | null { + return this.session?.sessionId ?? null; + } + + get lastEventId(): number | undefined { + return this.lastSeenEventId ?? this.session?.lastEventId; + } + + private ensureSession(): DaemonIdeSessionClient { + if (!this.session) { + throw new Error('Not connected to daemon session'); + } + return this.session; + } + + private async pumpEvents( + session: DaemonIdeSessionClient, + signal: AbortSignal, + generation: number, + ): Promise { + try { + const resumeId = this.lastSeenEventId ?? session.lastEventId; + for await (const event of session.events({ + signal, + lastEventId: resumeId, + resume: true, + })) { + let shouldAdvanceLastSeenEventId = true; + try { + shouldAdvanceLastSeenEventId = await this.handleEvent(event, signal); + } catch (error) { + console.warn('[DaemonIdeConnection] Event handler failed:', { + sessionId: session.sessionId, + eventType: event.type, + eventId: event.id, + error: toSafeErrorMessage(error), + }); + } finally { + if (shouldAdvanceLastSeenEventId && event.id !== undefined) { + this.lastSeenEventId = event.id; + } + } + } + if (!signal.aborted) { + this.clearCurrentSession(session, 'stream_ended'); + } + } catch (error) { + if (!signal.aborted) { + console.warn( + '[DaemonIdeConnection] Event stream failed:', + toSafeErrorMessage(error), + ); + console.debug('[DaemonIdeConnection] Event stream session:', { + sessionId: session.sessionId, + }); + this.eventController?.abort(); + this.clearCurrentSession(session, 'daemon_error'); + } + } finally { + // A disconnect callback may synchronously reconnect before the old pump + // reaches finally; only the active generation may clear eventPump. + if (this.pumpGeneration === generation) { + this.eventPump = null; + } + } + } + + private async handleEvent( + event: DaemonIdeEvent, + signal: AbortSignal, + ): Promise { + switch (event.type) { + case 'session_update': + this.onSessionUpdate(event.data as SessionNotification); + return true; + case 'permission_request': + return await this.handlePermissionRequest(event.data, signal); + case 'session_died': + this.handleSessionDied(event.data); + return true; + default: + console.debug('[DaemonIdeConnection] Ignoring daemon event:', { + sessionId: this.session?.sessionId, + eventType: event.type, + eventId: event.id, + }); + return true; + } + } + + private async handlePermissionRequest( + data: unknown, + signal: AbortSignal, + ): Promise { + if (!isPermissionRequestData(data)) { + console.warn('[DaemonIdeConnection] Malformed permission request data'); + return true; + } + + const requestId = data['requestId']; + const request = data; + const session = this.session; + if (!session) { + console.warn( + '[DaemonIdeConnection] Dropping permission request: not connected', + { requestId }, + ); + return true; + } + const response = await this.resolvePermissionResponseUntilAbort( + request, + signal, + ); + if (!response) { + return true; + } + if (this.session !== session) { + console.warn( + '[DaemonIdeConnection] Permission response dropped: session changed', + { + requestId, + originalSessionId: session.sessionId, + currentSessionId: this.session?.sessionId, + }, + ); + return true; + } + try { + const accepted = await session.respondToPermission(requestId, response); + if (!accepted) { + console.warn( + '[DaemonIdeConnection] Permission response rejected by daemon for request:', + requestId, + ); + console.debug('[DaemonIdeConnection] Permission response session:', { + sessionId: session.sessionId, + }); + } + return true; + } catch (error) { + console.warn( + '[DaemonIdeConnection] Permission response failed:', + toSafeErrorMessage(error), + ); + return false; + } + } + + private async resolvePermissionResponseUntilAbort( + request: RequestPermissionRequest, + signal: AbortSignal, + ): Promise { + if (signal.aborted) { + return undefined; + } + + const responsePromise = this.resolvePermissionResponse(request).catch( + (error: unknown) => { + console.warn( + '[DaemonIdeConnection] Permission handler failed:', + toSafeErrorMessage(error), + ); + return { + outcome: { outcome: 'cancelled' }, + } as RequestPermissionResponse; + }, + ); + + return await new Promise( + (resolve) => { + const onAbort = () => resolve(undefined); + signal.addEventListener('abort', onAbort, { once: true }); + responsePromise.then((response) => { + signal.removeEventListener('abort', onAbort); + resolve(signal.aborted ? undefined : response); + }); + }, + ); + } + + private async resolvePermissionResponse( + request: RequestPermissionRequest, + ): Promise { + const rawInput = request.toolCall?.rawInput; + const toolCallKind = request.toolCall?.kind as string | undefined; + const isAskUserQuestion = + toolCallKind === 'ask_user_question' && + isRecord(rawInput) && + Array.isArray(rawInput['questions']); + + if (isAskUserQuestion) { + const askResponse = await this.onAskUserQuestion({ + sessionId: request.sessionId, + questions: rawInput['questions'] as AskUserQuestionRequest['questions'], + metadata: rawInput['metadata'] as AskUserQuestionRequest['metadata'], + }); + if ( + !askResponse.optionId || + this.isCancelledOption(askResponse.optionId) + ) { + return { outcome: { outcome: 'cancelled' } }; + } + const optionId = this.resolvePermissionOptionId( + request, + askResponse.optionId, + ); + if (!optionId) { + console.warn( + '[DaemonIdeConnection] AskUserQuestion option not advertised; cancelling', + { + requestId: (request as { requestId?: string }).requestId, + optionId: askResponse.optionId, + }, + ); + return { outcome: { outcome: 'cancelled' } }; + } + return { + outcome: { + outcome: 'selected', + optionId, + }, + // Daemon's HTTP permission route preserves top-level passthrough + // fields and the ACP session consumes `answers` from this position. + answers: askResponse.answers, + } as RequestPermissionResponse; + } + + const response = await this.onPermissionRequest(request); + if (!response.optionId || this.isCancelledOption(response.optionId)) { + return { outcome: { outcome: 'cancelled' } }; + } + + const optionId = this.resolvePermissionOptionId(request, response.optionId); + if (!optionId) { + console.warn( + '[DaemonIdeConnection] Permission option not advertised; cancelling', + { + requestId: (request as { requestId?: string }).requestId, + optionId: response.optionId, + }, + ); + return { outcome: { outcome: 'cancelled' } }; + } + + return { + outcome: { + outcome: 'selected', + optionId, + }, + }; + } + + private handleSessionDied(data: unknown): void { + const eventSessionId = + isRecord(data) && typeof data['sessionId'] === 'string' + ? data['sessionId'] + : undefined; + if (!this.session) { + console.debug( + '[DaemonIdeConnection] session_died received with no active session', + { eventSessionId }, + ); + return; + } + if (eventSessionId === undefined) { + console.warn('[DaemonIdeConnection] Malformed session_died event'); + return; + } + if (eventSessionId !== this.session.sessionId) { + return; + } + + const reason = + isRecord(data) && typeof data['reason'] === 'string' + ? data['reason'] + : 'session_died'; + console.debug('[DaemonIdeConnection] Session died:', { + sessionId: this.session.sessionId, + reason, + }); + this.eventController?.abort(); + this.clearCurrentSession(this.session, reason); + } + + private isCancelledOption(optionId?: string): boolean { + return ( + optionId === 'cancel' || + optionId === 'reject' || + (optionId !== undefined && optionId.startsWith('reject_')) + ); + } + + private resolvePermissionOptionId( + request: RequestPermissionRequest, + preferredOptionId?: string, + ): string | undefined { + const options = Array.isArray(request.options) ? request.options : []; + if (!preferredOptionId || options.length === 0) { + return undefined; + } + + return options.some((option) => option.optionId === preferredOptionId) + ? preferredOptionId + : undefined; + } + + private clearCurrentSession( + session: DaemonIdeSessionClient, + reason: string, + ): void { + if (this.session !== session) { + return; + } + console.debug('[DaemonIdeConnection] Clearing session:', { + sessionId: session.sessionId, + reason, + }); + this.eventController = null; + this.eventPump = null; + this.session = null; + this.onDisconnected(null, reason); + } +} + +function isAbortError(error: unknown): boolean { + return isRecord(error) && error['name'] === 'AbortError'; +}