From 1acc30c4c1749c34b4b76b0b30cb507f7bf90b52 Mon Sep 17 00:00:00 2001 From: Lixxx1 Date: Tue, 8 Sep 2026 20:32:27 +0800 Subject: [PATCH 01/14] feat: add authenticated DSH 0.1.2 transport --- package.json | 4 +- pnpm-lock.yaml | 27 ++++ src/dsh-connection.ts | 189 +++++++++++++++++++++++ src/dsh-remote-api.ts | 42 +++++ src/extension.ts | 3 +- src/launch.ts | 10 +- src/runtime-endpoint.ts | 52 ++++--- src/runtime-output.ts | 53 +++++++ src/runtime.ts | 154 +++++++++++++++---- tests/dsh-connection.spec.ts | 269 +++++++++++++++++++++++++++++++++ tests/dsh-remote-api.spec.ts | 39 +++++ tests/launch.spec.ts | 7 + tests/runtime-endpoint.spec.ts | 36 ++++- tests/runtime-output.spec.ts | 43 ++++++ tests/runtime-startup.spec.ts | 210 +++++++++++++++++++++++++ 15 files changed, 1078 insertions(+), 60 deletions(-) create mode 100644 src/dsh-connection.ts create mode 100644 src/dsh-remote-api.ts create mode 100644 src/runtime-output.ts create mode 100644 tests/dsh-connection.spec.ts create mode 100644 tests/dsh-remote-api.spec.ts create mode 100644 tests/runtime-output.spec.ts create mode 100644 tests/runtime-startup.spec.ts diff --git a/package.json b/package.json index 32428ec..74ef937 100644 --- a/package.json +++ b/package.json @@ -228,13 +228,15 @@ "publish:marketplace": "vsce publish --readme-path README.marketplace.md" }, "devDependencies": { + "@modelcontextprotocol/sdk": "1.30.0", "@types/node": "^22.20.0", "@types/vscode": "^1.100.0", + "@types/ws": "^8.18.1", "@vscode/vsce": "^3.6.0", - "@modelcontextprotocol/sdk": "1.30.0", "esbuild": "^0.25.12", "typescript": "^6.0.3", "vitest": "^4.1.8", + "ws": "^8.21.3", "zod": "^3.25.76" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeb1e25..90d83b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@types/vscode': specifier: ^1.100.0 version: 1.125.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@vscode/vsce': specifier: ^3.6.0 version: 3.9.2 @@ -29,6 +32,9 @@ importers: vitest: specifier: ^4.1.8 version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.25.12)) + ws: + specifier: ^8.21.3 + version: 8.21.3 zod: specifier: ^3.25.76 version: 3.25.76 @@ -465,6 +471,9 @@ packages: '@types/vscode@1.125.0': resolution: {integrity: sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typespec/ts-http-runtime@0.3.8': resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} engines: {node: '>=22.0.0'} @@ -1891,6 +1900,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -2308,6 +2329,10 @@ snapshots: '@types/vscode@1.125.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.1 + '@typespec/ts-http-runtime@0.3.8': dependencies: http-proxy-agent: 7.0.2 @@ -3778,6 +3803,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 diff --git a/src/dsh-connection.ts b/src/dsh-connection.ts new file mode 100644 index 0000000..c07150f --- /dev/null +++ b/src/dsh-connection.ts @@ -0,0 +1,189 @@ +import { createHash, randomUUID } from 'node:crypto' +import WebSocket from 'ws' +import { redactDshSecrets } from './runtime-output.js' + +export class DshConnectionError extends Error { + constructor( + readonly code: string, + message: string, + readonly status?: number, + ) { + super(message) + this.name = 'DshConnectionError' + } +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Only the local, root DSH endpoint may receive a launch token or cookie. */ +export function dshLocalUrl(value: URL): URL { + const url = new URL(value.href) + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' + || url.username !== '' || url.password !== '' || url.pathname !== '/' + || url.hash !== '' || url.port === '0') { + throw new DshConnectionError('invalid-url', 'DSH must use an HTTP root URL on 127.0.0.1.') + } + const keys = [...url.searchParams.keys()] + if (keys.some(key => key !== 'token') || keys.length > 1 + || (keys.length === 1 && !/^[A-Za-z0-9_-]+$/.test(url.searchParams.get('token') ?? ''))) { + throw new DshConnectionError('invalid-url', 'DSH returned an invalid launch URL.') + } + return url +} + +/** + * Extension-host-owned HTTP/WebSocket carrier for DSH 0.1.2 Remotes. + * Credentials are private fields, never part of runtime state or JSON output. + * Redirects and automatic RPC retries are deliberately disabled. + */ +export class DshConnection { + readonly #origin: string + readonly #lifetime = new AbortController() + readonly #sockets = new Set() + #cookie: string | undefined + #launchUrl: URL | undefined + #authenticating = false + + constructor(baseUrl: URL) { + const url = dshLocalUrl(baseUrl) + if (url.search !== '') throw new DshConnectionError('invalid-url', 'Authenticate the DSH launch URL explicitly.') + this.#origin = url.origin + } + + get baseUrl(): URL { return new URL(this.#origin) } + get authenticated(): boolean { return this.#cookie !== undefined && !this.#lifetime.signal.aborted } + + /** Called only by the explicit Open in Browser action, never published to the Webview. */ + browserUrl(): URL { + return new URL(this.#launchUrl?.href ?? this.#origin) + } + + async authenticate(launchUrl: URL, signal?: AbortSignal): Promise { + const url = dshLocalUrl(launchUrl) + if (url.origin !== this.#origin || !url.searchParams.has('token')) { + throw new DshConnectionError('authentication-required', 'Reopen the URL printed by DSH, or restart its managed runtime.') + } + if (this.#authenticating) throw new DshConnectionError('authentication-pending', 'DSH authentication is already in progress.') + this.#authenticating = true + this.#cookie = undefined + this.#launchUrl = undefined + try { + const response = await fetch(url, { + method: 'GET', redirect: 'manual', + signal: this.signal(10_000, signal), + }) + try { + if (response.status !== 303 || response.headers.get('location') !== '/') { + throw new DshConnectionError('authentication-failed', 'DSH did not accept its launch token. Restart the runtime.', response.status) + } + const cookieName = `dsh-auth-${createHash('sha256').update(url.host).digest('base64url')}` + const matches = response.headers.getSetCookie().filter(cookie => cookie.startsWith(`${cookieName}=`)) + const cookie = matches[0]?.split(';', 1)[0] + if (matches.length !== 1 || cookie === undefined + || !/^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(cookie.slice(cookieName.length + 1)) + || cookie.length > 4096) { + throw new DshConnectionError('authentication-failed', 'DSH did not return a valid authentication cookie.') + } + this.#lifetime.signal.throwIfAborted() + signal?.throwIfAborted() + this.#cookie = cookie + this.#launchUrl = url + } finally { + await response.body?.cancel() + } + } catch (error) { + if (error instanceof DshConnectionError) throw error + // Native fetch failures can include their request URL (and its token). + throw new DshConnectionError('authentication-failed', 'Could not authenticate with DSH. Check the runtime and reconnect.') + } finally { + this.#authenticating = false + } + } + + async call(endpoint: string, args: Record, timeoutMs = 30_000, signal?: AbortSignal): Promise { + // Prevent URL traversal, query injection, and credential-bearing redirects. + if (!/^(?:\$events|[A-Za-z][A-Za-z0-9]*)\/[A-Za-z][A-Za-z0-9]*$/.test(endpoint)) { + throw new DshConnectionError('invalid-endpoint', 'Invalid DSH Remote endpoint.') + } + const rpcId = randomUUID() + try { + const response = await fetch(new URL(`/api/${endpoint}`, this.#origin), { + method: 'POST', redirect: 'manual', + headers: { 'content-type': 'application/json', ...this.headers() }, + body: JSON.stringify({ type: 'client-request', rpcId, method: endpoint, payload: { args } }), + signal: this.signal(timeoutMs, signal), + }) + if (!response.ok) { + await response.body?.cancel() + if (response.status === 401 || response.status === 403) { + this.#cookie = undefined + throw new DshConnectionError('authentication-required', 'DSH authentication expired or was refused. Reconnect the runtime.', response.status) + } + throw new DshConnectionError('http-error', `DSH ${endpoint} failed: HTTP ${String(response.status)}.`, response.status) + } + let envelope: unknown + try { + envelope = await response.json() + } catch (error) { + if (error instanceof SyntaxError) throw new DshConnectionError('invalid-response', `DSH ${endpoint} returned invalid JSON.`) + throw error + } + if (!record(envelope) || envelope.type !== 'server-response' || envelope.rpcId !== rpcId + || !record(envelope.result) || typeof envelope.result.ok !== 'boolean') { + throw new DshConnectionError('invalid-response', `Invalid DSH response for ${endpoint}.`) + } + const result = envelope.result + if (result.ok === false) { + const error = result.error + if (!record(error) || typeof error.code !== 'string' || typeof error.message !== 'string') { + throw new DshConnectionError('invalid-response', `Invalid DSH error for ${endpoint}.`) + } + throw new DshConnectionError(this.safe(error.code), this.safe(error.message)) + } + return result.value as T + } catch (error) { + if (error instanceof DshConnectionError) throw error + if (this.#lifetime.signal.aborted || signal?.aborted === true) { + throw new DshConnectionError('cancelled', `DSH ${endpoint} was cancelled.`) + } + throw new DshConnectionError('transport-error', `DSH ${endpoint} did not complete. Check the connection before retrying.`) + } + } + + /** The stream multiplexer is the only credential-bearing WebSocket target. */ + openStreamSocket(): WebSocket { + if (this.#lifetime.signal.aborted) throw new DshConnectionError('cancelled', 'The DSH connection has been disposed.') + const url = new URL('/api/remote.mux', this.#origin) + url.protocol = 'ws:' + const socket = new WebSocket(url, { + headers: this.headers(), followRedirects: false, handshakeTimeout: 10_000, + }) + this.#sockets.add(socket) + // Avoid an unhandled EventEmitter error if the owner disposes while connecting. + socket.on('error', () => {}) + socket.once('close', () => { this.#sockets.delete(socket) }) + return socket + } + + dispose(): void { + this.#lifetime.abort() + this.#cookie = undefined + this.#launchUrl = undefined + for (const socket of this.#sockets) socket.terminate() + this.#sockets.clear() + } + + private headers(): Record { + return this.#cookie === undefined ? {} : { cookie: this.#cookie } + } + + private signal(timeoutMs: number, signal?: AbortSignal): AbortSignal { + return AbortSignal.any([this.#lifetime.signal, AbortSignal.timeout(timeoutMs), ...(signal === undefined ? [] : [signal])]) + } + + private safe(value: string): string { + return redactDshSecrets(value, [this.#launchUrl?.searchParams.get('token') ?? '', this.#cookie ?? '', this.#cookie?.split('=')[1] ?? '']) + } +} diff --git a/src/dsh-remote-api.ts b/src/dsh-remote-api.ts new file mode 100644 index 0000000..c471759 --- /dev/null +++ b/src/dsh-remote-api.ts @@ -0,0 +1,42 @@ +import { randomUUID } from 'node:crypto' +import type { DshConnection } from './dsh-connection.js' +import type { PromptImage, PromptMode, QueueAction, SessionSummary } from './dsh-client.js' + +/** Named-argument contracts of the 0.1.2 Session Remotes, independent of the UI. */ +export class DshRemoteApi { + constructor(private readonly connection: DshConnection) {} + + listSessions(): Promise<{ items: SessionSummary[] }> { + return this.connection.call('session/list', { _request: {} }) + } + + createSession(cwd: string): Promise<{ sessionId: string; agentPreset?: string }> { + return this.connection.call('session/create', { request: { cwd } }) + } + + renameSession(sessionId: string, title: string): Promise<{ title: string; seq?: number }> { + return this.connection.call('session/rename', { request: { sessionId, title } }) + } + + prompt(sessionId: string, text: string, images: readonly PromptImage[] = [], mode: PromptMode = 'queue'): Promise<{ accepted: true }> { + const content: Array = images.map(image => ({ + type: 'image', mediaType: image.mediaType, data: image.data, + ...(image.name === undefined ? {} : { name: image.name }), + })) + if (text !== '') content.push({ type: 'text', text }) + return this.connection.call('session/prompt', { + request: { + requestId: randomUUID(), sessionId, mode, content, + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + }) + } + + cancel(sessionId: string): Promise<{ accepted: true }> { + return this.connection.call('session/cancel', { request: { sessionId } }) + } + + updateQueue(sessionId: string, itemId: string, action: QueueAction): Promise<{ accepted: true }> { + return this.connection.call('session/updateQueue', { request: { sessionId, itemId, action } }) + } +} diff --git a/src/extension.ts b/src/extension.ts index 888d9fb..a534b72 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1948,7 +1948,8 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(vscode.commands.registerCommand('deepseekHarness.showOutput', () => { output.show(true) })) context.subscriptions.push(vscode.commands.registerCommand('deepseekHarness.openInBrowser', async () => { try { - const uri = await runtime.start(controller.cwd === '' ? undefined : vscode.Uri.file(controller.cwd)) + await runtime.start(controller.cwd === '' ? undefined : vscode.Uri.file(controller.cwd)) + const uri = vscode.Uri.parse(runtime.connection.browserUrl().href) await vscode.env.openExternal(await vscode.env.asExternalUri(uri)) } catch (error) { void vscode.window.showErrorMessage(error instanceof Error ? error.message : String(error)) diff --git a/src/launch.ts b/src/launch.ts index 0c3dd9c..8f0656a 100644 --- a/src/launch.ts +++ b/src/launch.ts @@ -276,8 +276,14 @@ function resolveTsxImport(sourceRoot: string): string { /** Extract the URL printed by the official web-app Cordis plugin. */ export function parseDshWebUrl(line: string): string | undefined { - const match = /(?:^|\s)dsh web:\s+(http:\/\/127\.0\.0\.1:\d+)(?:\s|$)/.exec(line) - return match?.[1] + const match = /(?:^|\s)dsh web:\s+(http:\/\/127\.0\.0\.1(?::\d+)?(?:\/)?(?:\?token=[A-Za-z0-9_-]+)?)(?:\s|$)/.exec(line) + if (match?.[1] === undefined) return undefined + try { + const url = new URL(match[1]) + return url.port !== '0' ? match[1] : undefined + } catch { + return undefined + } } /** diff --git a/src/runtime-endpoint.ts b/src/runtime-endpoint.ts index 521e289..c16ce12 100644 --- a/src/runtime-endpoint.ts +++ b/src/runtime-endpoint.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto' +import { DshConnection, DshConnectionError } from './dsh-connection.js' export const DEFAULT_DSH_SERVER_URL = 'http://127.0.0.1:3080' export const DEFAULT_DSH_WEB_ARGS = ['web', '--host', '127.0.0.1', '--port', '0'] as const @@ -7,27 +7,43 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -/** Verify that a local endpoint speaks the official DSH RPC protocol. */ -export async function probeDshServer(baseUrl: URL, timeoutMs = 750): Promise { - const rpcId = randomUUID() +export type DshServerProbe = + | { kind: 'ready' } + | { kind: 'authentication-required' } + | { kind: 'unsupported' } + | { kind: 'unavailable' } + | { kind: 'invalid-response' } + +/** Probe the new read-only contract; a 401 must not be mistaken for a stopped server. */ +export async function probeDshServer(connection: DshConnection, timeoutMs = 750): Promise { try { - const response = await fetch(new URL('/api/session.list', baseUrl), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ type: 'client-request', rpcId, method: 'session.list', payload: {} }), - signal: AbortSignal.timeout(timeoutMs), - }) - if (!response.ok) return false - const envelope: unknown = await response.json() - if (!isRecord(envelope) || envelope.type !== 'server-response' || envelope.rpcId !== rpcId) return false - const result = envelope.result - if (!isRecord(result) || result.ok !== true || !isRecord(result.value)) return false - return Array.isArray(result.value.items) - } catch { - return false + const value = await connection.call('session/list', { _request: {} }, timeoutMs) + return { kind: isRecord(value) && Array.isArray(value.items) ? 'ready' : 'invalid-response' } + } catch (error) { + if (error instanceof DshConnectionError) { + if (error.code === 'authentication-required') return { kind: 'authentication-required' } + if (error.status === 404 || error.code === 'gateway/not-found') return { kind: 'unsupported' } + if (error.code === 'invalid-response') return { kind: 'invalid-response' } + } + return { kind: 'unavailable' } } } +export const DSH_UPGRADE_MESSAGE = 'This sidebar requires DeepSeek Harness 0.1.2-rc.1 or a compatible newer release. Update DSH, then reconnect.' + +/** Unknown/source versions are checked against the actual Remote contract at startup. */ +export function assertSupportedDshVersion(version: string | undefined): void { + const match = /(?:^|\s|v)(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?(?:\+[\w.-]+)?(?:\s|$)/.exec(version ?? '') + if (match === null) return + const major = Number(match[1]) + const minor = Number(match[2]) + const patch = Number(match[3]) + if (major > 0 || minor > 1 || (minor === 1 && patch > 2)) return + if (minor === 1 && patch === 2 + && (match[4] === undefined || /^rc\.[1-9]\d*$/.test(match[4]))) return + throw new Error(DSH_UPGRADE_MESSAGE) +} + /** Explicit launch settings take precedence over automatic endpoint reuse. */ export function shouldProbeExistingDsh( enabled: boolean, diff --git a/src/runtime-output.ts b/src/runtime-output.ts new file mode 100644 index 0000000..8eaca45 --- /dev/null +++ b/src/runtime-output.ts @@ -0,0 +1,53 @@ +import { stripVTControlCharacters } from 'node:util' + +export function redactDshSecrets(value: string, secrets: readonly string[] = []): string { + let text = stripVTControlCharacters(value) + for (const secret of secrets) { + if (secret !== '') text = text.split(secret).join('[redacted]') + } + return text + .replace(/([?&](?:token|access_token)=)[^\s&#"']*/gi, '$1[redacted]') + .replace(/(\b(?:authorization|cookie|set-cookie)\s*:\s*)[^\r\n]*/gi, '$1[redacted]') +} + +/** Buffer before logging: a token (or even the word `token`) can span chunks. */ +export class RuntimeOutput { + private buffer = '' + private discarding = false + + constructor( + private readonly log: (line: string) => void, + private readonly accept: (line: string) => void, + private readonly maxLineLength = 65_536, + ) {} + + consume(chunk: string): void { + // Split first so even a very large incoming chunk never becomes retained state. + const parts = chunk.split(/\r\n|\r|\n/) + for (let index = 0; index < parts.length; index++) { + const part = parts[index] ?? '' + if (!this.discarding) { + if (this.buffer.length + part.length > this.maxLineLength) { + this.buffer = '' + this.discarding = true + this.log('[runtime] oversized output line omitted') + } else { + this.buffer += part + } + } + if (index < parts.length - 1) this.flush() + } + } + + flush(): void { + const line = this.buffer + this.buffer = '' + if (this.discarding) { + this.discarding = false + return + } + if (line === '') return + this.log(redactDshSecrets(line)) + this.accept(stripVTControlCharacters(line)) + } +} diff --git a/src/runtime.ts b/src/runtime.ts index 2239075..2bb7d7d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -14,6 +14,9 @@ import { type RuntimeLaunchPreparation, } from './runtime-launch.js' import { terminateProcessTree } from './process-tree.js' +import { DshConnection } from './dsh-connection.js' +import { assertSupportedDshVersion, DSH_UPGRADE_MESSAGE } from './runtime-endpoint.js' +import { redactDshSecrets, RuntimeOutput } from './runtime-output.js' export type RuntimeOwnership = 'external' | 'managed' @@ -36,6 +39,9 @@ function deferredStart(): PendingStart { resolve = ok reject = fail }) + // stop() can reject while launch preparation is still awaited; the caller + // receives the same rejection once that preparation settles. + void promise.catch(() => {}) return { resolve, reject, promise } } @@ -84,7 +90,11 @@ export class DshRuntime implements vscode.Disposable { private child: ChildProcessWithoutNullStreams | undefined private pending: PendingStart | undefined private startupTimer: NodeJS.Timeout | undefined - private stdoutBuffer = '' + private _connection: DshConnection | undefined + private authenticating: DshConnection | undefined + private startTask: Promise | undefined + private stopTask: Promise | undefined + private stopRevision = 0 private stopping = false private launchPreparation: RuntimeLaunchPreparation | undefined private launchPreparationRelease: Promise | undefined @@ -102,16 +112,36 @@ export class DshRuntime implements vscode.Disposable { return this._state } + get connection(): DshConnection { + if (this._state.kind !== 'ready' || this._connection === undefined) { + throw new Error('DeepSeek Harness is not connected.') + } + return this._connection + } + private publish(state: RuntimeState): void { this._state = state this.changes.fire(state) } - async start(workspaceUri?: vscode.Uri): Promise { + start(workspaceUri?: vscode.Uri): Promise { + if (this.startTask !== undefined) return this.startTask + const task = this.startRuntime(workspaceUri) + this.startTask = task + const clear = (): void => { if (this.startTask === task) this.startTask = undefined } + void task.then(clear, clear) + return task + } + + private async startRuntime(workspaceUri?: vscode.Uri): Promise { + const revision = this.stopRevision + if (this.stopTask !== undefined) await this.stopTask + if (revision !== this.stopRevision) throw new Error('DSH runtime stopped.') if (this._state.kind === 'ready') return this._state.localUri if (this.pending !== undefined) return this.pending.promise - if (this.child !== undefined) await this.stop() + if (this.child !== undefined) await this.stopRuntime() await this.releaseLaunchPreparation() + if (revision !== this.stopRevision) throw new Error('DSH runtime stopped.') const workspace = workspaceUri === undefined ? vscode.workspace.workspaceFolders?.[0]?.uri @@ -133,7 +163,6 @@ export class DshRuntime implements vscode.Disposable { const timeoutMs = config.get('startupTimeout', 60_000) const pending = deferredStart() this.pending = pending - this.stdoutBuffer = '' this.stopping = false let launch: LaunchCommand @@ -158,15 +187,27 @@ export class DshRuntime implements vscode.Disposable { && findSourceRoot(this.context.extensionUri.fsPath) === undefined) { const existingUrl = new URL(DEFAULT_DSH_SERVER_URL) this.publish({ kind: 'starting', detail: 'Looking for an existing DeepSeek Harness runtime…' }) - if (await probeDshServer(existingUrl)) { - if (this.pending !== pending) return pending.promise + const connection = new DshConnection(existingUrl) + const probe = await probeDshServer(connection) + if (probe.kind === 'ready') { + if (this.pending !== pending) { + connection.dispose() + return pending.promise + } const localUri = vscode.Uri.parse(existingUrl.href) this.output.appendLine(`[runtime] reusing existing DSH: ${existingUrl.href}`) + this._connection = connection this.pending = undefined this.publish({ kind: 'ready', localUri, ownership: 'external' }) pending.resolve(localUri) return pending.promise } + connection.dispose() + if (probe.kind === 'authentication-required') { + this.output.appendLine('[runtime] existing endpoint requires authentication; starting a separate managed DSH runtime.') + } else if (probe.kind === 'unsupported') { + this.output.appendLine('[runtime] existing endpoint does not support the required DSH Remote protocol; starting a separate managed runtime.') + } if (this.pending !== pending) return pending.promise } const versionLaunch = resolveLaunch(this.context.extensionUri.fsPath, configuredExecutable, ['--version'], { @@ -174,6 +215,7 @@ export class DshRuntime implements vscode.Disposable { }) version = await readDshVersion(versionLaunch, workspace.fsPath) if (this.pending !== pending) return pending.promise + assertSupportedDshVersion(version) const versionedArgs = webArgsForDshVersion(configuredArgs, version) const args = applyRuntimeLaunchPreparation(versionedArgs, version, launchPreparation) launch = resolveLaunch(this.context.extensionUri.fsPath, configuredExecutable, args, { @@ -188,8 +230,8 @@ export class DshRuntime implements vscode.Disposable { const renderedCommand = [launch.command, ...launch.args].map(part => JSON.stringify(part)).join(' ') this.output.appendLine(`[runtime] cwd: ${workspace.fsPath}`) - if (version !== undefined) this.output.appendLine(`[runtime] DSH version: ${version}`) - this.output.appendLine(`[runtime] launch: ${renderedCommand}`) + if (version !== undefined) this.output.appendLine(`[runtime] DSH version: ${redactDshSecrets(version)}`) + this.output.appendLine(`[runtime] launch: ${redactDshSecrets(renderedCommand)}`) if (storedApiKey !== undefined) this.output.appendLine('[runtime] DeepSeek credential: VS Code SecretStorage') this.publish({ kind: 'starting', @@ -217,17 +259,27 @@ export class DshRuntime implements vscode.Disposable { } this.child = child + const consume = (line: string): void => { + if (this.child === child) this.acceptOutputLine(line, pending) + } + const stdout = new RuntimeOutput(line => { this.output.appendLine(line) }, consume) + const stderr = new RuntimeOutput(line => { this.output.appendLine(line) }, consume) child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { this.consumeStdout(chunk) }) - child.stderr.on('data', (chunk: string) => { this.output.append(chunk) }) + child.stdout.on('data', (chunk: string) => { stdout.consume(chunk) }) + child.stderr.on('data', (chunk: string) => { stderr.consume(chunk) }) + child.once('close', () => { stdout.flush(); stderr.flush() }) child.on('error', (error) => { + if (this.child !== child) return if (child.pid === undefined) this.child = undefined this.failStart(error, pending) }) child.on('exit', (code, signal) => { + // A replaced process must not clear the new startup timer or credentials. + if (this.child !== child) return this.clearStartupTimer() this.child = undefined + this.disposeConnections() void this.releaseLaunchPreparation() const detail = `DSH exited (${signal ?? `code ${String(code)}`}).` this.output.appendLine(`[runtime] ${detail}`) @@ -243,35 +295,58 @@ export class DshRuntime implements vscode.Disposable { }) this.startupTimer = setTimeout(() => { + if (this.pending !== pending) return this.failStart(new Error(`DSH did not report a web URL within ${String(timeoutMs)} ms.`), pending) - void this.stop() + void this.stop(true) }, timeoutMs) return pending.promise } - private consumeStdout(chunk: string): void { - this.output.append(chunk) - this.stdoutBuffer += chunk - let newline = this.stdoutBuffer.indexOf('\n') - while (newline !== -1) { - const line = this.stdoutBuffer.slice(0, newline).replace(/\r$/, '') - this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1) - this.acceptOutputLine(line) - newline = this.stdoutBuffer.indexOf('\n') + private acceptOutputLine(line: string, pending: PendingStart): void { + if (this.pending !== pending || this.authenticating !== undefined) return + const url = parseDshWebUrl(line) + if (url === undefined) return + const launchUrl = new URL(url) + if (!launchUrl.searchParams.has('token')) { + this.failStart(new Error(DSH_UPGRADE_MESSAGE), pending) + void this.stop(true) + return } + const connection = new DshConnection(new URL(launchUrl.origin)) + this.authenticating = connection + this.publish({ kind: 'starting', detail: 'Authenticating with DeepSeek Harness…' }) + void (async () => { + await connection.authenticate(launchUrl) + const probe = await probeDshServer(connection, 10_000) + if (probe.kind !== 'ready') { + throw new Error(probe.kind === 'unsupported' ? DSH_UPGRADE_MESSAGE + : `Could not verify the authenticated DSH Remote service (${probe.kind}). Reconnect the runtime.`) + } + if (this.pending !== pending) { + connection.dispose() + return + } + this.authenticating = undefined + this._connection = connection + const localUri = vscode.Uri.parse(connection.baseUrl.href) + this.pending = undefined + this.clearStartupTimer() + this.publish({ kind: 'ready', localUri, ownership: 'managed' }) + pending.resolve(localUri) + })().catch(error => { + connection.dispose() + if (this.pending !== pending) return + this.failStart(error instanceof Error ? error : new Error('DSH authentication failed.'), pending) + void this.stop(true) + }) } - private acceptOutputLine(line: string): void { - if (this.pending === undefined) return - const url = parseDshWebUrl(line) - if (url === undefined) return - const localUri = vscode.Uri.parse(url) - const pending = this.pending - this.pending = undefined - this.clearStartupTimer() - this.publish({ kind: 'ready', localUri, ownership: 'managed' }) - pending.resolve(localUri) + private disposeConnections(): void { + this.authenticating?.dispose() + this.authenticating = undefined + this._connection?.dispose() + this._connection = undefined } private failStart(error: Error, pending: PendingStart): void { @@ -318,9 +393,22 @@ export class DshRuntime implements vscode.Disposable { return this.start() } - async stop(): Promise { + stop(preserveFailure = false): Promise { + this.stopRevision++ + this.startTask = undefined + if (this.stopTask !== undefined) return this.stopTask + const task = this.stopRuntime(preserveFailure) + this.stopTask = task + const clear = (): void => { if (this.stopTask === task) this.stopTask = undefined } + void task.then(clear, clear) + return task + } + + private async stopRuntime(preserveFailure = false): Promise { + const state: RuntimeState = preserveFailure && this._state.kind === 'failed' ? this._state : { kind: 'stopped' } this.stopping = true this.clearStartupTimer() + this.disposeConnections() const child = this.child this.child = undefined const pending = this.pending @@ -328,7 +416,7 @@ export class DshRuntime implements vscode.Disposable { pending?.reject(new Error('DSH runtime stopped.')) if (child === undefined || child.exitCode !== null) { await this.releaseLaunchPreparation() - this.publish({ kind: 'stopped' }) + this.publish(state) return } @@ -349,7 +437,7 @@ export class DshRuntime implements vscode.Disposable { if (!terminateProcessTree(child)) finish() }) await this.releaseLaunchPreparation() - this.publish({ kind: 'stopped' }) + this.publish(state) } dispose(): void { diff --git a/tests/dsh-connection.spec.ts b/tests/dsh-connection.spec.ts new file mode 100644 index 0000000..220eefb --- /dev/null +++ b/tests/dsh-connection.spec.ts @@ -0,0 +1,269 @@ +import { createHash } from 'node:crypto' +import { createServer, type RequestListener, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { once } from 'node:events' +import { WebSocketServer } from 'ws' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DshConnection, dshLocalUrl } from '../src/dsh-connection.ts' +import { DshRemoteApi } from '../src/dsh-remote-api.ts' + +const servers: Server[] = [] +const connections: DshConnection[] = [] +const socketServers: WebSocketServer[] = [] +const TOKEN = 'launch-token-for-test' +const COOKIE_VALUE = 'v1.eyJ0ZXN0Ijp0cnVlfQ.c2lnbmF0dXJl' + +function cookieFor(authority: string): string { + return `dsh-auth-${createHash('sha256').update(authority).digest('base64url')}=${COOKIE_VALUE}` +} + +async function serve(handler: RequestListener): Promise { + const server = createServer(handler) + servers.push(server) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + return new URL(`http://127.0.0.1:${String((server.address() as AddressInfo).port)}`) +} + +function connect(url: URL): DshConnection { + const connection = new DshConnection(url) + connections.push(connection) + return connection +} + +function launchUrl(url: URL): URL { return new URL(`/?token=${TOKEN}`, url) } + +afterEach(async () => { + vi.unstubAllGlobals() + for (const connection of connections.splice(0)) connection.dispose() + await Promise.all(socketServers.splice(0).map(server => new Promise(resolve => { + for (const client of server.clients) client.terminate() + server.close(() => { resolve() }) + }))) + await Promise.all(servers.splice(0).map(server => new Promise(resolve => { + server.closeAllConnections() + server.close(() => { resolve() }) + }))) +}) + +describe('DSH authenticated Remote connection', () => { + it('exchanges the root token, sends named RPC arguments, and authenticates the multiplexed WebSocket', async () => { + const requests: Array<{ path: string; cookie: string | undefined; body: any }> = [] + const url = await serve((request, response) => { + if (request.method === 'GET') { + expect(request.url).toBe(`/?token=${TOKEN}`) + expect(request.headers.cookie).toBeUndefined() + response.writeHead(303, { location: '/', 'set-cookie': `${cookieFor(request.headers.host!)}; Path=/; HttpOnly; SameSite=Strict` }) + response.end() + return + } + let body = '' + request.on('data', chunk => { body += chunk }) + request.on('end', () => { + const message = JSON.parse(body) + requests.push({ path: request.url!, cookie: request.headers.cookie, body: message }) + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ + type: 'server-response', rpcId: message.rpcId, + result: { ok: true, value: message.method === 'session/list' ? { items: [] } : { accepted: true } }, + })) + }) + }) + const connection = connect(url) + await connection.authenticate(launchUrl(url)) + expect(connection.authenticated).toBe(true) + expect(connection.baseUrl.search).toBe('') + expect(JSON.stringify(connection)).not.toContain(TOKEN) + expect(JSON.stringify(connection)).not.toContain(COOKIE_VALUE) + + const api = new DshRemoteApi(connection) + await expect(api.listSessions()).resolves.toEqual({ items: [] }) + await expect(api.prompt('session-1', 'hello')).resolves.toEqual({ accepted: true }) + await expect(api.cancel('session-1')).resolves.toEqual({ accepted: true }) + expect(requests.map(request => request.path)).toEqual(['/api/session/list', '/api/session/prompt', '/api/session/cancel']) + expect(requests.every(request => request.cookie === cookieFor(url.host))).toBe(true) + expect(requests[0]!.body).toEqual({ type: 'client-request', rpcId: expect.any(String), method: 'session/list', payload: { args: { _request: {} } } }) + expect(requests[1]!.body.payload.args.request.requestId).toEqual(expect.any(String)) + expect(requests[1]!.body.payload.args.request.requestId).not.toBe(requests[1]!.body.rpcId) + + let upgradePath: string | undefined + let upgradeCookie: string | undefined + const socketServer = new WebSocketServer({ server: servers[0] }) + socketServers.push(socketServer) + socketServer.on('connection', (_socket, request) => { + upgradePath = request.url + upgradeCookie = request.headers.cookie + }) + const socket = connection.openStreamSocket() + await once(socket, 'open') + expect(upgradePath).toBe('/api/remote.mux') + expect(upgradeCookie).toBe(cookieFor(url.host)) + const closed = once(socket, 'close') + connection.dispose() + await closed + expect(connection.authenticated).toBe(false) + expect(connection.browserUrl().search).toBe('') + }) + + it.each([ + 'https://127.0.0.1:3080/', 'http://example.com:3080/', 'http://localhost:3080/', + 'http://user:password@127.0.0.1:3080/', 'http://127.0.0.1:3080/other', + 'http://127.0.0.1:3080/?token=a&token=b', 'http://127.0.0.1:3080/?token=', + 'http://127.0.0.1:3080/?other=value', 'http://127.0.0.1:3080/#token=a', + 'http://127.0.0.1:0/', + ])('rejects unsafe or ambiguous launch endpoints: %s', value => { + expect(() => dshLocalUrl(new URL(value))).toThrow() + }) + + it('does not send the token to a different authority', async () => { + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + const connection = connect(new URL('http://127.0.0.1:3080')) + await expect(connection.authenticate(new URL(`http://127.0.0.1:3081/?token=${TOKEN}`))).rejects.toMatchObject({ code: 'authentication-required' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('never follows an authentication redirect or sends credentials to its target', async () => { + let hits = 0 + const target = await serve((_request, response) => { hits++; response.end('unexpected') }) + const source = await serve((request, response) => { + response.writeHead(303, { location: target.href, 'set-cookie': cookieFor(request.headers.host!) }) + response.end() + }) + await expect(connect(source).authenticate(launchUrl(source))).rejects.toMatchObject({ code: 'authentication-failed' }) + expect(hits).toBe(0) + }) + + it.each(['wrong-authority', 'missing', 'duplicate', 'invalid'])('rejects a %s cookie', async kind => { + const url = await serve((request, response) => { + const good = cookieFor(request.headers.host!) + response.writeHead(303, { + location: '/', + ...(kind === 'missing' ? {} : { 'set-cookie': kind === 'duplicate' ? [good, good] + : kind === 'wrong-authority' ? cookieFor('127.0.0.1:1') : good.replace(COOKIE_VALUE, 'garbage') }), + }) + response.end() + }) + const connection = connect(url) + await expect(connection.authenticate(launchUrl(url))).rejects.toMatchObject({ code: 'authentication-failed' }) + expect(connection.authenticated).toBe(false) + }) + + it('does not expose a launch URL from native authentication errors', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error(`failed at /?token=${TOKEN}`) })) + const url = new URL('http://127.0.0.1:3080') + await expect(connect(url).authenticate(launchUrl(url))).rejects.toThrow('Could not authenticate with DSH') + }) + + it('discards late authentication responses after disposal', async () => { + let complete!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { complete = resolve }))) + const url = new URL('http://127.0.0.1:3080') + const connection = connect(url) + const pending = connection.authenticate(launchUrl(url)) + connection.dispose() + complete(new Response(null, { status: 303, headers: { location: '/', 'set-cookie': cookieFor(url.host) } })) + await expect(pending).rejects.toThrow() + expect(connection.authenticated).toBe(false) + expect(connection.browserUrl().search).toBe('') + }) + + it.each([ + null, { type: 'server-response', rpcId: 'wrong', result: { ok: true, value: {} } }, + { type: 'server-response', result: null }, + ])('rejects a malformed response without echoing its body', async body => { + const url = await serve((_request, response) => { response.end(JSON.stringify(body)) }) + await expect(connect(url).call('session/list', { _request: {} })).rejects.toMatchObject({ code: 'invalid-response' }) + }) + + it('preserves Remote error codes and redacts known credentials in their messages', async () => { + const url = await serve((request, response) => { + if (request.method === 'GET') { + response.writeHead(303, { location: '/', 'set-cookie': cookieFor(request.headers.host!) }) + response.end() + return + } + let body = '' + request.on('data', chunk => { body += chunk }) + request.on('end', () => { + response.end(JSON.stringify({ type: 'server-response', rpcId: JSON.parse(body).rpcId, + result: { ok: false, error: { code: 'session/model-unavailable', message: `Rejected ${TOKEN} and ${COOKIE_VALUE}`, details: {} } }, + })) + }) + }) + const connection = connect(url) + await connection.authenticate(launchUrl(url)) + await expect(connection.call('session/prompt', {})).rejects.toMatchObject({ + code: 'session/model-unavailable', message: 'Rejected [redacted] and [redacted]', + }) + }) + + it('does not replay a POST when its response is lost', async () => { + let calls = 0 + const url = await serve(request => { + request.resume() + request.once('end', () => { calls++; request.socket.destroy() }) + }) + await expect(new DshRemoteApi(connect(url)).prompt('session-1', 'hello')).rejects.toMatchObject({ code: 'transport-error' }) + expect(calls).toBe(1) + }) + + it('does not follow RPC redirects, retry, or include the response body in an error', async () => { + let calls = 0 + const url = await serve((_request, response) => { + calls++ + response.writeHead(307, { location: '/unexpected' }) + response.end(`/?token=${TOKEN}`) + }) + await expect(connect(url).call('session/list', {})).rejects.toMatchObject({ code: 'http-error', status: 307 }) + expect(calls).toBe(1) + }) + + it('never forwards its WebSocket cookie through a redirect', async () => { + let targetHits = 0 + const target = await serve((_request, response) => { targetHits++; response.end() }) + const source = await serve((request, response) => { + response.writeHead(303, { location: '/', 'set-cookie': cookieFor(request.headers.host!) }) + response.end() + }) + servers.at(-1)!.on('upgrade', (_request, socket) => { + socket.end(`HTTP/1.1 302 Found\r\nLocation: ${target.href.replace('http:', 'ws:')}\r\nContent-Length: 0\r\n\r\n`) + }) + const connection = connect(source) + await connection.authenticate(launchUrl(source)) + const socket = connection.openStreamSocket() + const closed = new Promise(resolve => { socket.once('close', () => { resolve() }) }) + const [error] = await once(socket, 'error') + await closed + expect(error.message).toContain('302') + expect(targetHits).toBe(0) + }) + + it('rejects non-JSON responses as invalid protocol rather than an unavailable server', async () => { + const url = await serve((_request, response) => { response.end('not DSH') }) + await expect(connect(url).call('session/list', {})).rejects.toMatchObject({ code: 'invalid-response' }) + }) + + it.each(['../escape', 'session/list?token=secret', 'http://127.0.0.1:3090/', 'session.list'])('rejects invalid endpoint %s before sending', async endpoint => { + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + await expect(connect(new URL('http://127.0.0.1:3080')).call(endpoint, {})).rejects.toMatchObject({ code: 'invalid-endpoint' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('classifies expired authentication without exposing the response body', async () => { + const url = await serve((_request, response) => { response.writeHead(401); response.end(TOKEN) }) + await expect(connect(url).call('session/list', {})).rejects.toMatchObject({ code: 'authentication-required', status: 401 }) + }) + + it('aborts in-flight RPC calls on disposal', async () => { + let received!: () => void + const arrived = new Promise(resolve => { received = resolve }) + const url = await serve(request => { request.resume(); received() }) + const connection = connect(url) + const pending = connection.call('session/list', {}) + const checked = expect(pending).rejects.toMatchObject({ code: 'cancelled' }) + await arrived + connection.dispose() + await checked + }) +}) diff --git a/tests/dsh-remote-api.spec.ts b/tests/dsh-remote-api.spec.ts new file mode 100644 index 0000000..a2ce879 --- /dev/null +++ b/tests/dsh-remote-api.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DshConnection } from '../src/dsh-connection.ts' +import { DshRemoteApi } from '../src/dsh-remote-api.ts' + +describe('DSH 0.1.2 Session Remotes', () => { + it('uses the exact named arguments for list, create, rename, cancel and queue mutations', async () => { + const call = vi.fn().mockResolvedValue({ accepted: true }) + const api = new DshRemoteApi({ call } as unknown as DshConnection) + await api.listSessions() + await api.createSession('C:\\Users\\测试\\project') + await api.renameSession('s1', 'A new title') + await api.cancel('s1') + await api.updateQueue('s1', 'm1', { kind: 'steer' }) + expect(call.mock.calls).toEqual([ + ['session/list', { _request: {} }], + ['session/create', { request: { cwd: 'C:\\Users\\测试\\project' } }], + ['session/rename', { request: { sessionId: 's1', title: 'A new title' } }], + ['session/cancel', { request: { sessionId: 's1' } }], + ['session/updateQueue', { request: { sessionId: 's1', itemId: 'm1', action: { kind: 'steer' } } }], + ]) + }) + + it('mints a fresh user-message requestId and preserves image order and steering', async () => { + const call = vi.fn().mockResolvedValue({ accepted: true }) + const api = new DshRemoteApi({ call } as unknown as DshConnection) + const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'YWJj', name: 'diagram.png' } + await api.prompt('s1', 'inspect this', [image], 'steer') + await api.prompt('s1', '', [image]) + const first = call.mock.calls[0]![1].request + const second = call.mock.calls[1]![1].request + expect(first).toEqual({ + requestId: expect.any(String), sessionId: 's1', mode: 'steer', content: [image, { type: 'text', text: 'inspect this' }], + clientTimeZone: expect.any(String), + }) + expect(second.requestId).not.toBe(first.requestId) + expect(second.content).toEqual([image]) + expect(second.mode).toBe('queue') + }) +}) diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts index 6cced0a..3deb113 100644 --- a/tests/launch.spec.ts +++ b/tests/launch.spec.ts @@ -11,6 +11,13 @@ describe('DSH launch resolution', () => { expect(parseDshWebUrl('info dsh web: http://127.0.0.1:8080 (LAN: ignored)')).toBe('http://127.0.0.1:8080') expect(parseDshWebUrl('server listening at http://127.0.0.1:43127')).toBeUndefined() expect(parseDshWebUrl('dsh web: https://example.com')).toBeUndefined() + expect(parseDshWebUrl('dsh web: http://127.0.0.1:3080/?token=launch-token_123')).toBe('http://127.0.0.1:3080/?token=launch-token_123') + expect(parseDshWebUrl('dsh web: http://127.0.0.1:3080/')).toBe('http://127.0.0.1:3080/') + expect(parseDshWebUrl('dsh web: http://127.0.0.1:3080/?token=one&token=two')).toBeUndefined() + expect(parseDshWebUrl('dsh web: http://127.0.0.1:3080/path')).toBeUndefined() + expect(parseDshWebUrl('dsh web: http://127.0.0.1:0')).toBeUndefined() + expect(parseDshWebUrl('dsh web: http://127.0.0.1:999999')).toBeUndefined() + expect(parseDshWebUrl('dsh web: http://127.0.0.1/?token=token')).toBe('http://127.0.0.1/?token=token') }) it('suppresses the rc.8 browser handoff while preserving older DSH launches', () => { diff --git a/tests/runtime-endpoint.spec.ts b/tests/runtime-endpoint.spec.ts index c2da4c8..16f2196 100644 --- a/tests/runtime-endpoint.spec.ts +++ b/tests/runtime-endpoint.spec.ts @@ -1,7 +1,8 @@ import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it } from 'vitest' -import { probeDshServer, shouldProbeExistingDsh } from '../src/runtime-endpoint.ts' +import { assertSupportedDshVersion, probeDshServer, shouldProbeExistingDsh } from '../src/runtime-endpoint.ts' +import { DshConnection } from '../src/dsh-connection.ts' const servers: Server[] = [] @@ -39,20 +40,20 @@ describe('existing DSH runtime probe', () => { expect(shouldProbeExistingDsh(true, '', true)).toBe(false) }) - it('accepts a matching official session.list response', async () => { + it('accepts a matching official session/list response', async () => { const url = await serve(request => ({ type: 'server-response', rpcId: request.rpcId, result: { ok: true, value: { items: [] } }, })) - await expect(probeDshServer(url)).resolves.toBe(true) + await expect(probeDshServer(new DshConnection(url))).resolves.toEqual({ kind: 'ready' }) }) it('rejects a service that does not speak the DSH RPC protocol', async () => { const url = await serve(() => ({ status: 'ok' })) - await expect(probeDshServer(url)).resolves.toBe(false) + await expect(probeDshServer(new DshConnection(url))).resolves.toEqual({ kind: 'invalid-response' }) }) it('rejects a response that does not echo the request id', async () => { @@ -62,6 +63,31 @@ describe('existing DSH runtime probe', () => { result: { ok: true, value: { items: [] } }, })) - await expect(probeDshServer(url)).resolves.toBe(false) + await expect(probeDshServer(new DshConnection(url))).resolves.toEqual({ kind: 'invalid-response' }) + }) + + it.each([401, 403, 404])('distinguishes HTTP %s from a stopped server', async status => { + const server = createServer((request, response) => { request.resume(); response.writeHead(status); response.end() }) + servers.push(server) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const url = new URL(`http://127.0.0.1:${String((server.address() as AddressInfo).port)}`) + await expect(probeDshServer(new DshConnection(url))).resolves.toEqual({ + kind: status === 404 ? 'unsupported' : 'authentication-required', + }) + }) + + it('recognizes a service that disappeared', async () => { + const url = await serve(() => ({})) + const server = servers.pop()! + await new Promise(resolve => { server.close(() => { resolve() }) }) + await expect(probeDshServer(new DshConnection(url))).resolves.toEqual({ kind: 'unavailable' }) + }) + + it.each(['0.1.1-rc.2', '0.1.0-rc.8', '0.1.2-alpha.5', '0.1.2-rc.0', '0.0.9'])('rejects the unsupported runtime %s early', version => { + expect(() => assertSupportedDshVersion(version)).toThrow('0.1.2-rc.1') + }) + + it.each(['0.1.2-rc.1', 'dsh 0.1.2-rc.2', 'v0.1.2', '0.1.2+build.1', '0.1.3', '0.2.0', '1.0.0', undefined, 'source'])('allows %s to reach the protocol check', version => { + expect(() => assertSupportedDshVersion(version)).not.toThrow() }) }) diff --git a/tests/runtime-output.spec.ts b/tests/runtime-output.spec.ts new file mode 100644 index 0000000..5317fe6 --- /dev/null +++ b/tests/runtime-output.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeOutput, redactDshSecrets } from '../src/runtime-output.ts' +import { parseDshWebUrl } from '../src/launch.ts' + +describe('runtime output and launch-token privacy', () => { + it('handles every chunk boundary without logging any part of the launch token', () => { + const raw = '\u001b[32mdsh web: http://127.0.0.1:3080/?token=secret-token\u001b[0m\r\n' + for (let offset = 0; offset <= raw.length; offset++) { + const logs: string[] = [] + const urls: Array = [] + const output = new RuntimeOutput(line => { logs.push(line) }, line => { urls.push(parseDshWebUrl(line)) }) + output.consume(raw.slice(0, offset)) + output.consume(raw.slice(offset)) + output.flush() + expect(logs).toEqual(['dsh web: http://127.0.0.1:3080/?token=[redacted]']) + expect(urls).toEqual(['http://127.0.0.1:3080/?token=secret-token']) + } + }) + + it('flushes a final unterminated line with the same redaction', () => { + const logs: string[] = [] + const output = new RuntimeOutput(line => { logs.push(line) }, () => {}) + output.consume('Cookie: dsh-auth-example=secret') + expect(logs).toEqual([]) + output.flush() + expect(logs).toEqual(['Cookie: [redacted]']) + }) + + it('discards oversized lines without leaking their prefix and resumes at a newline', () => { + const logs: string[] = [] + const accepted: string[] = [] + const output = new RuntimeOutput(line => { logs.push(line) }, line => { accepted.push(line) }, 32) + output.consume('token=' + 'x'.repeat(40)) + output.consume('trailing-secret\nok\n') + expect(logs).toEqual(['[runtime] oversized output line omitted', 'ok']) + expect(accepted).toEqual(['ok']) + }) + + it('redacts query tokens, headers and explicit secret values', () => { + expect(redactDshSecrets('url?token=abc&x=1\nAuthorization: Bearer abc\nCookie: a=b\nplain-secret', ['plain-secret'])) + .toBe('url?token=[redacted]&x=1\nAuthorization: [redacted]\nCookie: [redacted]\n[redacted]') + }) +}) diff --git a/tests/runtime-startup.spec.ts b/tests/runtime-startup.spec.ts new file mode 100644 index 0000000..9653544 --- /dev/null +++ b/tests/runtime-startup.spec.ts @@ -0,0 +1,210 @@ +import { createHash } from 'node:crypto' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + version: '0.1.2-rc.1', + timeout: 60_000, + children: [] as ChildProcessWithoutNullStreams[], +})) + +vi.mock('vscode', () => ({ + EventEmitter: class { + readonly event = vi.fn() + fire(): void {} + dispose(): void {} + }, + Uri: { parse: (value: string) => ({ toString: () => value, fsPath: '/workspace' }) }, + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: () => ({ + get: (key: string, fallback: unknown) => key === 'reuseExistingRuntime' ? false + : key === 'startupTimeout' ? state.timeout : fallback, + inspect: () => undefined, + }), + }, +})) + +vi.mock('../src/launch.ts', async importOriginal => ({ + ...await importOriginal(), + findSourceRoot: () => undefined, + resolveLaunch: (_root: string, _executable: string, args: string[]) => ({ command: 'dsh', args, env: {} }), +})) + +vi.mock('node:child_process', async () => { + const { EventEmitter } = await import('node:events') + const { PassThrough } = await import('node:stream') + return { + spawn: vi.fn((_command: string, args: string[]) => { + const child = Object.assign(new EventEmitter(), { + pid: 123, exitCode: null as number | null, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + }) as unknown as ChildProcessWithoutNullStreams + if (args.includes('--version')) { + queueMicrotask(() => { + child.stdout.emit('data', state.version + '\n') + child.emit('exit', 0) + child.emit('close', 0) + }) + } else { + state.children.push(child) + } + return child + }), + } +}) + +vi.mock('../src/process-tree.ts', () => ({ + terminateProcessTree: (child: ChildProcessWithoutNullStreams) => { + child.emit('exit', 0) + child.emit('close', 0) + return true + }, +})) + +import { DshRuntime } from '../src/runtime.ts' + +const runtimes: DshRuntime[] = [] +function runtime(): { instance: DshRuntime; logs: string[] } { + const logs: string[] = [] + const instance = new DshRuntime({ + extensionUri: { fsPath: '/extension' }, secrets: { get: async () => undefined }, + } as never, { appendLine: (line: string) => { logs.push(line) } } as never) + runtimes.push(instance) + return { instance, logs } +} + +function authResponse(): Response { + const name = `dsh-auth-${createHash('sha256').update('127.0.0.1:43127').digest('base64url')}` + return new Response(null, { status: 303, headers: { location: '/', 'set-cookie': `${name}=v1.dGVzdA.c2lnbmF0dXJl` } }) +} + +function rpcResponse(init?: RequestInit): Response { + const message = JSON.parse(String(init?.body)) + return Response.json({ type: 'server-response', rpcId: message.rpcId, result: { ok: true, value: { items: [] } } }) +} + +beforeEach(() => { + state.children = [] + state.version = '0.1.2-rc.1' + state.timeout = 60_000 + vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => init?.method === 'GET' ? authResponse() : rpcResponse(init))) +}) + +afterEach(async () => { + await Promise.all(runtimes.splice(0).map(instance => instance.stop())) + vi.unstubAllGlobals() +}) + +describe('authenticated runtime startup', () => { + it('waits for authentication and a Remote probe before publishing a credential-free ready state', async () => { + let finishAuth!: (response: Response) => void + const auth = new Promise(resolve => { finishAuth = resolve }) + vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => init?.method === 'GET' ? auth : rpcResponse(init))) + const { instance, logs } = runtime() + const pending = instance.start() + await vi.waitFor(() => { expect(state.children).toHaveLength(1) }) + state.children[0]!.stdout.emit('data', 'dsh web: http://127.0.0.1:43127/?to') + state.children[0]!.stdout.emit('data', 'ken=private-launch-token\r\n') + expect(instance.state.kind).toBe('starting') + expect(() => instance.connection).toThrow('not connected') + finishAuth(authResponse()) + const uri = await pending + expect(uri.toString()).toBe('http://127.0.0.1:43127/') + expect(instance.state.kind).toBe('ready') + expect(JSON.stringify(instance.state)).not.toContain('private-launch-token') + expect(logs.join('\n')).not.toContain('private-launch-token') + expect(instance.connection.browserUrl().search).toBe('?token=private-launch-token') + }) + + it('also accepts and redacts launch URLs on stderr', async () => { + const { instance, logs } = runtime() + const pending = instance.start() + await vi.waitFor(() => { expect(state.children).toHaveLength(1) }) + state.children[0]!.stderr.emit('data', '\u001b[32mdsh web: http://127.0.0.1:43127/?token=secret\u001b[0m\n') + await pending + expect(instance.state.kind).toBe('ready') + expect(logs.join('\n')).not.toContain('token=secret') + }) + + it('rejects older DSH before spawning its web process', async () => { + state.version = '0.1.1-rc.2' + const { instance } = runtime() + await expect(instance.start()).rejects.toThrow('0.1.2-rc.1') + expect(state.children).toEqual([]) + expect(instance.state.kind).toBe('failed') + }) + + it('does not report ready after stop interrupts an authentication request', async () => { + let finishAuth!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { finishAuth = resolve }))) + const { instance } = runtime() + const pending = instance.start() + const checked = expect(pending).rejects.toThrow('stopped') + await vi.waitFor(() => { expect(state.children).toHaveLength(1) }) + state.children[0]!.stdout.emit('data', 'dsh web: http://127.0.0.1:43127/?token=secret\n') + await instance.stop() + finishAuth(authResponse()) + await checked + await new Promise(resolve => { setImmediate(resolve) }) + expect(instance.state.kind).toBe('stopped') + expect(() => instance.connection).toThrow('not connected') + }) + + it('preserves an authentication error after terminating its managed process', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('private-token', { status: 401 }))) + const { instance, logs } = runtime() + const pending = instance.start() + const checked = expect(pending).rejects.toThrow('did not accept') + await vi.waitFor(() => { expect(state.children).toHaveLength(1) }) + state.children[0]!.stdout.emit('data', 'dsh web: http://127.0.0.1:43127/?token=private-token\n') + await checked + await new Promise(resolve => { setImmediate(resolve) }) + expect(instance.state).toMatchObject({ kind: 'failed', message: expect.stringContaining('did not accept') }) + expect(logs.join('\n')).not.toContain('private-token') + }) + + it('ignores a late exit from a replaced process', async () => { + const { instance } = runtime() + const first = instance.start() + await vi.waitFor(() => { expect(state.children).toHaveLength(1) }) + const old = state.children[0]! + old.stdout.emit('data', 'dsh web: http://127.0.0.1:43127/?token=first\n') + await first + await instance.stop() + const second = instance.start() + await vi.waitFor(() => { expect(state.children).toHaveLength(2) }) + state.children[1]!.stdout.emit('data', 'dsh web: http://127.0.0.1:43127/?token=second\n') + await second + old.emit('exit', 1) + expect(instance.state.kind).toBe('ready') + expect(instance.connection.authenticated).toBe(true) + }) + + it('shares concurrent startup callers', async () => { + const { instance } = runtime() + const first = instance.start() + const second = instance.start() + await vi.waitFor(() => { expect(state.children).toHaveLength(1) }) + state.children[0]!.stdout.emit('data', 'dsh web: http://127.0.0.1:43127/?token=secret\n') + expect((await first).toString()).toBe((await second).toString()) + }) + + it('can stop before startup has reserved its pending connection', async () => { + const { instance } = runtime() + const pending = instance.start() + const checked = expect(pending).rejects.toThrow('stopped') + await instance.stop() + await checked + expect(state.children).toEqual([]) + }) + + it('preserves a startup timeout and ignores a late launch URL', async () => { + state.timeout = 20 + const { instance } = runtime() + await expect(instance.start()).rejects.toThrow('within 20 ms') + await new Promise(resolve => { setImmediate(resolve) }) + state.children[0]!.stdout.emit('data', 'dsh web: http://127.0.0.1:43127/?token=too-late\n') + expect(instance.state).toMatchObject({ kind: 'failed', message: expect.stringContaining('within 20 ms') }) + expect(vi.mocked(fetch)).not.toHaveBeenCalled() + }) +}) From 39e376e4fe2622a9f6e571072d9793f08df9aa53 Mon Sep 17 00:00:00 2001 From: Lixxx1 Date: Fri, 11 Sep 2026 08:56:13 +0800 Subject: [PATCH 02/14] feat: connect chat to DSH 0.1.2 remote streams --- src/dsh-client.ts | 262 +++++-------------- src/dsh-history.ts | 38 +++ src/dsh-remote-api.ts | 18 +- src/dsh-session-feed.ts | 297 +++++++++++++++++++++ src/dsh-streams.ts | 92 +++++++ src/extension.ts | 52 +++- tests/dsh-client.spec.ts | 494 +++++++++++++++++++---------------- tests/dsh-remote-api.spec.ts | 13 +- 8 files changed, 826 insertions(+), 440 deletions(-) create mode 100644 src/dsh-history.ts create mode 100644 src/dsh-session-feed.ts create mode 100644 src/dsh-streams.ts diff --git a/src/dsh-client.ts b/src/dsh-client.ts index 80ad5ae..7daffa8 100644 --- a/src/dsh-client.ts +++ b/src/dsh-client.ts @@ -1,4 +1,7 @@ -import { randomUUID } from 'node:crypto' +import type { DshConnection } from './dsh-connection.js' +import { DshRemoteApi } from './dsh-remote-api.js' +import { DshSessionFeed, type SessionOpening } from './dsh-session-feed.js' +import { wireRecord } from './dsh-streams.js' import type { DshEvent } from './conversation.js' import type { PluginInventorySnapshot } from './plugin-profile.js' import type { SettingsDescription, SettingsMutation, SettingsNamespace } from './runtime-settings.js' @@ -11,7 +14,7 @@ export interface SessionSummary { cwd?: string origin?: 'subagent' agentPreset?: string - projections?: { values?: Record } + projections?: { asOfSeq?: number; values?: Record } } export interface HistoryEntry { @@ -115,240 +118,103 @@ export type DshFrame = | { channel: 'mux'; rpcId: string; payload: Record } | { channel: 'host'; rpcId: string; payload: Record } -interface RpcEnvelope { - type: 'server-response' - rpcId: string - result: { ok: true; value: T } | { ok: false; error: { message?: string; code?: string } } -} - -interface ServerRequestEnvelope { - type: 'server-request' - rpcId: string - method: string - payload: unknown +interface ModelCatalog { + default: ModelSelection + routableProviders: string[] + groups: SessionModels['groups'] + failures: SessionModels['failures'] } +/** The sidebar uses only authenticated 0.1.2 Remotes. */ export class DshClient { - private readonly streamAbort = new AbortController() + private readonly api: DshRemoteApi + private readonly feed: DshSessionFeed + private readonly lifetime = new AbortController() private readonly frameListeners = new Set<(frame: DshFrame) => void>() private readonly errorListeners = new Set<(error: Error) => void>() + private catalog: ModelCatalog | undefined - constructor(private readonly baseUrl: URL) {} + constructor(private readonly connection: DshConnection) { + this.api = new DshRemoteApi(connection, this.lifetime.signal) + this.feed = new DshSessionFeed(connection, + frame => { for (const listener of this.frameListeners) listener(frame) }, + error => { for (const listener of this.errorListeners) listener(error) }) + } onFrame(listener: (frame: DshFrame) => void): () => void { this.frameListeners.add(listener) return () => { this.frameListeners.delete(listener) } } - onError(listener: (error: Error) => void): () => void { this.errorListeners.add(listener) return () => { this.errorListeners.delete(listener) } } - - startStreams(): void { - this.openWebSocket('events.mux', 'mux') - this.openWebSocket('events.host', 'host') - } - - listSessions(): Promise<{ items: SessionSummary[] }> { - return this.call('session.list', {}) - } - - listWorkspaces(): Promise<{ archivedSessionIds?: string[] }> { - return this.call('workspace.list', {}) - } - - renameSession(sessionId: string, title: string): Promise<{ title: string; seq?: number }> { - return this.call('session.rename', { sessionId, title }) + startStreams(): Promise { return this.feed.start() } + openSession(sessionId: string): Promise { return this.feed.open(sessionId) } + async listSessions(): Promise<{ items: SessionSummary[] }> { + const result = await this.api.listSessions() + return { items: result.items.map(summary => this.feed.summary(summary)) } } - + listWorkspaces(): Promise<{ archivedSessionIds: string[] }> { return this.feed.listWorkspaces() } + renameSession(sessionId: string, title: string): Promise<{ title: string; seq?: number }> { return this.api.renameSession(sessionId, title) } archiveSession(sessionId: string): Promise<{ archivedSessionIds: string[] }> { - return this.call('workspace.archiveSession', { sessionId }) + return this.call('workspace/archiveSession', { request: { sessionId } }) } + createSession(cwd: string): Promise<{ sessionId: string; agentPreset?: string }> { return this.api.createSession(cwd) } + history(sessionId: string, beforeSeq: number): Promise<{ events: HistoryEntry[]; hasMore: boolean }> { return this.feed.page(sessionId, beforeSeq) } - createSession(cwd: string): Promise<{ sessionId: string; agentPreset?: string }> { - return this.call('session.create', { cwd }) + async models(sessionId: string): Promise { + this.catalog = await this.call('session/modelCatalog', {}) + return this.currentModels(sessionId) as SessionModels } - - history(sessionId: string, beforeSeq?: number): Promise<{ events: HistoryEntry[]; hasMore: boolean }> { - return this.call('session.history', { - sessionId, - ...(beforeSeq === undefined ? {} : { beforeSeq }), - maxMessages: 100, - }) - } - - models(sessionId: string): Promise { - return this.call('session.models', { sessionId }) + currentModels(sessionId: string): SessionModels | undefined { + const catalog = this.catalog + if (catalog === undefined) return undefined + const projection = this.feed.projectionValues(sessionId).modelSelection + const candidate = wireRecord(projection) ? projection.next ?? projection.lastUsed : undefined + const current: ModelSelection = wireRecord(candidate) && typeof candidate.provider === 'string' && typeof candidate.model === 'string' + ? { provider: candidate.provider, model: candidate.model, ...(typeof candidate.reasoningEffort === 'string' ? { reasoningEffort: candidate.reasoningEffort } : {}) } + : catalog.default + return { current, routable: catalog.routableProviders.includes(current.provider), groups: catalog.groups, failures: catalog.failures } } attachment(sessionId: string, attachmentId: string): Promise<{ attachment: ImageAttachment; data: string }> { - return this.call('session.attachment', { sessionId, attachmentId }) + return this.call('session/attachment', { request: { sessionId, attachmentId } }) } - - pluginInventory(): Promise { - return this.call('pluginInventory/list', { args: {} }) - } - - settings(): Promise { - return this.call('settings.describe', {}) - } - + pluginInventory(): Promise { return this.call('pluginInventory/list', {}) } + settings(): Promise { return this.call('settings/describe', {}) } mutateSettings(ns: string, ops: SettingsMutation[], expectedRevision: number): Promise { - return this.call('settings.mutate', { ns, ops, expectedRevision }) + return this.call('settings/mutate', { ns, ops, expectedRevision }) } - - prompt( - sessionId: string, - text: string, - images: readonly PromptImage[] = [], - mode: PromptMode = 'queue', - ): Promise<{ accepted: true }> { - const content: Array<{ type: 'text'; text: string } | PromptImage> = images.map(image => ({ - type: 'image', - mediaType: image.mediaType, - data: image.data, - ...(image.name === undefined ? {} : { name: image.name }), - })) - if (text !== '') content.push({ type: 'text', text }) - return this.call('session.prompt', { - sessionId, - mode, - content, - clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }) - } - - async respond(rpcId: string, value: unknown): Promise { - const response = await fetch(new URL('/api/respond', this.baseUrl), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-response', - rpcId, - result: { ok: true, value }, - }), - signal: AbortSignal.timeout(30_000), - }) - if (!response.ok) throw new Error(`DSH response transport failed: HTTP ${String(response.status)}`) - const receipt = await response.json() as RpcReceipt - if (receipt.accepted !== true) { - throw new Error(receipt.reason === 'not-pending' - ? 'This request has already been answered.' - : 'DeepSeek Harness rejected the response.') - } - return receipt - } - - cancel(sessionId: string): Promise<{ accepted: true }> { - return this.call('session.cancel', { sessionId }) + prompt(sessionId: string, text: string, images: readonly PromptImage[] = [], mode: PromptMode = 'queue'): Promise<{ accepted: true }> { + return this.api.prompt(sessionId, text, images, mode) } - - updateQueue(sessionId: string, itemId: string, action: QueueAction): Promise<{ accepted: true }> { - return this.call('session.updateQueue', { sessionId, itemId, action }) - } - + respond(rpcId: string, value: unknown): Promise { return this.feed.respond(rpcId, value) } + cancel(sessionId: string): Promise<{ accepted: true }> { return this.api.cancel(sessionId) } + updateQueue(sessionId: string, itemId: string, action: QueueAction): Promise<{ accepted: true }> { return this.api.updateQueue(sessionId, itemId, action) } selectModel(sessionId: string, selection: ModelSelection): Promise<{ selected: ModelSelection }> { - return this.call('session.selectModel', { sessionId, ...selection }) - } - - listCommands(sessionId: string): Promise { - return this.call('commands/list', { args: { agentId: sessionId } }, 10_000) + return this.call('session/selectModel', { request: { sessionId, ...selection } }) } - + listCommands(sessionId: string): Promise { return this.call('commands/list', { agentId: sessionId }, 10_000) } async listSkills(sessionId: string): Promise { - const result = await this.call<{ skills: SkillDescriptor[] }>('skill.list', { sessionId }, 10_000) - return result.skills + return (await this.call<{ skills: SkillDescriptor[] }>('skills/list', { request: { sessionId } }, 10_000)).skills } - - listAgentPresets(): Promise { - return this.call('agentPreset.list', {}, 10_000) + listAgentPresets(): Promise { return this.call('agentPresets/list', {}, 10_000) } + async selectAgentPreset(sessionId: string, agentPreset: string): Promise<{ agentPreset: string }> { + return { agentPreset: await this.call('agentPresets/select', { agentId: sessionId, agentPreset }) } } - - selectAgentPreset(sessionId: string, agentPreset: string): Promise<{ agentPreset: string }> { - return this.call('agentPreset.select', { sessionId, agentPreset }, 30_000) - } - - executeCommand( - sessionId: string, - line: string, - images?: readonly PromptImage[], - ): Promise { + executeCommand(sessionId: string, line: string, images: readonly PromptImage[] = []): Promise { return this.call('commands/execute', { - args: { - agentId: sessionId, - line, - ...(images === undefined ? {} : { - images: images.map(image => ({ - mediaType: image.mediaType, - data: image.data, - ...(image.name === undefined ? {} : { name: image.name }), - })), - }), - }, + agentId: sessionId, line, images: images.map(({ mediaType, data, name }) => ({ mediaType, data, ...(name === undefined ? {} : { name }) })), }, 300_000) } - dispose(): void { - this.streamAbort.abort() + this.lifetime.abort() + this.feed.dispose() this.frameListeners.clear() this.errorListeners.clear() } - - private async call(method: string, payload: unknown, timeoutMs = 30_000): Promise { - const rpcId = randomUUID() - const response = await fetch(new URL(`/api/${method}`, this.baseUrl), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ type: 'client-request', rpcId, method, payload }), - signal: AbortSignal.timeout(timeoutMs), - }) - if (!response.ok) throw new Error(`DSH transport failed: HTTP ${String(response.status)}`) - const envelope = await response.json() as RpcEnvelope - if (envelope.type !== 'server-response' || envelope.rpcId !== rpcId) { - throw new Error(`Invalid DSH response for ${method}.`) - } - if (!envelope.result.ok) { - throw new Error(envelope.result.error.message ?? envelope.result.error.code ?? `${method} failed`) - } - return envelope.result.value - } - - private openWebSocket(path: 'events.mux' | 'events.host', channel: 'mux' | 'host'): void { - const url = new URL(`/api/${path}`, this.baseUrl) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - const socket = new WebSocket(url) - let opened = false - const abort = (): void => { - if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close() - } - this.streamAbort.signal.addEventListener('abort', abort, { once: true }) - socket.addEventListener('open', () => { opened = true }, { once: true }) - socket.addEventListener('message', (event) => { - try { - if (typeof event.data !== 'string') throw new Error('DSH sent a binary WebSocket frame.') - const envelope = JSON.parse(event.data) as ServerRequestEnvelope - if (envelope.type !== 'server-request' || typeof envelope.payload !== 'object' || envelope.payload === null) { - throw new Error('DSH sent an invalid event envelope.') - } - const frame: DshFrame = { channel, rpcId: envelope.rpcId, payload: envelope.payload as Record } - for (const listener of this.frameListeners) listener(frame) - } catch (error) { - const normalized = error instanceof Error ? error : new Error(String(error)) - for (const listener of this.errorListeners) listener(normalized) - } - }) - socket.addEventListener('error', () => { - if (this.streamAbort.signal.aborted) return - const error = new Error(`DSH ${channel} WebSocket ${opened ? 'failed' : 'could not connect'}.`) - for (const listener of this.errorListeners) listener(error) - }) - socket.addEventListener('close', () => { - this.streamAbort.signal.removeEventListener('abort', abort) - if (this.streamAbort.signal.aborted || !opened) return - const error = new Error(`DSH ${channel} WebSocket closed.`) - for (const listener of this.errorListeners) listener(error) - }, { once: true }) - if (this.streamAbort.signal.aborted) abort() + private call(endpoint: string, args: Record, timeoutMs = 30_000): Promise { + return this.connection.call(endpoint, args, timeoutMs, this.lifetime.signal) } } diff --git a/src/dsh-history.ts b/src/dsh-history.ts new file mode 100644 index 0000000..434eac9 --- /dev/null +++ b/src/dsh-history.ts @@ -0,0 +1,38 @@ +import type { HistoryEntry } from './dsh-client.js' +import { wireRecord } from './dsh-streams.js' + +/** Expand the official lossless chunk encoding before the existing projector. */ +export function decodeHistory(records: unknown): HistoryEntry[] { + if (!Array.isArray(records)) throw new Error('Invalid DSH history records.') + return records.flatMap((record): HistoryEntry[] => { + if (!wireRecord(record) || !wireRecord(record.event)) throw new Error('Invalid DSH history record.') + const event = record.event + if (typeof event.type !== 'string' || !Number.isSafeInteger(event.seq) || !Number.isSafeInteger(event.time)) { + throw new Error('Invalid DSH history event.') + } + const seq = event.seq as number + let time = event.time as number + if (record.type === 'event') return [{ event: { ...event, type: event.type, seq, time, data: event.data } }] + const data = event.data + if (record.type !== 'chunks' || !wireRecord(data)) throw new Error('Invalid DSH chunk record.') + const tool = event.type === 'chunkrow/tool-call-chunks' + const kind = tool ? 'tool-call-delta' : event.type === 'chunkrow/text-chunks' ? 'text-delta' + : event.type === 'chunkrow/reasoning-chunks' ? 'reasoning-delta' : undefined + const parts = tool ? data.args : data.texts + const gaps = data.dt + if (kind === undefined || !Array.isArray(parts) || parts.length === 0 || !parts.every(p => typeof p === 'string') + || !Array.isArray(gaps) || gaps.length !== parts.length - 1 || !gaps.every(Number.isSafeInteger) + || typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number' + || (tool && (typeof data.id !== 'string' || (data.name !== undefined && typeof data.name !== 'string')))) { + throw new Error('Invalid DSH packed chunks.') + } + return parts.map((part: string, index) => { + if (index > 0) time += gaps[index - 1] as number + if (!Number.isSafeInteger(time) || !Number.isSafeInteger(seq + index)) throw new Error('Invalid DSH chunk cursor.') + const chunk = tool + ? { type: kind, index: data.index, id: data.id, argumentsDelta: part, ...(data.name === undefined ? {} : { name: data.name }) } + : { type: kind, index: data.index, text: part } + return { event: { type: 'assistant/chunk', seq: seq + index, time, data: { turn: data.turn, step: data.step, chunk } } } + }) + }) +} diff --git a/src/dsh-remote-api.ts b/src/dsh-remote-api.ts index c471759..1989ca0 100644 --- a/src/dsh-remote-api.ts +++ b/src/dsh-remote-api.ts @@ -4,18 +4,18 @@ import type { PromptImage, PromptMode, QueueAction, SessionSummary } from './dsh /** Named-argument contracts of the 0.1.2 Session Remotes, independent of the UI. */ export class DshRemoteApi { - constructor(private readonly connection: DshConnection) {} + constructor(private readonly connection: DshConnection, private readonly signal?: AbortSignal) {} listSessions(): Promise<{ items: SessionSummary[] }> { - return this.connection.call('session/list', { _request: {} }) + return this.call('session/list', { _request: {} }) } createSession(cwd: string): Promise<{ sessionId: string; agentPreset?: string }> { - return this.connection.call('session/create', { request: { cwd } }) + return this.call('session/create', { request: { cwd } }) } renameSession(sessionId: string, title: string): Promise<{ title: string; seq?: number }> { - return this.connection.call('session/rename', { request: { sessionId, title } }) + return this.call('session/rename', { request: { sessionId, title } }) } prompt(sessionId: string, text: string, images: readonly PromptImage[] = [], mode: PromptMode = 'queue'): Promise<{ accepted: true }> { @@ -24,7 +24,7 @@ export class DshRemoteApi { ...(image.name === undefined ? {} : { name: image.name }), })) if (text !== '') content.push({ type: 'text', text }) - return this.connection.call('session/prompt', { + return this.call('session/prompt', { request: { requestId: randomUUID(), sessionId, mode, content, clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -33,10 +33,14 @@ export class DshRemoteApi { } cancel(sessionId: string): Promise<{ accepted: true }> { - return this.connection.call('session/cancel', { request: { sessionId } }) + return this.call('session/cancel', { request: { sessionId } }) } updateQueue(sessionId: string, itemId: string, action: QueueAction): Promise<{ accepted: true }> { - return this.connection.call('session/updateQueue', { request: { sessionId, itemId, action } }) + return this.call('session/updateQueue', { request: { sessionId, itemId, action } }) + } + + private call(endpoint: string, args: Record): Promise { + return this.connection.call(endpoint, args, 30_000, this.signal) } } diff --git a/src/dsh-session-feed.ts b/src/dsh-session-feed.ts new file mode 100644 index 0000000..5408971 --- /dev/null +++ b/src/dsh-session-feed.ts @@ -0,0 +1,297 @@ +import type { DshConnection } from './dsh-connection.js' +import type { DshFrame, HistoryEntry, RpcReceipt, SessionSummary } from './dsh-client.js' +import { decodeHistory } from './dsh-history.js' +import { DshStreams, wireRecord } from './dsh-streams.js' + +interface Projection { seq: number; value: unknown } +interface PendingQuestion { sessionId: string; event: string; request: Record } +interface Follow { + sessionId: string + cursor: number + lastSeq: number + active: boolean + pending: DshFrame[] + cancel: () => void + reject: (error: Error) => void +} +export interface SessionOpening { + events: HistoryEntry[] + hasMore: boolean + projections: Record + /** Install the snapshot in the UI before releasing events buffered behind it. */ + activate: () => void + isCurrent: () => boolean +} + +/** Adapts the 0.1.2 streams to the sidebar's internal (not wire) event vocabulary. */ +export class DshSessionFeed { + private readonly streams: DshStreams + private startup: Promise | undefined + private clientId: string | undefined + private follow: Follow | undefined + private archived: string[] = [] + private readonly projections = new Map>() + private readonly projectionFloors = new Map() + private readonly queues = new Map() + private readonly jobs = new Map() + private readonly running = new Map() + private readonly questions = new Map() + private readonly displayedQuestions = new Map() + + constructor(private readonly connection: DshConnection, private readonly emit: (frame: DshFrame) => void, + private readonly error: (error: Error) => void) { + this.streams = new DshStreams(connection, failure => { this.closeFollow(failure); this.error(failure) }) + } + + start(): Promise { + this.startup ??= Promise.all([ + this.baseline('$events', frame => { + if (frame.type === 'ready') { + if (typeof frame.clientId !== 'string') throw new Error('Invalid event identity.') + this.clientId = frame.clientId + return true + } + this.remoteEvent(frame) + return false + }), + this.baseline('session/control', frame => { this.control(frame); return frame.type === 'baseline' }), + this.baseline('workspace/follow', frame => { + const value = frame.type === 'baseline' ? frame.value : frame + if ((frame.type === 'baseline' || frame.type === 'archived') && wireRecord(value)) { + if (!Array.isArray(value.archivedSessionIds) || !value.archivedSessionIds.every(id => typeof id === 'string')) { + throw new Error('Invalid workspace baseline.') + } + this.archived = value.archivedSessionIds + if (frame.type !== 'baseline') this.host({ type: 'host/archived-sessions-changed', archivedSessionIds: this.archived }) + } + return frame.type === 'baseline' + }), + ]).then(() => undefined) + return this.startup + } + + async listWorkspaces(): Promise<{ archivedSessionIds: string[] }> { + await this.start() + return { archivedSessionIds: [...this.archived] } + } + + summary(summary: SessionSummary): SessionSummary { + const values = { ...summary.projections?.values } + const watermark = summary.projections?.asOfSeq ?? -1 + for (const [key, projection] of this.projections.get(summary.sessionId) ?? []) { + if (projection.seq >= watermark) values[key] = projection.value + } + return { ...summary, running: this.running.get(summary.sessionId) ?? summary.running, + projections: { values }, ...(typeof values.agentPreset === 'string' ? { agentPreset: values.agentPreset } : {}) } + } + + projectionValues(sessionId: string): Record { + return Object.fromEntries([...this.projections.get(sessionId) ?? []].map(([key, projection]) => [key, projection.value])) + } + + open(sessionId: string): Promise { + this.closeFollow() + return new Promise((resolve, reject) => { + const state: Follow = { sessionId, cursor: -1, lastSeq: -1, active: false, pending: [], cancel: () => {}, reject } + this.follow = state + let opened = false + const timer = setTimeout(() => { + if (this.follow === state) this.closeFollow(new Error('DSH session snapshot timed out.')) + }, 30_000) + state.reject = error => { clearTimeout(timer); reject(error) } + state.cancel = this.streams.open('session/follow', { request: { address: { kind: 'session', sessionId }, maxMessages: 100 } }, raw => { + if (this.follow !== state) return + if (!wireRecord(raw)) throw new Error('Invalid session frame.') + if (!opened) { + if (raw.type !== 'snapshot' || !Number.isSafeInteger(raw.cursor) || !wireRecord(raw.header) + || raw.header.id !== sessionId || !wireRecord(raw.projections) || !wireRecord(raw.projections.values) + || raw.projections.asOfSeq !== raw.cursor || typeof raw.hasMore !== 'boolean') throw new Error('Invalid session snapshot.') + state.cursor = state.lastSeq = raw.cursor as number + const events = decodeHistory(raw.records) + const latest = events.at(-1)?.event.seq + if (latest !== undefined && latest > state.cursor) throw new Error('History exceeds snapshot cursor.') + this.installProjections(sessionId, state.cursor, raw.projections.values) + opened = true + clearTimeout(timer) + resolve({ events, hasMore: raw.hasMore, projections: this.projectionValues(sessionId), isCurrent: () => this.follow === state, activate: () => { + if (this.follow !== state || state.active) return + state.active = true + this.mux({ type: 'session/queue', sessionId, items: this.queues.get(sessionId) ?? [] }) + this.mux({ type: 'session/jobs', sessionId, jobs: this.jobs.get(sessionId) ?? [] }) + for (const [key, projection] of this.projections.get(sessionId) ?? []) { + this.mux({ type: 'session/projection', sessionId, key, value: projection.value }) + } + for (const frame of state.pending.splice(0)) this.emit(frame) + this.presentQuestions() + } }) + } else { + if (raw.type !== 'event') throw new Error('Unexpected session snapshot.') + const entry = decodeHistory([raw])[0] + if (entry === undefined || entry.event.seq !== state.lastSeq + 1) throw new Error('Non-contiguous DSH session events.') + state.lastSeq = entry.event.seq + this.mux({ type: 'session/event', sessionId, event: entry.event }) + } + }, error => { state.reject(error) }) + }) + } + + async page(sessionId: string, beforeSeq: number): Promise<{ events: HistoryEntry[]; hasMore: boolean }> { + const state = this.follow + if (state?.sessionId !== sessionId || state.cursor < 0) throw new Error('Open the session before loading older history.') + const result = await this.connection.call<{ records: unknown; hasMore: boolean }>('session/page', { + request: { address: { kind: 'session', sessionId }, throughSeq: state.cursor, beforeSeq, maxMessages: 100 }, + }) + if (this.follow !== state) throw new Error('The conversation changed while loading history.') + if (typeof result.hasMore !== 'boolean') throw new Error('Invalid history page.') + return { events: decodeHistory(result.records), hasMore: result.hasMore } + } + + async respond(id: string, value: unknown): Promise { + const pending = this.questions.get(id) + if (pending === undefined || !wireRecord(value) || value.sessionId !== pending.sessionId) { + throw new Error('This request is no longer pending.') + } + const answer = pending.event === 'approval/request' ? value.outcome : value.answer + if (pending.event === 'approval/request' && answer !== 'allowed-once' && answer !== 'rejected') throw new Error('Invalid approval outcome.') + if (pending.event === 'user-questions/request' && (!wireRecord(answer) || !Array.isArray(answer.answers))) throw new Error('Invalid question answer.') + await this.result(id, { kind: 'result', value: answer }) + this.dismissQuestion(id) + return { accepted: true } + } + + dispose(): void { + this.closeFollow() + this.questions.clear() + this.streams.dispose() + } + + private closeFollow(error = new Error('The conversation subscription changed.')): void { + const previous = this.follow + this.follow = undefined + this.displayedQuestions.clear() + if (previous === undefined) return + previous.cancel() + previous.reject(error) + } + + private baseline(endpoint: string, item: (value: Record) => boolean): Promise { + return new Promise((resolve, reject) => { + let cancel = (): void => {} + const timer = setTimeout(() => { cancel(); reject(new Error(`DSH ${endpoint} baseline timed out.`)) }, 30_000) + cancel = this.streams.open(endpoint, {}, raw => { + if (!wireRecord(raw)) throw new Error('Invalid DSH baseline.') + if (item(raw)) { clearTimeout(timer); resolve() } + }, error => { clearTimeout(timer); reject(error) }) + }) + } + + private installProjections(sessionId: string, seq: number, values: Record): void { + const previous = this.projections.get(sessionId) + const next = new Map() + for (const [key, value] of Object.entries(values)) next.set(key, { seq, value }) + // The control stream can already be ahead of the history snapshot. + for (const [key, projection] of previous ?? []) if (projection.seq > seq) next.set(key, projection) + this.projections.set(sessionId, next) + this.projectionFloors.set(sessionId, Math.max(seq, this.projectionFloors.get(sessionId) ?? -1)) + } + + private control(frame: Record): void { + if (frame.type === 'baseline') { + const value = frame.value + if (!wireRecord(value) || !wireRecord(value.queues) || !wireRecord(value.jobs) || !wireRecord(value.projections)) throw new Error('Invalid control baseline.') + for (const [id, items] of Object.entries(value.queues)) this.control({ type: 'queue', sessionId: id, items }) + for (const [id, jobs] of Object.entries(value.jobs)) this.control({ type: 'jobs', sessionId: id, jobs }) + for (const [id, projection] of Object.entries(value.projections)) { + if (!wireRecord(projection) || !wireRecord(projection.values) || !Number.isSafeInteger(projection.asOfSeq)) throw new Error('Invalid projection baseline.') + this.installProjections(id, projection.asOfSeq as number, projection.values) + } + return + } + const id = frame.sessionId + if (typeof id !== 'string') throw new Error('Invalid control session.') + if (frame.type === 'queue' && Array.isArray(frame.items)) { + this.queues.set(id, frame.items) + if (this.follow?.active) this.mux({ type: 'session/queue', sessionId: id, items: frame.items }) + } else if (frame.type === 'jobs' && Array.isArray(frame.jobs)) { + this.jobs.set(id, frame.jobs) + if (this.follow?.active) this.mux({ type: 'session/jobs', sessionId: id, jobs: frame.jobs }) + } else if (frame.type === 'projection' && typeof frame.key === 'string' && Number.isSafeInteger(frame.seq)) { + if ((frame.seq as number) < (this.projectionFloors.get(id) ?? -1)) return + const map = this.projections.get(id) ?? new Map() + if ((map.get(frame.key)?.seq ?? -1) > (frame.seq as number)) return + map.set(frame.key, { seq: frame.seq as number, value: frame.value }) + this.projections.set(id, map) + // Replayed from the newest cached value on activation, never from stale buffered values. + if (this.follow?.active) this.mux({ type: 'session/projection', sessionId: id, key: frame.key, value: frame.value }) + } else throw new Error('Invalid control update.') + } + + private remoteEvent(frame: Record): void { + if (frame.type === 'cancel' && typeof frame.eventId === 'string') { this.dismissQuestion(frame.eventId); return } + if (frame.type === 'waterfall' && typeof frame.eventId === 'string' && typeof frame.agentId === 'string' + && typeof frame.event === 'string' && wireRecord(frame.request)) { + if (frame.agentId !== this.follow?.sessionId || (frame.event !== 'approval/request' && frame.event !== 'user-questions/request')) { + void this.result(frame.eventId, { kind: 'next' }).catch(this.error) + return + } + const question = { sessionId: frame.agentId, event: frame.event, request: frame.request } + this.questions.set(frame.eventId, question) + if (this.follow.active) this.presentQuestions() + return + } + if (frame.type !== 'emit' || typeof frame.event !== 'string' || !Array.isArray(frame.args)) throw new Error('Invalid remote event.') + const [id, value] = frame.args + if (frame.event === 'api-session/added' && wireRecord(id)) this.host({ ...id, type: 'host/session-added' }) + else if (typeof id === 'string') { + if (frame.event === 'api-session/status' && typeof value === 'boolean') { + this.running.set(id, value) + this.host({ type: 'host/session-status', sessionId: id, running: value }) + } else if (frame.event === 'api-session/error') this.host({ type: 'host/agent-error', sessionId: id, message: value }) + } + } + + private presentQuestion(id: string, pending: PendingQuestion): void { + const approval = pending.event === 'approval/request' + this.emit({ channel: 'mux', rpcId: id, payload: { ...pending.request, sessionId: pending.sessionId, + type: approval ? 'approval/requested' : 'question/requested', ...(approval ? { approvalId: id } : {}) } }) + } + + private presentQuestions(): void { + if (this.follow?.active !== true) return + // The sidebar has one approval panel and one question panel. Keep parallel + // tool approvals queued instead of overwriting an unanswered request. + const shown = new Set() + for (const [id, pending] of this.questions) { + if (pending.sessionId !== this.follow.sessionId || shown.has(pending.event)) continue + shown.add(pending.event) + if (this.displayedQuestions.get(pending.event) !== id) { + this.displayedQuestions.set(pending.event, id) + this.presentQuestion(id, pending) + } + } + } + + private dismissQuestion(id: string): void { + const pending = this.questions.get(id) + if (pending === undefined) return + this.questions.delete(id) + if (this.displayedQuestions.get(pending.event) === id) this.displayedQuestions.delete(pending.event) + this.mux({ type: pending.event === 'approval/request' ? 'approval/resolved' : 'question/resolved', + sessionId: pending.sessionId, approvalId: id, questionRpcId: id }) + this.presentQuestions() + } + + private result(eventId: string, outcome: unknown): Promise { + if (this.clientId === undefined) return Promise.reject(new Error('DSH event stream is not ready.')) + return this.connection.call('$events/result', { clientId: this.clientId, eventId, outcome }) + } + + private mux(payload: Record): void { + const frame: DshFrame = { channel: 'mux', rpcId: '', payload } + const state = this.follow + if (state !== undefined && !state.active && payload.sessionId === state.sessionId) state.pending.push(frame) + else this.emit(frame) + } + + private host(payload: Record): void { this.emit({ channel: 'host', rpcId: '', payload }) } +} diff --git a/src/dsh-streams.ts b/src/dsh-streams.ts new file mode 100644 index 0000000..3eb51e7 --- /dev/null +++ b/src/dsh-streams.ts @@ -0,0 +1,92 @@ +import { randomUUID } from 'node:crypto' +import WebSocket from 'ws' +import type { DshConnection } from './dsh-connection.js' + +export function wireRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +interface Stream { + endpoint: string + args: Record + item: (value: unknown) => void + fail: (error: Error) => void +} + +/** One authenticated carrier; logical subscriptions have independent lifetimes. */ +export class DshStreams { + private socket: WebSocket | undefined + private readonly streams = new Map() + private closed = false + + constructor(private readonly connection: DshConnection, private readonly failed: (error: Error) => void) {} + + open(endpoint: string, args: Record, item: Stream['item'], fail: Stream['fail']): () => void { + if (this.closed) { + fail(new Error('The DSH event stream is closed. Reconnect the runtime.')) + return () => {} + } + const id = randomUUID() + const stream = { endpoint, args, item, fail } + this.streams.set(id, stream) + try { + if (this.socket === undefined) this.connect() + else if (this.socket.readyState === WebSocket.OPEN) this.sendOpen(id, stream) + } catch { + this.abort(new Error('Could not open the DSH event stream.')) + } + return () => { + if (!this.streams.delete(id)) return + if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: 'cancel', streamId: id })) + } + } + + dispose(): void { + this.abort(new Error('The DSH event stream was disposed.'), false) + } + + private connect(): void { + const socket = this.connection.openStreamSocket() + this.socket = socket + socket.once('open', () => { + for (const [id, stream] of this.streams) this.sendOpen(id, stream) + }) + socket.on('message', (data, binary) => { + if (this.closed) return + try { + if (binary) throw new Error('Binary event frame') + const frame: unknown = JSON.parse(data.toString()) + if (!wireRecord(frame) || typeof frame.streamId !== 'string') throw new Error('Invalid envelope') + const stream = this.streams.get(frame.streamId) + // Cancellation can race an already queued server frame. + if (stream === undefined) return + if (frame.type === 'item') stream.item(frame.value) + else if (frame.type === 'end' || frame.type === 'error') { + this.streams.delete(frame.streamId) + // Do not put arbitrary remote error text (or credentials) in logs. + const error = new Error(`DSH ${stream.endpoint} subscription ended. Reconnect the runtime.`) + stream.fail(error) + this.abort(error) + } else throw new Error('Unknown envelope') + } catch { + this.abort(new Error('DSH sent an invalid event stream frame. Reconnect the runtime.')) + } + }) + socket.once('error', () => { this.abort(new Error('The DSH event stream connection failed.')) }) + socket.once('close', () => { this.abort(new Error('The DSH event stream connection closed.')) }) + } + + private sendOpen(id: string, stream: Stream): void { + this.socket?.send(JSON.stringify({ type: 'open', streamId: id, endpoint: stream.endpoint, payload: { args: stream.args } })) + } + + private abort(error: Error, report = true): void { + if (this.closed) return + this.closed = true + const pending = [...this.streams.values()] + this.streams.clear() + this.socket?.terminate() + for (const stream of pending) stream.fail(error) + if (report) this.failed(error) + } +} diff --git a/src/extension.ts b/src/extension.ts index a534b72..25357bd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -216,6 +216,7 @@ class DshChatController implements vscode.Disposable { private summaries: SessionSummary[] = [] private _state: ChatViewState private generation = 0 + private sessionLoadGeneration = 0 private readonly guardedDirtyCalls = new Set() private queueRawText = new Map() private readonly attachmentResults = new Map>() @@ -308,9 +309,11 @@ class DshChatController implements vscode.Disposable { }) if (this.cwd === '') return try { - const uri = await this.runtime.start(this.cwd === '' ? undefined : vscode.Uri.file(this.cwd)) + await this.runtime.start(this.cwd === '' ? undefined : vscode.Uri.file(this.cwd)) if (generation !== this.generation) return - const client = new DshClient(new URL(uri.toString(true))) + const connection = this.runtime.connection + if (connection === undefined) throw new Error('DSH did not establish an authenticated connection.') + const client = new DshClient(connection) this.client = client this.clientDisposables.push( client.onFrame(frame => { @@ -325,9 +328,9 @@ class DshChatController implements vscode.Disposable { this.publish({ phase: 'error', statusText: `Lost the DSH event stream: ${error.message}` }) }), ) - await this.loadSessions() + await client.startStreams() if (generation !== this.generation || this.client !== client) return - client.startStreams() + await this.loadSessions() } catch (error) { if (generation !== this.generation) return const message = error instanceof Error ? error.message : String(error) @@ -395,6 +398,7 @@ class DshChatController implements vscode.Disposable { if (this._state.sessionId === '' || !this._state.hasMoreHistory || this._state.loadingHistory) return const sessionId = this._state.sessionId const client = this.requireClient() + const loadGeneration = this.sessionLoadGeneration const beforeSeq = earliestHistorySequence(this.historyEntries) if (beforeSeq === undefined) { this.publish({ hasMoreHistory: false }) @@ -403,7 +407,7 @@ class DshChatController implements vscode.Disposable { this.publish({ loadingHistory: true }) try { const page = await client.history(sessionId, beforeSeq) - if (this.client !== client || this._state.sessionId !== sessionId) return + if (this.client !== client || this._state.sessionId !== sessionId || loadGeneration !== this.sessionLoadGeneration) return const unseenEntries = unseenHistoryEntries(this.historyEntries, page.events) this.historyEntries = mergeHistoryEntries(this.historyEntries, unseenEntries) this.projector.reset(this.historyEntries) @@ -414,8 +418,10 @@ class DshChatController implements vscode.Disposable { loadingHistory: false, }) this.hydrateImages(client, sessionId) + } catch (error) { + if (this.client === client && loadGeneration === this.sessionLoadGeneration) throw error } finally { - if (this.client === client && this._state.sessionId === sessionId && this._state.loadingHistory) { + if (this.client === client && loadGeneration === this.sessionLoadGeneration && this._state.sessionId === sessionId && this._state.loadingHistory) { this.publish({ loadingHistory: false }) } } @@ -649,6 +655,7 @@ class DshChatController implements vscode.Disposable { private async loadSession(sessionId: string): Promise { const client = this.requireClient() + const loadGeneration = ++this.sessionLoadGeneration this.historyEntries = [] this.publish({ phase: 'loading', @@ -657,16 +664,27 @@ class DshChatController implements vscode.Disposable { hasMoreHistory: false, loadingHistory: false, }) - const [{ events, hasMore }, models] = await Promise.all([ - client.history(sessionId), - client.models(sessionId), - ]) - if (this.client !== client || this._state.sessionId !== sessionId) return + const result = await (async () => { + const opening = await client.openSession(sessionId) + if (!opening.isCurrent()) return undefined + const models = await client.models(sessionId) + return { opening, models } + })().catch((error: unknown) => { + if (this.client === client && loadGeneration === this.sessionLoadGeneration) throw error + return undefined + }) + if (result === undefined || this.client !== client || loadGeneration !== this.sessionLoadGeneration || !result.opening.isCurrent()) return + const { opening, models } = result + const { events, hasMore } = opening this.projector.reset(events) this.historyEntries = events this.guardedDirtyCalls.clear() this.queueRawText.clear() const summary = this.summaries.find(item => item.sessionId === sessionId) + if (summary !== undefined) { + summary.projections = { values: opening.projections } + if (typeof opening.projections.agentPreset === 'string') summary.agentPreset = opening.projections.agentPreset + } this.publish({ phase: 'ready', statusText: '', @@ -690,6 +708,7 @@ class DshChatController implements vscode.Disposable { loadingHistory: false, ...this.modelPatch(models), }) + opening.activate() this.hydrateImages(client, sessionId) void this.loadCommands(client, sessionId) void this.loadSkills(client, sessionId) @@ -929,6 +948,10 @@ class DshChatController implements vscode.Disposable { if (payload.key === 'plan' && sessionId === this._state.sessionId) { this.publish({ plan: planModeStateOf(payload.value) }) } + if (payload.key === 'modelSelection' && sessionId === this._state.sessionId) { + const models = this.client?.currentModels(sessionId) + if (models !== undefined) this.publish(this.modelPatch(models)) + } if ((payload.key === 'tokenUsage' || payload.key === 'sessionStats' || payload.key === 'contextPressure' @@ -969,7 +992,12 @@ class DshChatController implements vscode.Disposable { } if (frame.channel === 'host' && type === 'host/session-added' && payload.cwd === this.cwd) { - void this.loadSessions(sessionId).catch(error => { this.report(error) }) + // Discovery must not steal focus or replace a snapshot currently loading. + if (!this.summaries.some(item => item.sessionId === sessionId)) { + this.summaries.push({ sessionId, cwd: this.cwd, updatedAt: typeof payload.updatedAt === 'number' ? payload.updatedAt : 0, + running: payload.running === true, blank: payload.blank === true }) + this.publishSessionItems() + } } } diff --git a/tests/dsh-client.spec.ts b/tests/dsh-client.spec.ts index 15bea61..53db3d7 100644 --- a/tests/dsh-client.spec.ts +++ b/tests/dsh-client.spec.ts @@ -1,240 +1,290 @@ +import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' -import { DshClient } from '../src/dsh-client.js' - -describe('DshClient queue protocol', () => { - afterEach(() => { vi.unstubAllGlobals() }) - - it('sends direct steering and official queue mutations through their real RPC methods', async () => { - const requests: Array<{ method: string; payload: unknown }> = [] - vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string; payload: unknown } - requests.push({ method: request.method, payload: request.payload }) - return new Response(JSON.stringify({ - type: 'server-response', - rpcId: request.rpcId, - result: { ok: true, value: { accepted: true } }, - }), { status: 200, headers: { 'content-type': 'application/json' } }) - })) - const client = new DshClient(new URL('http://127.0.0.1:31415')) - - await client.prompt('session-1', 'Change direction', [], 'steer') - await client.updateQueue('session-1', 'item-1', { - kind: 'edit', - content: [{ type: 'text', text: 'Updated follow-up' }], +import { DshClient, type DshFrame } from '../src/dsh-client.js' +import type { DshConnection } from '../src/dsh-connection.js' +import { decodeHistory } from '../src/dsh-history.js' +import { ConversationProjector } from '../src/conversation.js' + +const clients: DshClient[] = [] +afterEach(() => { for (const client of clients.splice(0)) client.dispose(); vi.useRealTimers() }) + +const event = (seq: number, text = 'hello') => ({ + type: 'event', event: { seq, time: seq * 10, type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } }, +}) +const snapshot = (id = 's', cursor = 5, values: Record = {}) => ({ + type: 'snapshot', header: { id }, cursor, records: [event(cursor)], hasMore: true, projections: { asOfSeq: cursor, values }, +}) + +function harness(autoSnapshot = true) { + const requests: { endpoint: string; args: any }[] = [] + const outgoing: any[] = [] + const ids = new Map() + const frames: DshFrame[] = [] + const errors: Error[] = [] + const results: Record = { + 'session/list': { items: [] }, 'session/create': { sessionId: 's' }, + 'session/modelCatalog': { default: { provider: 'p', model: 'm' }, routableProviders: ['p'], groups: [], failures: [] }, + 'session/page': { records: [event(1)], hasMore: false }, + 'skills/list': { skills: [] }, 'agentPresets/list': { presets: [] }, 'agentPresets/select': 'coding', + } + const socket = new EventEmitter() as EventEmitter & { readyState: number; send: (raw: string) => void; terminate: () => void } + socket.readyState = 0 + const receive = (id: string, value: unknown) => socket.emit('message', Buffer.from(JSON.stringify({ type: 'item', streamId: id, value })), false) + const push = (endpoint: string, value: unknown) => receive(ids.get(endpoint)!, value) + socket.send = raw => { + const frame = JSON.parse(raw) + outgoing.push(frame) + if (frame.type !== 'open') return + ids.set(frame.endpoint, frame.streamId) + queueMicrotask(() => { + if (frame.endpoint === '$events') receive(frame.streamId, { type: 'ready', clientId: 'client-1', host: { home: '/isolated' } }) + if (frame.endpoint === 'session/control') receive(frame.streamId, { type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }) + if (frame.endpoint === 'workspace/follow') receive(frame.streamId, { type: 'baseline', value: { items: [], archivedSessionIds: ['archived'] } }) + if (frame.endpoint === 'session/follow' && autoSnapshot) receive(frame.streamId, snapshot(frame.payload.args.request.address.sessionId)) }) - await client.updateQueue('session-1', 'item-1', { kind: 'steer' }) - - expect(requests).toEqual([ - { - method: 'session.prompt', - payload: expect.objectContaining({ - sessionId: 'session-1', - mode: 'steer', - content: [{ type: 'text', text: 'Change direction' }], - }), - }, - { - method: 'session.updateQueue', - payload: { - sessionId: 'session-1', - itemId: 'item-1', - action: { kind: 'edit', content: [{ type: 'text', text: 'Updated follow-up' }] }, - }, - }, - { - method: 'session.updateQueue', - payload: { sessionId: 'session-1', itemId: 'item-1', action: { kind: 'steer' } }, - }, + } + socket.terminate = () => { socket.readyState = 3; socket.emit('close') } + const connection = { + call: vi.fn(async (endpoint: string, args: unknown) => { + requests.push({ endpoint, args }) + return results[endpoint] ?? { accepted: true } + }), + openStreamSocket: vi.fn(() => { + queueMicrotask(() => { socket.readyState = 1; socket.emit('open') }) + return socket + }), + } as unknown as DshConnection + const client = new DshClient(connection) + clients.push(client) + client.onFrame(frame => frames.push(frame)) + client.onError(error => errors.push(error)) + return { client, connection, socket, requests, outgoing, frames, errors, results, ids, push, receive } +} + +describe('DSH 0.1.2 chat transport', () => { + it('multiplexes all baselines through one connection and uses named Remote arguments', async () => { + const h = harness() + await h.client.startStreams() + await h.client.startStreams() + expect(h.connection.openStreamSocket).toHaveBeenCalledTimes(1) + expect(h.outgoing.map(f => f.endpoint)).toEqual(['$events', 'session/control', 'workspace/follow']) + expect(h.outgoing.every(f => JSON.stringify(f.payload) === '{"args":{}}')).toBe(true) + expect(await h.client.listWorkspaces()).toEqual({ archivedSessionIds: ['archived'] }) + await h.client.prompt('s', 'Change direction', [], 'steer') + await h.client.updateQueue('s', 'item', { kind: 'steer' }) + await h.client.cancel('s') + expect(h.requests).toMatchObject([ + { endpoint: 'session/prompt', args: { request: { requestId: expect.any(String), sessionId: 's', mode: 'steer', content: [{ type: 'text', text: 'Change direction' }] } } }, + { endpoint: 'session/updateQueue', args: { request: { sessionId: 's', itemId: 'item', action: { kind: 'steer' } } } }, + { endpoint: 'session/cancel', args: { request: { sessionId: 's' } } }, ]) }) - it('uses the official session rename and workspace archive RPCs', async () => { - const requests: Array<{ method: string; payload: unknown }> = [] - vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string; payload: unknown } - requests.push({ method: request.method, payload: request.payload }) - const value = request.method === 'workspace.list' - ? { archivedSessionIds: ['archived-1'] } - : request.method === 'workspace.archiveSession' - ? { archivedSessionIds: ['archived-1', 'session-1'] } - : { title: 'Renamed', seq: 12 } - return new Response(JSON.stringify({ - type: 'server-response', - rpcId: request.rpcId, - result: { ok: true, value }, - }), { status: 200, headers: { 'content-type': 'application/json' } }) - })) - const client = new DshClient(new URL('http://127.0.0.1:31415')) - - await expect(client.listWorkspaces()).resolves.toEqual({ archivedSessionIds: ['archived-1'] }) - await expect(client.renameSession('session-1', 'Renamed')).resolves.toMatchObject({ title: 'Renamed' }) - await expect(client.archiveSession('session-1')).resolves.toEqual({ archivedSessionIds: ['archived-1', 'session-1'] }) - - expect(requests).toEqual([ - { method: 'workspace.list', payload: {} }, - { method: 'session.rename', payload: { sessionId: 'session-1', title: 'Renamed' } }, - { method: 'workspace.archiveSession', payload: { sessionId: 'session-1' } }, + it('preserves rename, archive, image, plugin, settings and command contracts', async () => { + const h = harness() + await h.client.renameSession('s', 'Renamed') + await h.client.archiveSession('s') + await h.client.attachment('s', 'image') + await h.client.pluginInventory() + await h.client.settings() + await h.client.mutateSettings('ns', [{ op: 'set', path: ['x'], value: 1 }], 3) + await h.client.listSkills('s') + expect(await h.client.selectAgentPreset('s', 'coding')).toEqual({ agentPreset: 'coding' }) + await h.client.executeCommand('s', '/compact') + await h.client.executeCommand('s', '/plan inspect', [{ type: 'image', mediaType: 'image/png', data: 'YWJj', name: 'image.png' }]) + expect(h.requests).toEqual([ + { endpoint: 'session/rename', args: { request: { sessionId: 's', title: 'Renamed' } } }, + { endpoint: 'workspace/archiveSession', args: { request: { sessionId: 's' } } }, + { endpoint: 'session/attachment', args: { request: { sessionId: 's', attachmentId: 'image' } } }, + { endpoint: 'pluginInventory/list', args: {} }, { endpoint: 'settings/describe', args: {} }, + { endpoint: 'settings/mutate', args: { ns: 'ns', ops: [{ op: 'set', path: ['x'], value: 1 }], expectedRevision: 3 } }, + { endpoint: 'skills/list', args: { request: { sessionId: 's' } } }, + { endpoint: 'agentPresets/select', args: { agentId: 's', agentPreset: 'coding' } }, + { endpoint: 'commands/execute', args: { agentId: 's', line: '/compact', images: [] } }, + { endpoint: 'commands/execute', args: { agentId: 's', line: '/plan inspect', images: [{ mediaType: 'image/png', data: 'YWJj', name: 'image.png' }] } }, ]) }) - it('reads the official runtime plugin inventory without inventing a management RPC', async () => { - const requests: Array<{ method: string; payload: unknown }> = [] - vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string; payload: unknown } - requests.push({ method: request.method, payload: request.payload }) - return new Response(JSON.stringify({ - type: 'server-response', - rpcId: request.rpcId, - result: { - ok: true, - value: { - entries: [{ - entryId: 'plugin-1', - moduleName: '@example/runtime-plugin', - enabled: true, - fiberPhase: 'active', - }], - }, - }, - }), { status: 200, headers: { 'content-type': 'application/json' } }) - })) - const client = new DshClient(new URL('http://127.0.0.1:31415')) - - await expect(client.pluginInventory()).resolves.toEqual({ - entries: [{ - entryId: 'plugin-1', - moduleName: '@example/runtime-plugin', - enabled: true, - fiberPhase: 'active', - }], - }) - expect(requests).toEqual([{ method: 'pluginInventory/list', payload: { args: {} } }]) + it('buffers post-snapshot events until activation, and pins pagination to the opening cursor', async () => { + const h = harness() + await h.client.startStreams() + const opening = await h.client.openSession('s') + h.push('session/follow', event(6, ' world')) + expect(h.frames.filter(f => f.payload.type === 'session/event')).toHaveLength(0) + opening.activate() + opening.activate() + expect(h.frames.filter(f => f.payload.type === 'session/event')).toHaveLength(1) + const projector = new ConversationProjector() + projector.reset(opening.events) + for (const frame of h.frames.filter(f => f.payload.type === 'session/event')) projector.apply(frame.payload.event as any) + expect(projector.messages()[0]?.text).toBe('hello world') + await h.client.history('s', 5) + expect(h.requests.at(-1)).toEqual({ endpoint: 'session/page', args: { request: { address: { kind: 'session', sessionId: 's' }, throughSeq: 5, beforeSeq: 5, maxMessages: 100 } } }) }) - it('reads a durable image through its authorizing session', async () => { - const requests: Array<{ method: string; payload: unknown }> = [] - vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string; payload: unknown } - requests.push({ method: request.method, payload: request.payload }) - return new Response(JSON.stringify({ - type: 'server-response', - rpcId: request.rpcId, - result: { - ok: true, - value: { - attachment: { - attachmentId: 'sha256:image', - mediaType: 'image/png', - bytes: 3, - width: 1, - height: 1, - }, - data: 'YWJj', - }, - }, - }), { status: 200, headers: { 'content-type': 'application/json' } }) - })) - const client = new DshClient(new URL('http://127.0.0.1:31415')) - - await expect(client.attachment('session-1', 'sha256:image')).resolves.toMatchObject({ data: 'YWJj' }) - expect(requests).toEqual([{ - method: 'session.attachment', - payload: { sessionId: 'session-1', attachmentId: 'sha256:image' }, - }]) + it('cancels stale subscriptions including A → B → A and ignores late frames', async () => { + const h = harness(false) + await h.client.startStreams() + const first = h.client.openSession('a') + const rejection = expect(first).rejects.toThrow('subscription changed') + const oldId = h.ids.get('session/follow')! + const second = h.client.openSession('b') + const secondRejection = expect(second).rejects.toThrow('subscription changed') + const third = h.client.openSession('a') + h.receive(oldId, snapshot('a')) + h.push('session/follow', snapshot('a')) + const opening = await third + await rejection + await secondRejection + opening.activate() + h.receive(oldId, event(6)) + expect(h.outgoing.filter(f => f.type === 'cancel')).toHaveLength(2) + expect(h.frames.filter(f => f.payload.type === 'session/event')).toHaveLength(0) }) - it('describes and mutates official runtime settings with revision protection', async () => { - const requests: Array<{ method: string; payload: unknown }> = [] - vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string; payload: unknown } - requests.push({ method: request.method, payload: request.payload }) - return new Response(JSON.stringify({ - type: 'server-response', - rpcId: request.rpcId, - result: { ok: true, value: request.method === 'settings.describe' - ? { writable: true, hasDocument: true, namespaces: [] } - : { ns: 'agent-loop', revision: 4 } }, - }), { status: 200, headers: { 'content-type': 'application/json' } }) - })) - const client = new DshClient(new URL('http://127.0.0.1:31415')) - - await client.settings() - await client.mutateSettings('agent-loop', [{ - op: 'set', path: ['maxParallelToolCalls'], value: 4, - }], 3) - - expect(requests).toEqual([ - { method: 'settings.describe', payload: {} }, - { - method: 'settings.mutate', - payload: { - ns: 'agent-loop', - ops: [{ op: 'set', path: ['maxParallelToolCalls'], value: 4 }], - expectedRevision: 3, - }, - }, - ]) + it('merges newer control projections but never lets an older control frame overwrite the snapshot', async () => { + const h = harness(false) + await h.client.startStreams() + h.push('session/control', { type: 'projection', sessionId: 's', key: 'modelSelection', seq: 7, value: { next: { provider: 'p', model: 'new', reasoningEffort: 'high' } } }) + const pending = h.client.openSession('s') + h.push('session/follow', snapshot('s', 5, { modelSelection: { next: { provider: 'p', model: 'old' } }, title: 'snapshot' })) + const opening = await pending + h.push('session/control', { type: 'projection', sessionId: 's', key: 'title', seq: 4, value: 'stale' }) + h.push('session/control', { type: 'projection', sessionId: 's', key: 'removed-capability', seq: 4, value: 'stale' }) + expect((await h.client.models('s')).current).toEqual({ provider: 'p', model: 'new', reasoningEffort: 'high' }) + opening.activate() + expect(h.frames.filter(f => f.payload.key === 'title').map(f => f.payload.value)).toEqual(['snapshot']) + expect(h.frames.some(f => f.payload.key === 'removed-capability')).toBe(false) + h.push('session/control', { type: 'projection', sessionId: 's', key: 'modelSelection', seq: 8, value: { lastUsed: { provider: 'missing', model: 'm' }, next: null } }) + expect(h.client.currentModels('s')).toMatchObject({ routable: false, current: { provider: 'missing', model: 'm' } }) }) - it('requests older history using the official beforeSeq cursor', async () => { - const requests: Array<{ method: string; payload: unknown }> = [] - vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string; payload: unknown } - requests.push({ method: request.method, payload: request.payload }) - return new Response(JSON.stringify({ - type: 'server-response', rpcId: request.rpcId, - result: { ok: true, value: { events: [], hasMore: false } }, - }), { status: 200, headers: { 'content-type': 'application/json' } }) - })) - const client = new DshClient(new URL('http://127.0.0.1:31415')) - - await client.history('session-1') - await client.history('session-1', 42) - - expect(requests).toEqual([ - { method: 'session.history', payload: { sessionId: 'session-1', maxMessages: 100 } }, - { method: 'session.history', payload: { sessionId: 'session-1', beforeSeq: 42, maxMessages: 100 } }, - ]) + it('returns approvals and questions via $events/result and clears cancelled requests', async () => { + const h = harness() + await h.client.startStreams() + const opening = await h.client.openSession('s') + opening.activate() + h.push('$events', { type: 'waterfall', eventId: 'approval', agentId: 's', event: 'approval/request', request: { toolName: 'Write', reason: 'Permission' } }) + expect(h.frames.at(-1)).toMatchObject({ rpcId: 'approval', payload: { type: 'approval/requested', approvalId: 'approval', toolName: 'Write' } }) + await h.client.respond('approval', { sessionId: 's', outcome: 'allowed-once' }) + expect(h.requests.at(-1)).toEqual({ endpoint: '$events/result', args: { clientId: 'client-1', eventId: 'approval', outcome: { kind: 'result', value: 'allowed-once' } } }) + await expect(h.client.respond('approval', { sessionId: 's', outcome: 'allowed-once' })).rejects.toThrow('no longer pending') + h.push('$events', { type: 'waterfall', eventId: 'question', agentId: 's', event: 'user-questions/request', request: { questions: [{ id: 'q', question: 'Which?' }] } }) + await h.client.respond('question', { sessionId: 's', answer: { answers: [{ id: 'q', selected: ['yes'] }] } }) + expect(h.requests.at(-1)?.args.outcome).toEqual({ kind: 'result', value: { answers: [{ id: 'q', selected: ['yes'] }] } }) + h.push('$events', { type: 'waterfall', eventId: 'cancelled', agentId: 's', event: 'approval/request', request: { toolName: 'Write' } }) + h.push('$events', { type: 'cancel', eventId: 'cancelled' }) + expect(h.frames.at(-1)?.payload.type).toBe('approval/resolved') + await expect(h.client.respond('cancelled', { sessionId: 's', outcome: 'allowed-once' })).rejects.toThrow() }) - it('uses the rc.8 command image envelope without breaking legacy command calls', async () => { - const requests: Array<{ method: string; payload: unknown }> = [] - vi.stubGlobal('fetch', vi.fn(async (_url: URL, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string; payload: unknown } - requests.push({ method: request.method, payload: request.payload }) - return new Response(JSON.stringify({ - type: 'server-response', rpcId: request.rpcId, - result: { - ok: true, - value: { commandId: 'command-1', result: { kind: 'success' } }, - }, - }), { status: 200, headers: { 'content-type': 'application/json' } }) - })) - const client = new DshClient(new URL('http://127.0.0.1:31415')) - - await client.executeCommand('session-1', '/compact') - await client.executeCommand('session-1', '/plan inspect this', [{ - type: 'image', - mediaType: 'image/png', - data: 'YWJj', - name: 'diagram.png', - }]) - - expect(requests).toEqual([ - { - method: 'commands/execute', - payload: { args: { agentId: 'session-1', line: '/compact' } }, - }, - { - method: 'commands/execute', - payload: { - args: { - agentId: 'session-1', - line: '/plan inspect this', - images: [{ mediaType: 'image/png', data: 'YWJj', name: 'diagram.png' }], - }, - }, - }, - ]) + it('delegates unsupported or other-session waterfalls without granting permission', async () => { + const h = harness() + await h.client.startStreams() + h.push('$events', { type: 'waterfall', eventId: 'other', agentId: 'elsewhere', event: 'approval/request', request: { toolName: 'Write' } }) + await Promise.resolve() + expect(h.requests.at(-1)?.args.outcome).toEqual({ kind: 'next' }) + expect(h.frames.some(f => f.payload.type === 'approval/requested')).toBe(false) + }) + + it('fails closed on event gaps, fails pending opens on disconnect, and does not replay mutations', async () => { + const h = harness() + await h.client.startStreams() + const opening = await h.client.openSession('s') + opening.activate() + h.push('session/follow', event(9)) + expect(h.errors).toHaveLength(1) + expect(h.frames.filter(f => f.payload.type === 'session/event')).toHaveLength(0) + expect(h.connection.openStreamSocket).toHaveBeenCalledTimes(1) + const disconnected = harness(false) + await disconnected.client.startStreams() + const pending = disconnected.client.openSession('s') + const assertion = expect(pending).rejects.toThrow('closed') + disconnected.socket.emit('close') + await assertion + expect(disconnected.errors).toHaveLength(1) + }) + + it('rejects pending snapshot reads on disposal', async () => { + const h = harness(false) + await h.client.startStreams() + const pending = h.client.openSession('s') + const assertion = expect(pending).rejects.toThrow() + h.client.dispose() + await assertion + expect(h.errors).toHaveLength(0) + }) + + it('invalidates an already resolved snapshot when disconnected before activation', async () => { + const h = harness() + await h.client.startStreams() + const opening = await h.client.openSession('s') + expect(opening.isCurrent()).toBe(true) + h.push('session/follow', event(6)) + h.socket.emit('close') + expect(opening.isCurrent()).toBe(false) + opening.activate() + expect(h.frames.filter(f => f.payload.type === 'session/event')).toHaveLength(0) + await expect(h.client.openSession('s')).rejects.toThrow('closed') + }) + + it('queues concurrent approvals and advances to the next unanswered request', async () => { + const h = harness() + await h.client.startStreams() + const opening = await h.client.openSession('s') + opening.activate() + for (const eventId of ['a', 'b']) h.push('$events', { type: 'waterfall', eventId, agentId: 's', event: 'approval/request', request: { toolName: 'Write' } }) + expect(h.frames.at(-1)?.rpcId).toBe('a') + expect(h.frames.filter(f => f.payload.type === 'approval/requested')).toHaveLength(1) + await h.client.respond('a', { sessionId: 's', outcome: 'rejected' }) + expect(h.frames.at(-1)).toMatchObject({ rpcId: 'b', payload: { type: 'approval/requested' } }) + await h.client.respond('b', { sessionId: 's', outcome: 'rejected' }) + expect(h.frames.at(-1)?.payload.type).toBe('approval/resolved') + }) + + it('uses the latest queue and jobs baseline when switching conversations', async () => { + const h = harness() + await h.client.startStreams() + const opening = await h.client.openSession('s') + h.push('session/control', { type: 'queue', sessionId: 's', items: [{ id: 'queued' }] }) + h.push('session/control', { type: 'jobs', sessionId: 's', jobs: [{ id: 'job' }] }) + opening.activate() + expect(h.frames.find(f => f.payload.type === 'session/queue')?.payload.items).toEqual([{ id: 'queued' }]) + expect(h.frames.find(f => f.payload.type === 'session/jobs')?.payload.jobs).toEqual([{ id: 'job' }]) + const second = await h.client.openSession('other') + second.activate() + expect(h.frames.filter(f => f.payload.type === 'session/queue').at(-1)?.payload.items).toEqual([]) + }) + + it('times out missing snapshots and cancels their logical stream', async () => { + vi.useFakeTimers() + const h = harness(false) + await h.client.startStreams() + const pending = h.client.openSession('s') + const rejection = expect(pending).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(30_000) + await rejection + expect(h.outgoing.at(-1)?.type).toBe('cancel') + }) +}) + +describe('packed DSH history', () => { + it('expands text, reasoning and tool chunks with exact sequences and timestamps', () => { + const records = [ + { type: 'chunks', event: { type: 'chunkrow/text-chunks', seq: 1, time: 100, data: { turn: 1, step: 1, index: 0, dt: [2, -1], texts: ['a', 'b', 'c'] } } }, + { type: 'chunks', event: { type: 'chunkrow/reasoning-chunks', seq: 4, time: 102, data: { turn: 1, step: 1, index: 1, dt: [], texts: ['reason'] } } }, + { type: 'chunks', event: { type: 'chunkrow/tool-call-chunks', seq: 5, time: 103, data: { turn: 1, step: 1, index: 2, id: 'call', name: 'Read', dt: [1], args: ['{', '}'] } } }, + ] + const decoded = decodeHistory(records) + expect(decoded.map(r => r.event.seq)).toEqual([1, 2, 3, 4, 5, 6]) + expect(decoded.map(r => r.event.time)).toEqual([100, 102, 101, 102, 103, 104]) + expect(decoded[5]?.event.data).toEqual({ turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 2, id: 'call', name: 'Read', argumentsDelta: '}' } }) + const projector = new ConversationProjector() + projector.reset(decoded) + expect(projector.messages()[0]?.text).toBe('abc') + }) + it('rejects malformed or unknown packed records instead of dropping content', () => { + expect(() => decodeHistory([{ type: 'chunks', event: { type: 'chunkrow/text-chunks', seq: 0, time: 0, data: { texts: ['a'], dt: [1] } } }])).toThrow() + expect(() => decodeHistory([{ type: 'chunks', event: { type: 'chunkrow/unknown', seq: 0, time: 0, data: {} } }])).toThrow() }) }) diff --git a/tests/dsh-remote-api.spec.ts b/tests/dsh-remote-api.spec.ts index a2ce879..8a2d40f 100644 --- a/tests/dsh-remote-api.spec.ts +++ b/tests/dsh-remote-api.spec.ts @@ -11,7 +11,7 @@ describe('DSH 0.1.2 Session Remotes', () => { await api.renameSession('s1', 'A new title') await api.cancel('s1') await api.updateQueue('s1', 'm1', { kind: 'steer' }) - expect(call.mock.calls).toEqual([ + expect(call.mock.calls.map(args => args.slice(0, 2))).toEqual([ ['session/list', { _request: {} }], ['session/create', { request: { cwd: 'C:\\Users\\测试\\project' } }], ['session/rename', { request: { sessionId: 's1', title: 'A new title' } }], @@ -36,4 +36,15 @@ describe('DSH 0.1.2 Session Remotes', () => { expect(second.content).toEqual([image]) expect(second.mode).toBe('queue') }) + + it('forwards the client lifetime to all session mutations', async () => { + const call = vi.fn().mockResolvedValue({ accepted: true }) + const lifetime = new AbortController() + const api = new DshRemoteApi({ call } as unknown as DshConnection, lifetime.signal) + await api.prompt('s1', 'hello') + await api.cancel('s1') + lifetime.abort() + expect(call.mock.calls.every(args => args[2] === 30_000 && args[3] === lifetime.signal)).toBe(true) + expect(call.mock.calls[0]![3].aborted).toBe(true) + }) }) From bf402b5a00a6bde17ccdcb151926353a152a800c Mon Sep 17 00:00:00 2001 From: Lixxx1 Date: Fri, 11 Sep 2026 11:21:46 +0800 Subject: [PATCH 03/14] feat: restore tool cards and change review for DSH 0.1.2 --- package.json | 1 + pnpm-lock.yaml | 9 ++ src/conversation.ts | 5 +- src/diff-review.ts | 117 +++++++++++++++------- src/tool-diff.ts | 63 ++++++++++++ src/tool-presentation.ts | 128 ++++++++++++++++++++++++ tests/diff-review.spec.ts | 167 ++++++++++++++++++++++++++++++++ tests/tool-diff.spec.ts | 50 ++++++++++ tests/tool-presentation.spec.ts | 75 ++++++++++++++ tests/tool-write-guard.spec.ts | 7 ++ 10 files changed, 586 insertions(+), 36 deletions(-) create mode 100644 src/tool-diff.ts create mode 100644 src/tool-presentation.ts create mode 100644 tests/tool-diff.spec.ts create mode 100644 tests/tool-presentation.spec.ts diff --git a/package.json b/package.json index 74ef937..0e0d433 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,7 @@ "@types/vscode": "^1.100.0", "@types/ws": "^8.18.1", "@vscode/vsce": "^3.6.0", + "diff": "^9.0.0", "esbuild": "^0.25.12", "typescript": "^6.0.3", "vitest": "^4.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90d83b2..9789a42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@vscode/vsce': specifier: ^3.6.0 version: 3.9.2 + diff: + specifier: ^9.0.0 + version: 9.0.0 esbuild: specifier: ^0.25.12 version: 0.25.12 @@ -789,6 +792,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2677,6 +2684,8 @@ snapshots: detect-libc@2.1.2: {} + diff@9.0.0: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 diff --git a/src/conversation.ts b/src/conversation.ts index 987fa76..f3c4e03 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -1,5 +1,6 @@ import { withoutIdeContext } from './ide-context.js' import type { ImageMediaType } from './dsh-client.js' +import { presentToolCall, presentToolResult } from './tool-presentation.js' export type ConversationRole = 'user' | 'assistant' | 'tool' | 'command' | 'notice' @@ -221,7 +222,7 @@ export class ConversationProjector { text: name, detail: 'Running…', streaming: true, - callView: toolView(view, 'call'), + callView: toolView(view, 'call') ?? presentToolCall(name, data.arguments), ...(typeof data.arguments === 'string' ? { rawInput: data.arguments } : {}), }) return @@ -249,7 +250,7 @@ export class ConversationProjector { failed, ...(current?.callView === undefined ? {} : { callView: current.callView }), ...(current?.rawInput === undefined ? {} : { rawInput: current.rawInput }), - resultView: toolView(view, 'result'), + resultView: toolView(view, 'result') ?? presentToolResult(current?.text ?? '', current?.rawInput, event, failed), ...(rawResult === '' ? {} : { rawResult }), ...(images.length > 0 ? { images } : {}), }) diff --git a/src/diff-review.ts b/src/diff-review.ts index 754480b..77acaa2 100644 --- a/src/diff-review.ts +++ b/src/diff-review.ts @@ -5,6 +5,8 @@ import * as vscode from 'vscode' import type { DshEvent } from './conversation.js' import type { HistoryEntry } from './dsh-client.js' import { comparableFilePath } from './file-path.js' +import { fileMutation, mutationDiff, diffsFromMeta, writeCreated, toolRecord, type FileMutation, type FileDiff } from './tool-presentation.js' +import { diffLineStats, verifiedMutation } from './tool-diff.js' export interface ChangedFileItem { path: string @@ -18,18 +20,14 @@ export interface ChangedFileGroup { files: ChangedFileItem[] } -interface FileDiff { - path: string - oldText: string | null - newText: string -} - interface PendingFile { absolutePath: string displayPath: string before?: FileImage diffs: FileDiff[] turn: number + mutation?: FileMutation + identity?: string } interface ReviewSnapshot { @@ -38,6 +36,7 @@ interface ReviewSnapshot { fullFile: boolean additions: number deletions: number + eventSeq?: number } interface ReviewFile { @@ -45,6 +44,7 @@ interface ReviewFile { displayPath: string turn: number snapshots: ReviewSnapshot[] + identity?: string } interface FileImage { @@ -106,14 +106,22 @@ function inside(basePath: string, filePath: string): boolean { function projectPath(cwd: string, value: string): string | undefined { const resolved = path.normalize(path.isAbsolute(value) ? value : path.resolve(cwd, value)) - return inside(cwd, resolved) ? resolved : undefined + if (!inside(cwd, resolved)) return undefined + // Resolve existing ancestors too: a newly created file can be under a symlink. + let ancestor = resolved + while (!fs.existsSync(ancestor) && path.dirname(ancestor) !== ancestor) ancestor = path.dirname(ancestor) + const physical = path.resolve(comparableFilePath(ancestor), path.relative(ancestor, resolved)) + return inside(comparableFilePath(path.resolve(cwd)), physical) ? resolved : undefined } function readImage(filePath: string): FileImage | undefined { try { const stat = fs.statSync(filePath) if (!stat.isFile() || stat.size > MAX_SNAPSHOT_BYTES) return undefined - return { text: fs.readFileSync(filePath, 'utf8') } + const bytes = fs.readFileSync(filePath) + if (bytes.length > MAX_SNAPSHOT_BYTES || bytes.includes(0)) return undefined + const text = bytes.toString('utf8') + return Buffer.from(text, 'utf8').equals(bytes) ? { text } : undefined } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { text: null } return undefined @@ -141,28 +149,15 @@ function groupedDiffs(cwd: string, diffs: readonly FileDiff[], turn: number): Ma return grouped } -function changedLineCount(value: string | null): number { - if (value === null || value === '') return 0 - const lines = value.split(/\r?\n/) - return lines.length - (lines.at(-1) === '' ? 1 : 0) -} - -function lineStats(diffs: readonly FileDiff[]): { additions: number; deletions: number } { - return diffs.reduce((total, diff) => ({ - additions: total.additions + changedLineCount(diff.newText), - deletions: total.deletions + changedLineCount(diff.oldText), - }), { additions: 0, deletions: 0 }) -} - -function fragmentSnapshot(diffs: readonly FileDiff[]): ReviewSnapshot | undefined { - const changed = diffs.filter(diff => (diff.oldText ?? '') !== diff.newText) +function fragmentSnapshot(diffs: readonly FileDiff[], contextualHunks = false): ReviewSnapshot | undefined { + const changed = diffs.filter(diff => diff.oldText === null || diff.oldText !== diff.newText) if (changed.length === 0) return undefined const separator = '\n\n⋯ unchanged lines ⋯\n\n' return { before: changed.map(diff => diff.oldText ?? '').join(separator), after: changed.map(diff => diff.newText).join(separator), fullFile: false, - ...lineStats(changed), + ...diffLineStats(changed, contextualHunks), } } @@ -195,8 +190,15 @@ export class DiffReviewManager implements vscode.TextDocumentContentProvider, vs } rebuild(sessionId: string, cwd: string, entries: readonly HistoryEntry[]): ChangedFileGroup[] { + const live = new Map(this.allReviews(sessionId).flatMap(file => file.snapshots + .filter(snapshot => snapshot.fullFile && snapshot.eventSeq !== undefined) + .map(snapshot => [`${file.turn}:${comparableFilePath(file.absolutePath)}:${snapshot.eventSeq}`, { snapshot, identity: file.identity }] as const))) this.clearSession(sessionId) for (const entry of entries) this.accept(sessionId, cwd, entry.event, entry.view, true) + for (const file of this.allReviews(sessionId)) file.snapshots = file.snapshots.map(snapshot => { + const cached = live.get(`${file.turn}:${comparableFilePath(file.absolutePath)}:${snapshot.eventSeq}`) + return cached !== undefined && cached.identity === file.identity ? cached.snapshot : snapshot + }) return this.changedFiles(sessionId) } @@ -207,16 +209,43 @@ export class DiffReviewManager implements vscode.TextDocumentContentProvider, vs } accept(sessionId: string, cwd: string, event: DshEvent, view?: unknown, replay = false): boolean { + if (event.type === 'assistant/message' && !replay) { + const data = toolRecord(event.data) + const message = toolRecord(data?.message) + if (Array.isArray(message?.content)) for (const value of message.content) { + const block = toolRecord(value) + if (block?.type === 'tool-call' && typeof block.id === 'string' && typeof block.name === 'string' + && fileMutation(block.name, block.arguments) !== undefined) { + this.accept(sessionId, cwd, { ...event, type: 'tool/call', data: { + turn: data?.turn, callId: block.id, name: block.name, arguments: block.arguments, + } }) + } + } + return false + } + if (event.type === 'turn/end') { + for (const key of this.pending.keys()) if (key.startsWith(`${sessionId}:`)) this.pending.delete(key) + return false + } const callId = callIdOf(event) if (callId === undefined) return false const key = `${sessionId}:${callId}` const turn = turnOf(event) if (event.type === 'tool/call') { - const files = groupedDiffs(cwd, diffsOf(view, 'call'), turn) + const data = record(event.data) + const mutation = typeof data?.name === 'string' ? fileMutation(data.name, data.arguments) : undefined + const files = groupedDiffs(cwd, mutation === undefined ? diffsOf(view, 'call') : [mutationDiff(mutation)], turn) + const previous = this.pending.get(key) + for (const [pathKey, file] of files) { + if (mutation !== undefined) file.mutation = mutation + const earlier = previous?.get(pathKey)?.before + if (earlier !== undefined) file.before = earlier + file.identity = previous?.get(pathKey)?.identity ?? comparableFilePath(file.absolutePath) + } if (!replay) { for (const file of files.values()) { - const before = readImage(file.absolutePath) + const before = file.before ?? readImage(file.absolutePath) if (before !== undefined) file.before = before } } @@ -234,24 +263,39 @@ export class DiffReviewManager implements vscode.TextDocumentContentProvider, vs const currentPending = pending === undefined ? undefined : new Map([...pending.values()].map(file => [comparableFilePath(file.absolutePath), file])) - const results = groupedDiffs(cwd, diffsOf(view, 'result'), turn) + const modern = [...(currentPending?.values() ?? [])].some(file => file.mutation !== undefined) + const applied = modern ? diffsFromMeta(record(event.data)?.meta) : undefined + const created = modern && writeCreated(event) + const resultDiffs = modern ? applied ?? [] : diffsOf(view, 'result') + const results = groupedDiffs(cwd, resultDiffs, turn) const fileKeys = new Set([...(currentPending?.keys() ?? []), ...results.keys()]) let updated = false for (const fileKey of fileKeys) { const callFile = currentPending?.get(fileKey) const resultFile = results.get(fileKey) const absolutePath = callFile?.absolutePath ?? resultFile?.absolutePath - if (absolutePath === undefined) continue + if (absolutePath === undefined || projectPath(cwd, absolutePath) === undefined) continue const displayPath = callFile?.displayPath ?? resultFile?.displayPath ?? path.relative(cwd, absolutePath) const fileTurn = resultFile?.turn ?? callFile?.turn ?? turn const diffs = resultFile?.diffs ?? callFile?.diffs ?? [] + if (modern && callFile === undefined) continue const after = replay ? undefined : readImage(absolutePath) - const stats = lineStats(diffs) - const snapshot = callFile?.before !== undefined && after !== undefined && callFile.before.text !== after.text - ? { before: callFile.before.text, after: after.text, fullFile: true, ...stats } - : fragmentSnapshot(diffs) + const mutation = callFile?.mutation + const actualDiffs = mutation === undefined ? diffs : resultFile?.diffs + ?? (created && mutation.kind === 'write' ? [mutationDiff(mutation)] : []) + const before = callFile?.before + const sameTarget = before?.text === null || callFile?.identity === comparableFilePath(absolutePath) + const matchedApplied = resultFile?.diffs ?? (applied?.length === 0 ? applied : undefined) + const verified = sameTarget && (mutation === undefined + ? before !== undefined && after !== undefined && before.text !== after.text + : before !== undefined && after !== undefined && verifiedMutation(before.text, after.text, mutation, matchedApplied, created)) + const snapshot = verified && before !== undefined && after !== undefined + ? { before: before.text, after: after.text, fullFile: true, + ...diffLineStats([{ path: displayPath, oldText: before.text, newText: after.text ?? '' }]) } + : fragmentSnapshot(actualDiffs, mutation !== undefined && !created) if (snapshot === undefined) continue - this.record(sessionId, { absolutePath, displayPath, turn: fileTurn, snapshots: [snapshot] }) + this.record(sessionId, { absolutePath, displayPath, turn: fileTurn, identity: comparableFilePath(absolutePath), + snapshots: [{ ...snapshot, eventSeq: event.seq }] }) updated = true } return updated @@ -363,7 +407,11 @@ export class DiffReviewManager implements vscode.TextDocumentContentProvider, vs const key = comparableFilePath(incoming.absolutePath) const existing = files.get(key) if (existing === undefined) files.set(key, incoming) - else existing.snapshots.push(...incoming.snapshots) + else for (const snapshot of incoming.snapshots) { + if (snapshot.eventSeq === undefined || !existing.snapshots.some(value => value.eventSeq === snapshot.eventSeq)) { + existing.snapshots.push(snapshot) + } + } } private reviewContent(review: ReviewFile): { before: string | null; after: string | null; fullFile: boolean } { @@ -446,6 +494,7 @@ export class DiffReviewManager implements vscode.TextDocumentContentProvider, vs private validateCurrentFile(review: ReviewFile, expected: string | null): void { const reviewPath = comparableFilePath(review.absolutePath) + if (review.identity !== undefined && reviewPath !== review.identity) throw new Error(`Cannot revert ${review.displayPath}: its filesystem target changed.`) const dirty = vscode.workspace.textDocuments.some(document => document.isDirty && comparableFilePath(path.resolve(document.uri.fsPath)) === reviewPath, diff --git a/src/tool-diff.ts b/src/tool-diff.ts new file mode 100644 index 0000000..e0d319c --- /dev/null +++ b/src/tool-diff.ts @@ -0,0 +1,63 @@ +import { diffLines, structuredPatch } from 'diff' +import type { FileDiff, FileMutation } from './tool-presentation.js' + +export function normalizeDiffText(text: string): string { return text.replaceAll('\r\n', '\n') } + +/** Match the official three-context-line metadata without treating context as changed lines. */ +export function appliedHunks(before: string, after: string): Array> | undefined { + const patch = structuredPatch('', '', normalizeDiffText(before), normalizeDiffText(after), undefined, undefined, + { context: 3, timeout: 100 }) + return patch?.hunks.map(hunk => { + const side = (excluded: string): string[] => hunk.lines + .filter(line => !line.startsWith(excluded) && !line.startsWith('\\')) + .map(line => line.slice(1)) + const old = side('+') + return { oldText: old.length === 0 ? null : old.join('\n'), newText: side('-').join('\n') } + }) +} + +export function diffLineStats(diffs: readonly FileDiff[], contextualHunks = false): { additions: number; deletions: number } { + let additions = 0 + let deletions = 0 + for (const diff of diffs) { + const before = normalizeDiffText(diff.oldText ?? '') + const after = normalizeDiffText(diff.newText) + // In a hunk, a trailing LF separates an actual empty context line; it is not + // the file's final newline. Preserve it when counting fragment-only history. + const lines = (text: string): string => text === '' ? '' : (contextualHunks ? text : text.replace(/\n$/, '')) + '\n' + const changes = diffLines(lines(before), lines(after), { timeout: 100 }) + if (changes === undefined) continue + for (const change of changes) { + if (change.added) additions += change.count + if (change.removed) deletions += change.count + } + } + return { additions, deletions } +} + +/** Full-file undo is available only when the captured file and applied metadata agree. */ +export function verifiedMutation(before: string | null, after: string | null, mutation: FileMutation, + applied: readonly FileDiff[] | undefined, created: boolean): boolean { + if (after === null || (before?.startsWith('\uFEFF') ?? false)) return false + let expected: string + if (mutation.kind === 'write') { + expected = mutation.content + if (before === null) return created && after === expected + if (created) return false + } else { + if (before === null) return false + const normalized = normalizeDiffText(before) + const old = normalizeDiffText(mutation.oldText) + const parts = normalized.split(old) + if (old === '' || parts.length < 2 || (!mutation.replaceAll && parts.length !== 2)) return false + expected = parts.join(normalizeDiffText(mutation.newText)) + const sample = before.slice(0, 4096) + const crlf = sample.split('\r\n').length - 1 + const lf = sample.split('\n').length - 1 - crlf + if (crlf > lf) expected = expected.replaceAll('\n', '\r\n') + } + if (after !== expected || before === after || applied === undefined) return false + const computed = appliedHunks(before, after) + return computed !== undefined && computed.length === applied.length && computed.every((diff, index) => + diff.oldText === applied[index]?.oldText && diff.newText === applied[index]?.newText) +} diff --git a/src/tool-presentation.ts b/src/tool-presentation.ts new file mode 100644 index 0000000..1da2fb6 --- /dev/null +++ b/src/tool-presentation.ts @@ -0,0 +1,128 @@ +import type { DshEvent } from './conversation.js' + +type RecordValue = Record +export interface FileDiff { path: string; oldText: string | null; newText: string } +export type FileMutation = + | { kind: 'write'; path: string; content: string } + | { kind: 'edit'; path: string; oldText: string; newText: string; replaceAll: boolean } + +export function toolRecord(value: unknown): RecordValue | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as RecordValue : undefined +} + +export function toolArguments(value: unknown): RecordValue | undefined { + if (typeof value !== 'string') return toolRecord(value) + try { return toolRecord(JSON.parse(value)) } catch { return undefined } +} + +/** Only interpret the official filesystem tools; opaque plugin metadata stays opaque. */ +export function fileMutation(name: string, raw: unknown): FileMutation | undefined { + const args = toolArguments(raw) + if (typeof args?.file_path !== 'string' || args.file_path.trim() === '') return undefined + if (name === 'write' && typeof args.content === 'string') return { kind: 'write', path: args.file_path, content: args.content } + if (name === 'edit' && typeof args.old_string === 'string' && args.old_string !== '' && typeof args.new_string === 'string' + && (args.replace_all === undefined || typeof args.replace_all === 'boolean')) { + return { kind: 'edit', path: args.file_path, oldText: args.old_string, newText: args.new_string, replaceAll: args.replace_all === true } + } + return undefined +} + +export function mutationDiff(mutation: FileMutation): FileDiff { + return { path: mutation.path, oldText: mutation.kind === 'edit' ? mutation.oldText : null, + newText: mutation.kind === 'edit' ? mutation.newText : mutation.content } +} + +/** The persisted 0.1.2 fs result metadata contains applied contextual hunks, not full files. */ +export function diffsFromMeta(meta: unknown): FileDiff[] | undefined { + const values = toolRecord(meta)?.diffs + if (!Array.isArray(values)) return undefined + const diffs: FileDiff[] = [] + for (const value of values) { + const diff = toolRecord(value) + if (typeof diff?.path !== 'string' || diff.path.trim() === '' || (diff.oldText !== null && typeof diff.oldText !== 'string') + || typeof diff.newText !== 'string') return undefined + diffs.push({ path: diff.path, oldText: diff.oldText, newText: diff.newText }) + } + return diffs +} + +export function toolResultText(event: DshEvent): string { + const message = toolRecord(toolRecord(event.data)?.message) + const result = Array.isArray(message?.content) ? toolRecord(message.content[0]) : undefined + if (typeof result?.content === 'string') return result.content + if (!Array.isArray(result?.content)) return '' + return result.content.flatMap(value => { + const part = toolRecord(value) + return part?.type === 'text' && typeof part.text === 'string' ? [part.text] : [] + }).join('\n') +} + +/** A successful write can have no hunks for a create, a no-op, or an oversized diff basis. */ +export function writeCreated(event: DshEvent): boolean { + return /^[^\n]*<\/path>\nfile<\/type>\n\nCreated file\n<\/content>$/.test(toolResultText(event)) +} + +export function presentToolCall(name: string, raw: unknown): RecordValue | undefined { + const args = toolArguments(raw) + if (args === undefined) return undefined + const mutation = fileMutation(name, args) + if (mutation !== undefined) return { card: 'diff', title: `${name === 'write' ? 'Write' : 'Edit'} ${mutation.path}`, + diffs: [mutationDiff(mutation)], locations: [{ path: mutation.path }] } + if (name === 'read' && typeof args.file_path === 'string') return { card: 'generic', title: `Read ${args.file_path}`, + locations: [{ path: args.file_path, line: typeof args.offset === 'number' ? args.offset : 1 }] } + if ((name === 'bash' || name === 'pwsh') && typeof args.command === 'string') { + return { card: 'terminal', title: args.command, ...(typeof args.cwd === 'string' ? { cwd: args.cwd } : {}) } + } + if ((name === 'grep' || name === 'glob') && typeof args.pattern === 'string') { + return { card: 'generic', title: `${name === 'grep' ? 'Grep' : 'Glob'} ${args.pattern}` } + } + return undefined +} + +/** Rebuild built-in cards from durable events; never execute plugin presenter code in VS Code. */ +export function presentToolResult(name: string, raw: unknown, event: DshEvent, failed: boolean): RecordValue | undefined { + const data = toolRecord(event.data) + const meta = toolRecord(data?.meta) + const call = presentToolCall(name, raw) + const text = toolResultText(event) + if (call === undefined && text === '' && meta === undefined) return undefined + const generic: RecordValue = { card: 'generic', ...(call?.title === undefined ? {} : { title: call.title }), content: [{ type: 'text', text }] } + if (failed) return generic + const mutation = fileMutation(name, raw) + if (mutation !== undefined) { + const diffs = diffsFromMeta(meta) + if (diffs !== undefined && diffs.length > 0) return { card: 'diff', title: call?.title, diffs } + if (mutation.kind === 'write' && writeCreated(event)) return { card: 'diff', title: call?.title, diffs: [mutationDiff(mutation)] } + return generic + } + if (name === 'read' && meta !== undefined) { + const { path, offset, lines, totalLines } = meta + if (typeof path === 'string' && Number.isSafeInteger(offset) && (offset as number) > 0 + && Number.isSafeInteger(totalLines) && (totalLines as number) >= 0 && Array.isArray(lines)) { + let previous = (offset as number) - 1 + const valid = lines.every(value => { + const line = toolRecord(value) + if (line === undefined || !Number.isSafeInteger(line.number) || (line.number as number) <= previous + || (line.number as number) > (totalLines as number) || typeof line.text !== 'string') return false + previous = line.number as number + return true + }) + if (valid) return { card: 'read', title: call?.title, path, offset, lines, totalLines } + } + } + if ((name === 'glob' || name === 'grep') && meta !== undefined && typeof meta.truncated === 'boolean' + && Number.isSafeInteger(meta.total) && (meta.total as number) >= 0) { + if (meta.shape === 'paths' && Array.isArray(meta.paths) && meta.paths.every(p => typeof p === 'string')) { + return { card: 'search', title: call?.title, shape: 'paths', paths: meta.paths, truncated: meta.truncated, total: meta.total } + } + if (meta.shape === 'matches' && Array.isArray(meta.files) && meta.files.every(value => { + const file = toolRecord(value) + return typeof file?.path === 'string' && Array.isArray(file.matches) && file.matches.every(value => { + const match = toolRecord(value) + return typeof match?.line === 'string' && Number.isSafeInteger(match.lineNumber) && (match.lineNumber as number) > 0 + }) + })) return { card: 'search', title: call?.title, shape: 'matches', files: meta.files, truncated: meta.truncated, total: meta.total } + } + if (name === 'bash' || name === 'pwsh') return { card: 'terminal', title: call?.title, output: text } + return generic +} diff --git a/tests/diff-review.spec.ts b/tests/diff-review.spec.ts index 0210cb4..aaafaa8 100644 --- a/tests/diff-review.spec.ts +++ b/tests/diff-review.spec.ts @@ -25,6 +25,7 @@ vi.mock('vscode', () => { import type { DshEvent } from '../src/conversation.js' import { DiffReviewManager } from '../src/diff-review.js' +import { appliedHunks } from '../src/tool-diff.js' function event(type: string, seq: number, data: unknown): DshEvent { return { type, seq, time: seq, data } @@ -42,6 +43,172 @@ describe('DiffReviewManager', () => { return directory } + function modernEvents(name: 'edit' | 'write', args: Record, before: string | null, after: string, turn = 1) { + const callId = `modern-${turn}` + const call = event('tool/call', turn * 10, { turn, callId, name, arguments: JSON.stringify(args) }) + const text = name === 'write' + ? `${args.file_path}\nfile\n\n${before === null ? 'Created' : 'Updated'} file\n` + : `The file ${args.file_path} has been updated successfully.` + const result = event('tool/result', turn * 10 + 1, { turn, + meta: { diffs: before === null ? [] : appliedHunks(before, after)?.map(diff => ({ path: args.file_path, ...diff })) }, + message: { source: { kind: 'tool', callId }, content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }] }, + }) + return { call, result } + } + + it('reviews and reverts rc.1 applied hunks, retaining complete snapshots across history rebuilds', async () => { + const cwd = temporaryWorkspace(), file = path.join(cwd, 'app.ts') + const before = 'context\nold\ncontext\n', after = 'context\nnew\ncontext\n' + const { call, result } = modernEvents('edit', { file_path: 'app.ts', old_string: 'old', new_string: 'new' }, before, after) + const manager = new DiffReviewManager() + fs.writeFileSync(file, before) + manager.accept('modern', cwd, call) + fs.writeFileSync(file, after) + expect(manager.accept('modern', cwd, result)).toBe(true) + const expected = [{ turn: 1, files: [{ path: 'app.ts', additions: 1, deletions: 1, canRevert: true }] }] + expect(manager.changedFiles('modern')).toEqual(expected) + const entries = [call, result].map(event => ({ event })) + expect(manager.rebuild('modern', cwd, entries)).toEqual(expected) + expect(manager.prependHistory('modern', cwd, entries)).toEqual(expected) + await manager.reviewFile('modern', cwd, 'app.ts', 1) + const [, beforeUri, afterUri] = mocks.executeCommand.mock.calls.at(-1)! + expect(manager.provideTextDocumentContent(beforeUri)).toBe(before) + expect(manager.provideTextDocumentContent(afterUri)).toBe(after) + expect(manager.revertFile('modern', cwd, 'app.ts', 1)).toEqual([]) + expect(fs.readFileSync(file, 'utf8')).toBe(before) + }) + + it('captures the early assistant tool intent before the later tool/call notification', () => { + const cwd = temporaryWorkspace(), file = path.join(cwd, 'app.ts') + const args = { file_path: 'app.ts', old_string: 'old', new_string: 'new' } + const { call, result } = modernEvents('edit', args, 'old\n', 'new\n') + const manager = new DiffReviewManager() + fs.writeFileSync(file, 'old\n') + manager.accept('early', cwd, event('assistant/message', 9, { turn: 1, message: { content: [ + { type: 'tool-call', id: 'modern-1', name: 'edit', arguments: JSON.stringify(args) }, + ] } })) + fs.writeFileSync(file, 'new\n') + manager.accept('early', cwd, call) + manager.accept('early', cwd, result) + expect(manager.changedFiles('early')[0]?.files[0]?.canRevert).toBe(true) + }) + + it('does not invent a reversible full file from replayed hunks or a late snapshot', () => { + const cwd = temporaryWorkspace(), file = path.join(cwd, 'app.ts') + const { call, result } = modernEvents('edit', { file_path: 'app.ts', old_string: 'old', new_string: 'new' }, 'old\n', 'new\n') + fs.writeFileSync(file, 'new\n') + for (const replay of [true, false]) { + const manager = new DiffReviewManager() + manager.accept('late', cwd, call, undefined, replay) + manager.accept('late', cwd, result, undefined, replay) + expect(manager.changedFiles('late')).toEqual([{ turn: 1, files: [{ path: 'app.ts', additions: 1, deletions: 1, canRevert: false }] }]) + expect(() => manager.revertAll('late')).toThrow('full snapshot') + expect(fs.readFileSync(file, 'utf8')).toBe('new\n') + } + }) + + it.each(['', 'one\ntwo\n'])('safely reverts an explicitly created rc.1 file containing %j', content => { + const cwd = temporaryWorkspace(), file = path.join(cwd, 'new.ts') + const { call, result } = modernEvents('write', { file_path: 'new.ts', content }, null, content) + const manager = new DiffReviewManager() + manager.accept('create', cwd, call) + fs.writeFileSync(file, content) + manager.accept('create', cwd, result) + expect(manager.changedFiles('create')[0]?.files[0]?.canRevert).toBe(true) + manager.revertAll('create') + expect(fs.existsSync(file)).toBe(false) + }) + + it('rejects dirty buffers and subsequent user edits, while Keep leaves bytes untouched', () => { + const cwd = temporaryWorkspace(), file = path.join(cwd, 'app.ts') + const { call, result } = modernEvents('write', { file_path: 'app.ts', content: 'new\n' }, 'old\n', 'new\n') + const manager = new DiffReviewManager() + fs.writeFileSync(file, 'old\n') + manager.accept('dirty', cwd, call) + fs.writeFileSync(file, 'new\n') + manager.accept('dirty', cwd, result) + mocks.textDocuments.push({ isDirty: true, uri: { fsPath: file } }) + expect(() => manager.revertAll('dirty')).toThrow('unsaved') + mocks.textDocuments.splice(0) + fs.writeFileSync(file, 'manual edit\n') + expect(() => manager.revertAll('dirty')).toThrow('changed after DeepSeek') + expect(manager.keepAll('dirty')).toEqual([]) + expect(fs.readFileSync(file, 'utf8')).toBe('manual edit\n') + }) + + it('ignores failed results, empty overwrite metadata, malformed metadata and opaque plugin metadata', () => { + const cwd = temporaryWorkspace(), file = path.join(cwd, 'app.ts') + for (const variant of ['failure', 'empty', 'malformed', 'plugin', 'wrong-path']) { + const { call, result } = modernEvents('write', { file_path: 'app.ts', content: 'new\n' }, 'old\n', 'new\n') + if (variant === 'failure') (result.data as any).error = { message: 'denied' } + if (variant === 'empty') (result.data as any).meta = { diffs: [] } + if (variant === 'malformed') (result.data as any).meta = { diffs: [null] } + if (variant === 'plugin') (call.data as any).name = 'mcp__plugin__write' + if (variant === 'wrong-path') (result.data as any).meta.diffs[0].path = 'other.ts' + const manager = new DiffReviewManager() + fs.writeFileSync(file, 'old\n') + manager.accept(variant, cwd, call) + fs.writeFileSync(file, 'new\n') + manager.accept(variant, cwd, result) + expect(manager.changedFiles(variant)).toEqual([]) + } + }) + + it('validates a continuous rc.1 multi-turn edit chain before Revert All', () => { + const cwd = temporaryWorkspace(), file = path.join(cwd, 'app.ts') + const manager = new DiffReviewManager() + let before = 'original\r\n' + fs.writeFileSync(file, before) + for (const turn of [1, 2]) { + const after = `turn ${turn}\r\n` + const { call, result } = modernEvents('write', { file_path: 'app.ts', content: after }, before, after, turn) + manager.accept('series', cwd, call) + fs.writeFileSync(file, after) + manager.accept('series', cwd, result) + before = after + } + expect(manager.changedFiles('series').map(group => group.files[0]?.canRevert)).toEqual([true, true]) + manager.revertAll('series') + expect(fs.readFileSync(file, 'utf8')).toBe('original\r\n') + }) + + it('does not enable Revert for a binary or oversized captured file', () => { + const cwd = temporaryWorkspace(), file = path.join(cwd, 'app.ts') + for (const before of ['old\0bytes', 'x'.repeat(5 * 1024 * 1024 + 1)]) { + const { call, result } = modernEvents('write', { file_path: 'app.ts', content: 'new\n' }, 'old\n', 'new\n') + const manager = new DiffReviewManager() + fs.writeFileSync(file, before) + manager.accept('bounded', cwd, call) + fs.writeFileSync(file, 'new\n') + manager.accept('bounded', cwd, result) + expect(manager.changedFiles('bounded')[0]?.files[0]?.canRevert).toBe(false) + expect(() => manager.revertAll('bounded')).toThrow('full snapshot') + expect(fs.readFileSync(file, 'utf8')).toBe('new\n') + } + }) + + it.skipIf(process.platform === 'win32')('does not track symlink escapes or revert through a changed symlink target', () => { + const cwd = temporaryWorkspace(), outside = temporaryWorkspace() + fs.symlinkSync(outside, path.join(cwd, 'external')) + const escaped = modernEvents('write', { file_path: 'external/new.ts', content: 'new\n' }, null, 'new\n') + const manager = new DiffReviewManager() + manager.accept('escape', cwd, escaped.call) + fs.writeFileSync(path.join(outside, 'new.ts'), 'new\n') + manager.accept('escape', cwd, escaped.result) + expect(manager.changedFiles('escape')).toEqual([]) + + const file = path.join(cwd, 'a.ts'), other = path.join(cwd, 'b.ts'), link = path.join(cwd, 'link.ts') + fs.writeFileSync(file, 'old\n'); fs.writeFileSync(other, 'new\n'); fs.symlinkSync(file, link) + const { call, result } = modernEvents('edit', { file_path: 'link.ts', old_string: 'old', new_string: 'new' }, 'old\n', 'new\n') + manager.accept('link', cwd, call) + fs.writeFileSync(file, 'new\n') + manager.accept('link', cwd, result) + fs.unlinkSync(link); fs.symlinkSync(other, link) + expect(() => manager.revertAll('link')).toThrow('target changed') + expect(fs.readFileSync(other, 'utf8')).toBe('new\n') + expect(manager.rebuild('link', cwd, [call, result].map(event => ({ event })))[0]?.files[0]?.canRevert).toBe(false) + }) + it('rebuilds changed files from official DSH diff views and opens a native diff', async () => { const manager = new DiffReviewManager() const displayPath = path.join('src', 'app.ts') diff --git a/tests/tool-diff.spec.ts b/tests/tool-diff.spec.ts new file mode 100644 index 0000000..f57c4ac --- /dev/null +++ b/tests/tool-diff.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { appliedHunks, diffLineStats, verifiedMutation } from '../src/tool-diff.js' +import type { FileMutation } from '../src/tool-presentation.js' + +describe('applied tool diffs', () => { + const mutation: FileMutation = { kind: 'edit', path: 'app.ts', oldText: 'old', newText: 'new', replaceAll: false } + const diffs = [{ path: 'app.ts', oldText: 'context\nold\ncontext', newText: 'context\nnew\ncontext' }] + + it('matches the official three-line-context metadata and only counts changed lines', () => { + expect(appliedHunks('context\nold\ncontext\n', 'context\nnew\ncontext\n')).toEqual(diffs.map(({ path: _, ...diff }) => diff)) + expect(diffLineStats(diffs)).toEqual({ additions: 1, deletions: 1 }) + expect(diffLineStats([{ path: 'new', oldText: null, newText: 'one\ntwo\n' }])).toEqual({ additions: 2, deletions: 0 }) + expect(diffLineStats([{ path: 'empty', oldText: null, newText: '' }])).toEqual({ additions: 0, deletions: 0 }) + expect(diffLineStats([{ path: 'blank-line', oldText: 'context', newText: 'context\n' }], true)).toEqual({ additions: 1, deletions: 0 }) + }) + + it('checks both exact captured bytes and applied hunks before enabling undo', () => { + const before = 'context\nold\ncontext\n' + const after = 'context\nnew\ncontext\n' + expect(verifiedMutation(before, after, mutation, diffs, false)).toBe(true) + expect(verifiedMutation(after, after, mutation, diffs, false)).toBe(false) + expect(verifiedMutation(before, after + 'user edit\n', mutation, diffs, false)).toBe(false) + expect(verifiedMutation('other\nold\ncontext\n', 'other\nnew\ncontext\n', mutation, diffs, false)).toBe(false) + expect(verifiedMutation(before, after, mutation, undefined, false)).toBe(false) + expect(verifiedMutation(before, after, mutation, [], false)).toBe(false) + }) + + it('verifies replace_all across distinct hunks and CRLF files without losing original bytes', () => { + const before = ['old', ...Array.from({ length: 12 }, (_, i) => `context ${i}`), 'old', ''].join('\r\n') + const after = before.replaceAll('old', 'new') + const diffs = appliedHunks(before, after)!.map(diff => ({ path: 'app.ts', ...diff })) + expect(diffs).toHaveLength(2) + expect(diffLineStats(diffs)).toEqual({ additions: 2, deletions: 2 }) + expect(verifiedMutation(before, after, { ...mutation, replaceAll: true }, diffs, false)).toBe(true) + expect(verifiedMutation(before, after, mutation, diffs, false)).toBe(false) + expect(verifiedMutation(before, after.replaceAll('\r\n', '\n'), { ...mutation, replaceAll: true }, diffs, false)).toBe(false) + }) + + it('distinguishes new files, overwritten files, unknown bases and no-op writes', () => { + const write: FileMutation = { kind: 'write', path: 'app.ts', content: 'new\n' } + expect(verifiedMutation(null, 'new\n', write, [], true)).toBe(true) + expect(verifiedMutation(null, 'new\n', write, [], false)).toBe(false) + expect(verifiedMutation(null, 'different', write, [], true)).toBe(false) + expect(verifiedMutation(null, '', { ...write, content: '' }, [], true)).toBe(true) + expect(verifiedMutation('old\n', 'new\n', write, [{ path: 'app.ts', oldText: 'old', newText: 'new' }], false)).toBe(true) + expect(verifiedMutation('old\n', 'new\n', write, [], false)).toBe(false) + expect(verifiedMutation('new\n', 'new\n', write, [], false)).toBe(false) + expect(verifiedMutation('\uFEFFold\n', 'new\n', write, [], false)).toBe(false) + }) +}) diff --git a/tests/tool-presentation.spec.ts b/tests/tool-presentation.spec.ts new file mode 100644 index 0000000..a4c7ce0 --- /dev/null +++ b/tests/tool-presentation.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { ConversationProjector, type DshEvent } from '../src/conversation.js' +import { diffsFromMeta, fileMutation, presentToolCall, presentToolResult } from '../src/tool-presentation.js' + +function result(meta: unknown, text = 'done', failed = false): DshEvent { + return { type: 'tool/result', seq: 2, time: 2, data: { turn: 1, meta, message: { + source: { kind: 'tool', callId: 'call' }, + content: [{ type: 'tool-result', toolCallId: 'call', isError: failed, content: [{ type: 'text', text }] }], + } } } +} + +describe('0.1.2 built-in tool presentation', () => { + const args = { file_path: 'app.ts', old_string: 'old', new_string: 'new' } + const diffs = [{ path: 'app.ts', oldText: 'context\nold\ncontext', newText: 'context\nnew\ncontext' }] + + it('uses applied contextual hunks, identically for live events and history without legacy views', () => { + const entries = [ + { type: 'tool/call', seq: 1, time: 1, data: { turn: 1, callId: 'call', name: 'edit', arguments: JSON.stringify(args) } }, + result({ diffs }), + ] + const live = new ConversationProjector() + entries.forEach(entry => live.apply(entry)) + const history = new ConversationProjector() + history.reset(entries) + expect(history.messages()).toEqual(live.messages()) + expect(live.messages()[0]).toMatchObject({ text: 'edit', failed: false, + callView: { card: 'diff', title: 'Edit app.ts', diffs: [{ path: 'app.ts', oldText: 'old', newText: 'new' }] }, + resultView: { card: 'diff', diffs }, rawResult: 'done' }) + }) + + it('never shows a requested edit as applied after failure, a no-op or malformed metadata', () => { + for (const meta of [undefined, { diffs: [] }, { diffs: [null] }]) { + expect(presentToolResult('edit', args, result(meta), false)?.card).toBe('generic') + } + const projector = new ConversationProjector() + projector.apply({ type: 'tool/call', seq: 1, time: 1, data: { callId: 'call', name: 'edit', arguments: JSON.stringify(args) } }) + projector.apply(result({ diffs }, 'Permission denied', true)) + expect(projector.messages()[0]).toMatchObject({ failed: true, + resultView: { card: 'generic', content: [{ type: 'text', text: 'Permission denied' }] } }) + }) + + it('only presents a whole-file creation when the official result says it created a file', () => { + const args = { file_path: 'new.ts', content: 'new\n' } + const envelope = (operation: string) => `new.ts\nfile\n\n${operation} file\n` + expect(presentToolResult('write', args, result({ diffs: [] }, envelope('Created')), false)).toMatchObject({ + card: 'diff', diffs: [{ path: 'new.ts', oldText: null, newText: 'new\n' }], + }) + expect(presentToolResult('write', args, result({ diffs: [] }, envelope('Updated')), false)?.card).toBe('generic') + expect(presentToolResult('write', args, result({}, `Quoted:\n${envelope('Created')}`), false)?.card).toBe('generic') + }) + + it('reconstructs line-numbered Read and clickable search results', () => { + const read = { path: 'app.ts', offset: 42, lines: [{ number: 42, text: 'hello' }], totalLines: 80 } + expect(presentToolResult('read', { file_path: 'app.ts', offset: 42 }, result(read), false)).toMatchObject({ card: 'read', ...read }) + expect(presentToolCall('read', { file_path: 'app.ts', offset: 42 })).toMatchObject({ locations: [{ path: 'app.ts', line: 42 }] }) + const paths = { shape: 'paths', paths: ['app.ts', 'other.ts'], total: 2, truncated: false } + expect(presentToolResult('glob', { pattern: '*.ts' }, result(paths), false)).toMatchObject({ card: 'search', ...paths }) + const matches = { shape: 'matches', files: [{ path: 'app.ts', matches: [{ lineNumber: 42, line: 'hello' }] }], total: 1, truncated: false } + expect(presentToolResult('grep', { pattern: 'hello' }, result(matches), false)).toMatchObject({ card: 'search', ...matches }) + expect(presentToolResult('read', {}, result({ ...read, lines: [{ number: -1, text: 'bad' }] }), false)?.card).toBe('generic') + expect(presentToolResult('grep', {}, result({ ...matches, files: [null] }), false)?.card).toBe('generic') + }) + + it('preserves shell output and does not interpret arbitrary plugin metadata as filesystem changes', () => { + for (const name of ['bash', 'pwsh']) { + expect(presentToolResult(name, { command: 'node app.js' }, result(undefined, 'output\n[exit code: 1]'), false)).toMatchObject({ + card: 'terminal', title: 'node app.js', output: 'output\n[exit code: 1]', + }) + } + expect(presentToolResult('mcp__custom__edit', args, result({ diffs }), false)?.card).toBe('generic') + expect(fileMutation('mcp__custom__edit', args)).toBeUndefined() + expect(fileMutation('edit', '{broken')).toBeUndefined() + expect(diffsFromMeta({ diffs: [...diffs, { path: 'app.ts', newText: 'missing old text' }] })).toBeUndefined() + }) +}) diff --git a/tests/tool-write-guard.spec.ts b/tests/tool-write-guard.spec.ts index dd16b97..c62787c 100644 --- a/tests/tool-write-guard.spec.ts +++ b/tests/tool-write-guard.spec.ts @@ -31,6 +31,13 @@ describe('toolWriteIntents', () => { expect(toolWriteIntents(value, view)).toEqual([{ callId: 'call-2', paths: ['a.ts', 'b.ts'] }]) }) + it.each(['write', 'edit'])('detects rc.1 %s arguments without a presentation view', name => { + expect(toolWriteIntents(event('tool/call', { + turn: 1, step: 1, callId: 'rc1-call', name, + arguments: JSON.stringify({ file_path: 'src/app.ts', content: 'new', old_string: 'old', new_string: 'new' }), + }))).toEqual([{ callId: 'rc1-call', paths: ['src/app.ts'] }]) + }) + it('ignores read-only calls and malformed write arguments', () => { expect(toolWriteIntents(event('tool/call', { callId: 'read-1', name: 'read', arguments: '{"file_path":"src/app.ts"}', From 0f663fe118c81e4d9d11fabadecb72287c39f7f9 Mon Sep 17 00:00:00 2001 From: Lixxx1 Date: Fri, 11 Sep 2026 21:44:26 +0800 Subject: [PATCH 04/14] feat: adapt runtime controls and streaming to DSH 0.1.5 Synchronize model, command and preset discovery with runtime changes and protect settings edits with revisions. Expose runtime plugin inventory, including preset-scoped entries and external runtimes. Support transient assistant streams and command attachment format negotiation while preserving DSH 0.1.2 compatibility. Add regression coverage for streaming, discovery, settings and plugin management. --- src/collaboration-state.ts | 2 +- src/conversation.ts | 41 ++++- src/dsh-assistant-stream.ts | 145 ++++++++++++++++ src/dsh-client.ts | 80 +++++++-- src/dsh-command-transport.ts | 42 +++++ src/dsh-session-feed.ts | 25 ++- src/extension.ts | 188 +++++++++++++------- src/plugin-manager.ts | 132 ++++++++------ src/plugin-profile.ts | 50 ++++-- src/remote-read.ts | 30 ++++ src/runtime-plugin-inventory.ts | 37 ++++ src/runtime-settings-picker.ts | 61 +++++++ tests/collaboration-state.spec.ts | 5 + tests/discovery-controller.spec.ts | 232 +++++++++++++++++++++++++ tests/dsh-assistant-stream.spec.ts | 171 ++++++++++++++++++ tests/dsh-client.spec.ts | 123 +++++++++++++ tests/dsh-command-transport.spec.ts | 63 +++++++ tests/plugin-manager.spec.ts | 82 +++++++++ tests/remote-read.spec.ts | 39 +++++ tests/runtime-plugin-inventory.spec.ts | 51 ++++++ tests/runtime-settings-picker.spec.ts | 75 ++++++++ 21 files changed, 1530 insertions(+), 144 deletions(-) create mode 100644 src/dsh-assistant-stream.ts create mode 100644 src/dsh-command-transport.ts create mode 100644 src/remote-read.ts create mode 100644 src/runtime-plugin-inventory.ts create mode 100644 src/runtime-settings-picker.ts create mode 100644 tests/discovery-controller.spec.ts create mode 100644 tests/dsh-assistant-stream.spec.ts create mode 100644 tests/dsh-command-transport.spec.ts create mode 100644 tests/plugin-manager.spec.ts create mode 100644 tests/remote-read.spec.ts create mode 100644 tests/runtime-plugin-inventory.spec.ts create mode 100644 tests/runtime-settings-picker.spec.ts diff --git a/src/collaboration-state.ts b/src/collaboration-state.ts index 4a88136..87266c7 100644 --- a/src/collaboration-state.ts +++ b/src/collaboration-state.ts @@ -52,7 +52,7 @@ export function effectivePlanMode(plan: PlanModeState): boolean { } export function planModeWithCommandAvailability(plan: PlanModeState, available: boolean): PlanModeState { - return available && !plan.available ? { ...plan, available: true } : plan + return { ...plan, available } } export function planModeCommand(mode: 'normal' | 'plan'): '/plan' | '/plan off' { diff --git a/src/conversation.ts b/src/conversation.ts index f3c4e03..940e48d 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -1,6 +1,7 @@ import { withoutIdeContext } from './ide-context.js' import type { ImageMediaType } from './dsh-client.js' import { presentToolCall, presentToolResult } from './tool-presentation.js' +import type { AssistantAttempt, AssistantStreamUpdate } from './dsh-assistant-stream.js' export type ConversationRole = 'user' | 'assistant' | 'tool' | 'command' | 'notice' @@ -68,6 +69,7 @@ export interface DshEvent { seq: number time: number data: unknown + surfaceOp?: unknown } interface EventData { @@ -144,8 +146,11 @@ export class ConversationProjector { private readonly orderedIds: string[] = [] private readonly byId = new Map() private readonly hiddenCommandIds = new Set() + private liveAttempt: (AssistantAttempt & { message?: ConversationMessage }) | undefined - reset(entries: readonly (DshEvent | { event: DshEvent; view?: unknown })[]): void { + reset(entries: readonly (DshEvent | { event: DshEvent; view?: unknown })[], preserveLive = false): void { + const live = preserveLive ? this.liveAttempt : undefined + this.liveAttempt = undefined this.orderedIds.length = 0 this.byId.clear() this.hiddenCommandIds.clear() @@ -153,11 +158,36 @@ export class ConversationProjector { if ('event' in entry) this.apply(entry.event, entry.view) else this.apply(entry) } + this.liveAttempt = live + } + + /** A process-local prefix is not history; retry/abandonment must be able to discard it. */ + applyStream(update: AssistantStreamUpdate): void { + if (update.kind === 'start') { this.liveAttempt = { ...update }; return } + const active = this.liveAttempt + if (active?.attemptId !== update.attemptId) return + if (update.kind === 'end') { this.liveAttempt = undefined; return } + const chunk = update.chunk + const current = active.message + const images = chunk.type === 'block-end' ? imageContent(chunk.block) : [] + const text = chunk.type === 'text-delta' && typeof chunk.text === 'string' ? chunk.text : '' + if (text === '' && images.length === 0) return + const next: ConversationMessage = { + id: `assistant-live:${active.attemptId}`, role: 'assistant', streaming: true, + text: `${current?.text ?? ''}${text}`, + ...(current?.images === undefined && images.length === 0 ? {} : { images: mergeImages(current?.images, images) }), + } + if (text !== '') appendAssistantStream(current, next, text) + else inheritAssistantStream(current, next) + active.message = next } apply(event: DshEvent, view?: unknown): void { const data = record(event.data) if (data === undefined) return + if ((event.type === 'assistant/message' || event.type === 'assistant/attempt') + && this.liveAttempt?.turn === data.turn && this.liveAttempt?.step === data.step + && (event.type === 'assistant/attempt' || event.surfaceOp === 'append')) this.liveAttempt = undefined if (event.type === 'user/message') { const source = record(data.source) @@ -301,13 +331,20 @@ export class ConversationProjector { } messages(): ConversationMessage[] { - return this.orderedIds.flatMap(id => { + const messages = this.orderedIds.flatMap(id => { const value = this.byId.get(id) if (value === undefined) return [] const copy = { ...value } inheritAssistantStream(value, copy) return [copy] }) + const live = this.liveAttempt?.message + if (live !== undefined) { + const copy = { ...live } + inheritAssistantStream(live, copy) + messages.push(copy) + } + return messages } private set(id: string, message: ConversationMessage): void { diff --git a/src/dsh-assistant-stream.ts b/src/dsh-assistant-stream.ts new file mode 100644 index 0000000..3ad5b3f --- /dev/null +++ b/src/dsh-assistant-stream.ts @@ -0,0 +1,145 @@ +import type { HistoryEntry } from './dsh-client.js' +import { wireRecord } from './dsh-streams.js' + +export interface AssistantAttempt { + attemptId: string + turn: number + step: number +} + +/** Presentation only: never assign a durable sequence to a live model chunk. */ +export type AssistantStreamUpdate = + | ({ kind: 'start' } & AssistantAttempt) + | { kind: 'chunk'; attemptId: string; chunk: Record } + | { kind: 'end'; attemptId: string } + +interface ActiveAttempt extends AssistantAttempt { startedAfterSeq: number; nextIndex: number } + +function integer(value: unknown, minimum = 0): value is number { + return Number.isSafeInteger(value) && (value as number) >= minimum +} + +function invalid(): never { throw new Error('Invalid DSH assistant stream. Reconnect the runtime.') } + +/** Expand only the compact reconnect prefix; durable V3 history stays untouched. */ +function expandPrefix(value: unknown): Record[] { + if (!Array.isArray(value)) return invalid() + return value.flatMap((run): Record[] => { + if (!wireRecord(run)) return invalid() + if (run.type === 'chunk') { + if (!integer(run.time, Number.MIN_SAFE_INTEGER) || !wireRecord(run.chunk) || typeof run.chunk.type !== 'string') return invalid() + return [run.chunk] + } + const kind = run.type === 'text-chunks' ? 'text-delta' : run.type === 'reasoning-chunks' ? 'reasoning-delta' + : run.type === 'tool-call-chunks' ? 'tool-call-delta' : undefined + const members = kind === 'tool-call-delta' ? run.args : run.texts + if (kind === undefined || !integer(run.index) || !integer(run.time0, Number.MIN_SAFE_INTEGER) + || !Array.isArray(members) || members.length === 0 || !members.every(item => typeof item === 'string') + || !Array.isArray(run.dt) || run.dt.length !== members.length - 1 || !run.dt.every(Number.isSafeInteger) + || (kind === 'tool-call-delta' && (typeof run.id !== 'string' || (run.name !== undefined && typeof run.name !== 'string')))) return invalid() + const gaps = run.dt + let time = run.time0 + return members.map((text: string, index) => { + if (index > 0) time += gaps[index - 1] as number + if (!Number.isSafeInteger(time)) return invalid() + return kind === 'tool-call-delta' + ? { type: kind, index: run.index, id: run.id, argumentsDelta: text, ...(run.name === undefined ? {} : { name: run.name }) } + : { type: kind, index: run.index, text } + }) + }) +} + +/** Joins 0.1.5 transient attempts to their ordered, durable settlement. */ +export class DshAssistantStream { + private revision = 0 + private active: ActiveAttempt | undefined + private pending: HistoryEntry | undefined + private openingSettlement: HistoryEntry | undefined + + constructor( + private readonly live: (update: AssistantStreamUpdate) => void, + private readonly publish: (entry: HistoryEntry) => void, + ) {} + + open(baseline: unknown, cursor: number, entries: readonly HistoryEntry[] = []): void { + // 0.1.2 ignores the opt-in and continues to send durable assistant/chunk events. + if (baseline === undefined) return + if (!wireRecord(baseline) || !integer(baseline.revision)) return invalid() + this.revision = baseline.revision + if (baseline.activeAttempt === undefined) return + const opening = baseline.activeAttempt + if (!wireRecord(opening) || !integer(opening.nextIndex)) return invalid() + const chunks = expandPrefix(opening.stream) + if (chunks.length !== opening.nextIndex) return invalid() + // An opening cut can already contain the settlement while its process-local + // end marker is still in flight. Do not render or later publish it twice. + this.openingSettlement = entries.find(({ event }) => this.matchesSettlement(event, opening)) + this.start(opening, cursor, this.openingSettlement === undefined) + const active = this.active! + if (this.openingSettlement === undefined) { + for (const chunk of chunks) this.live({ kind: 'chunk', attemptId: active.attemptId, chunk }) + } + active.nextIndex = opening.nextIndex + } + + durable(entry: HistoryEntry): void { + const event = entry.event + const active = this.active + if (active !== undefined && this.matchesSettlement(event, active)) { + if (this.pending !== undefined || this.openingSettlement !== undefined) return invalid() + this.pending = entry + } else this.publish(entry) + } + + frame(value: unknown, cursor: number): void { + if (!wireRecord(value) || !integer(value.revision) || value.revision !== this.revision + 1 + || typeof value.attemptId !== 'string' || value.attemptId === '') return invalid() + this.revision = value.revision + if (value.type === 'start') { + if (this.active !== undefined || this.pending !== undefined) return invalid() + this.start(value, cursor) + return + } + if (value.type !== 'chunk' && value.type !== 'end') return invalid() + if (!integer(value.index)) return invalid() + if (value.type === 'chunk' && (!integer(value.time, Number.MIN_SAFE_INTEGER) + || !wireRecord(value.chunk) || typeof value.chunk.type !== 'string')) return invalid() + const active = this.active + // A controller attached after the attempt began can have no reconstructible prefix. + // Its eventual durable settlement is still authoritative; do not show a partial suffix. + if (active === undefined) return + if (value.attemptId !== active.attemptId || value.index !== active.nextIndex) return invalid() + if (value.type === 'chunk') { + if (this.pending !== undefined || this.openingSettlement !== undefined) return invalid() + active.nextIndex++ + this.live({ kind: 'chunk', attemptId: active.attemptId, chunk: value.chunk as Record }) + return + } + const outcome = value.outcome + if (!wireRecord(outcome)) return invalid() + if (outcome.kind === 'committed') { + const entry = this.pending ?? this.openingSettlement + if (entry === undefined || entry.event.seq !== outcome.seq || entry.event.type !== outcome.eventType) return invalid() + // The final message replaces the live prefix in a single UI publication. + if (this.openingSettlement === undefined) this.publish(entry) + } else if (outcome.kind === 'abandoned' && this.pending === undefined && this.openingSettlement === undefined) { + this.live({ kind: 'end', attemptId: active.attemptId }) + } else return invalid() + this.pending = undefined + this.openingSettlement = undefined + this.active = undefined + } + + private matchesSettlement(event: HistoryEntry['event'], attempt: { turn?: unknown; step?: unknown; startedAfterSeq?: unknown }): boolean { + return wireRecord(event.data) && integer(attempt.startedAfterSeq, -1) + && (event.type === 'assistant/attempt' || (event.type === 'assistant/message' && event.surfaceOp === 'append')) + && event.seq > attempt.startedAfterSeq && event.data.turn === attempt.turn && event.data.step === attempt.step + } + + private start(value: Record, cursor: number, publish = true): void { + if (typeof value.attemptId !== 'string' || value.attemptId === '' || !integer(value.turn) || !integer(value.step) + || !integer(value.startedAfterSeq, -1) || value.startedAfterSeq > cursor) return invalid() + this.active = { attemptId: value.attemptId, turn: value.turn, step: value.step, startedAfterSeq: value.startedAfterSeq, nextIndex: 0 } + if (publish) this.live({ kind: 'start', attemptId: value.attemptId, turn: value.turn, step: value.step }) + } +} diff --git a/src/dsh-client.ts b/src/dsh-client.ts index 7daffa8..bcff164 100644 --- a/src/dsh-client.ts +++ b/src/dsh-client.ts @@ -5,6 +5,8 @@ import { wireRecord } from './dsh-streams.js' import type { DshEvent } from './conversation.js' import type { PluginInventorySnapshot } from './plugin-profile.js' import type { SettingsDescription, SettingsMutation, SettingsNamespace } from './runtime-settings.js' +import { RemoteRead } from './remote-read.js' +import { DshCommandTransport } from './dsh-command-transport.js' export interface SessionSummary { sessionId: string @@ -31,7 +33,7 @@ export interface ModelSelection { export interface CommandDescriptor { name: string description: string - input?: { hint: string; images?: boolean } + input?: { hint: string; images?: boolean; attachments?: boolean } } export interface CommandExecution { @@ -127,17 +129,26 @@ interface ModelCatalog { /** The sidebar uses only authenticated 0.1.2 Remotes. */ export class DshClient { + private readonly commandTransport = new DshCommandTransport((endpoint, args, timeoutMs) => this.call(endpoint, args, timeoutMs)) private readonly api: DshRemoteApi private readonly feed: DshSessionFeed private readonly lifetime = new AbortController() private readonly frameListeners = new Set<(frame: DshFrame) => void>() private readonly errorListeners = new Set<(error: Error) => void>() - private catalog: ModelCatalog | undefined + private readonly catalog: RemoteRead + private readonly commands = new Map>() + private readonly skills = new Map>() + private readonly presets: RemoteRead constructor(private readonly connection: DshConnection) { this.api = new DshRemoteApi(connection, this.lifetime.signal) + this.catalog = new RemoteRead(() => this.call('session/modelCatalog', {}), this.lifetime.signal) + this.presets = new RemoteRead(() => this.call('agentPresets/list', {}, 10_000), this.lifetime.signal) this.feed = new DshSessionFeed(connection, - frame => { for (const listener of this.frameListeners) listener(frame) }, + frame => { + this.invalidateDiscovery(frame) + for (const listener of this.frameListeners) listener(frame) + }, error => { for (const listener of this.errorListeners) listener(error) }) } @@ -149,8 +160,18 @@ export class DshClient { this.errorListeners.add(listener) return () => { this.errorListeners.delete(listener) } } - startStreams(): Promise { return this.feed.start() } - openSession(sessionId: string): Promise { return this.feed.open(sessionId) } + async startStreams(): Promise { + await this.feed.start() + // Reads made before the event subscription may have missed a Host commit. + this.catalog.invalidate() + this.presets.invalidate() + for (const read of [...this.commands.values(), ...this.skills.values()]) read.invalidate() + } + openSession(sessionId: string): Promise { + this.presets.invalidate() + this.invalidateSessionDiscovery(sessionId) + return this.feed.open(sessionId) + } async listSessions(): Promise<{ items: SessionSummary[] }> { const result = await this.api.listSessions() return { items: result.items.map(summary => this.feed.summary(summary)) } @@ -164,11 +185,11 @@ export class DshClient { history(sessionId: string, beforeSeq: number): Promise<{ events: HistoryEntry[]; hasMore: boolean }> { return this.feed.page(sessionId, beforeSeq) } async models(sessionId: string): Promise { - this.catalog = await this.call('session/modelCatalog', {}) + await this.catalog.read() return this.currentModels(sessionId) as SessionModels } currentModels(sessionId: string): SessionModels | undefined { - const catalog = this.catalog + const catalog = this.catalog.current if (catalog === undefined) return undefined const projection = this.feed.projectionValues(sessionId).modelSelection const candidate = wireRecord(projection) ? projection.next ?? projection.lastUsed : undefined @@ -195,24 +216,53 @@ export class DshClient { selectModel(sessionId: string, selection: ModelSelection): Promise<{ selected: ModelSelection }> { return this.call('session/selectModel', { request: { sessionId, ...selection } }) } - listCommands(sessionId: string): Promise { return this.call('commands/list', { agentId: sessionId }, 10_000) } - async listSkills(sessionId: string): Promise { - return (await this.call<{ skills: SkillDescriptor[] }>('skills/list', { request: { sessionId } }, 10_000)).skills + listCommands(sessionId: string): Promise { + let read = this.commands.get(sessionId) + if (read === undefined) { + read = new RemoteRead(() => this.call('commands/list', { agentId: sessionId }, 10_000), this.lifetime.signal) + this.commands.set(sessionId, read) + } + return read.read() + } + listSkills(sessionId: string): Promise { + let read = this.skills.get(sessionId) + if (read === undefined) { + read = new RemoteRead(async () => (await this.call<{ skills: SkillDescriptor[] }>( + 'skills/list', { request: { sessionId } }, 10_000)).skills, this.lifetime.signal) + this.skills.set(sessionId, read) + } + return read.read() } - listAgentPresets(): Promise { return this.call('agentPresets/list', {}, 10_000) } + listAgentPresets(): Promise { return this.presets.read() } async selectAgentPreset(sessionId: string, agentPreset: string): Promise<{ agentPreset: string }> { - return { agentPreset: await this.call('agentPresets/select', { agentId: sessionId, agentPreset }) } + const selected = await this.call('agentPresets/select', { agentId: sessionId, agentPreset }) + this.invalidateSessionDiscovery(sessionId) + return { agentPreset: selected } } executeCommand(sessionId: string, line: string, images: readonly PromptImage[] = []): Promise { - return this.call('commands/execute', { - agentId: sessionId, line, images: images.map(({ mediaType, data, name }) => ({ mediaType, data, ...(name === undefined ? {} : { name }) })), - }, 300_000) + return this.commandTransport.execute(sessionId, line, images) } dispose(): void { this.lifetime.abort() this.feed.dispose() this.frameListeners.clear() this.errorListeners.clear() + this.commands.clear() + this.skills.clear() + } + private invalidateSessionDiscovery(sessionId: string): void { + this.commands.get(sessionId)?.invalidate() + this.skills.get(sessionId)?.invalidate() + } + private invalidateDiscovery(frame: DshFrame): void { + const payload = frame.payload + if (payload.type === 'host/commands-changed') for (const read of this.commands.values()) read.invalidate() + if (payload.type === 'host/models-changed' || payload.type === 'host/settings-changed' || payload.type === 'host/credentials-changed') this.catalog.invalidate() + if (payload.type === 'host/settings-changed' && payload.ns === 'agent-presets') this.presets.invalidate() + if ((payload.type === 'host/session-composition-changed' + || (payload.type === 'session/projection' && payload.key === 'agentPreset')) && typeof payload.sessionId === 'string') { + this.invalidateSessionDiscovery(payload.sessionId) + } } private call(endpoint: string, args: Record, timeoutMs = 30_000): Promise { return this.connection.call(endpoint, args, timeoutMs, this.lifetime.signal) diff --git a/src/dsh-command-transport.ts b/src/dsh-command-transport.ts new file mode 100644 index 0000000..6a64ce9 --- /dev/null +++ b/src/dsh-command-transport.ts @@ -0,0 +1,42 @@ +import type { CommandExecution, PromptImage } from './dsh-client.js' + +type Format = 'submittedAttachments' | 'images' +type Call = (endpoint: string, args: Record, timeoutMs?: number) => Promise + +/** Negotiate with an empty, non-executing line; never retry the user's command. */ +export class DshCommandTransport { + private format: Promise | undefined + + constructor(private readonly call: Call) {} + + async execute(sessionId: string, line: string, images: readonly PromptImage[]): Promise { + const format = await this.resolveFormat(sessionId) + const attachments = images.map(({ mediaType, data, name }) => ({ + ...(format === 'submittedAttachments' ? { type: 'image' } : {}), + mediaType, data, ...(name === undefined ? {} : { name }), + })) + return this.call('commands/execute', { agentId: sessionId, line, [format]: attachments }, 300_000) + } + + private resolveFormat(sessionId: string): Promise { + this.format ??= this.probe(sessionId).catch(error => { this.format = undefined; throw error }) + return this.format + } + + private async probe(sessionId: string): Promise { + try { + // Official execute() parses the line before minting a commandId, emitting + // lifecycle events, admitting attachments or invoking a command handler. + const result = await this.call('commands/execute', { agentId: sessionId, line: '', submittedAttachments: [] }, 10_000) + if (result !== undefined) throw new Error('DSH returned an unexpected command capability response.') + return 'submittedAttachments' + } catch (error) { + // This exact gateway rejection occurs before entering execute(). Other + // validation, authentication, timeout and transport errors must surface. + if (error instanceof Error && 'code' in error && error.code === 'gateway/arguments-invalid' + && error.message.includes('commands/execute: args fields do not match the descriptor:') + && error.message.includes('missing "images"') && error.message.includes('unexpected "submittedAttachments"')) return 'images' + throw error + } + } +} diff --git a/src/dsh-session-feed.ts b/src/dsh-session-feed.ts index 5408971..38f5352 100644 --- a/src/dsh-session-feed.ts +++ b/src/dsh-session-feed.ts @@ -2,6 +2,7 @@ import type { DshConnection } from './dsh-connection.js' import type { DshFrame, HistoryEntry, RpcReceipt, SessionSummary } from './dsh-client.js' import { decodeHistory } from './dsh-history.js' import { DshStreams, wireRecord } from './dsh-streams.js' +import { DshAssistantStream } from './dsh-assistant-stream.js' interface Projection { seq: number; value: unknown } interface PendingQuestion { sessionId: string; event: string; request: Record } @@ -94,12 +95,16 @@ export class DshSessionFeed { return new Promise((resolve, reject) => { const state: Follow = { sessionId, cursor: -1, lastSeq: -1, active: false, pending: [], cancel: () => {}, reject } this.follow = state + const assistant = new DshAssistantStream( + update => this.mux({ type: 'session/assistant-stream', sessionId, update }), + entry => this.mux({ type: 'session/event', sessionId, event: entry.event }), + ) let opened = false const timer = setTimeout(() => { if (this.follow === state) this.closeFollow(new Error('DSH session snapshot timed out.')) }, 30_000) state.reject = error => { clearTimeout(timer); reject(error) } - state.cancel = this.streams.open('session/follow', { request: { address: { kind: 'session', sessionId }, maxMessages: 100 } }, raw => { + state.cancel = this.streams.open('session/follow', { request: { address: { kind: 'session', sessionId }, maxMessages: 100, assistantStream: true } }, raw => { if (this.follow !== state) return if (!wireRecord(raw)) throw new Error('Invalid session frame.') if (!opened) { @@ -111,6 +116,7 @@ export class DshSessionFeed { const latest = events.at(-1)?.event.seq if (latest !== undefined && latest > state.cursor) throw new Error('History exceeds snapshot cursor.') this.installProjections(sessionId, state.cursor, raw.projections.values) + assistant.open(raw.assistantStream, state.cursor, events) opened = true clearTimeout(timer) resolve({ events, hasMore: raw.hasMore, projections: this.projectionValues(sessionId), isCurrent: () => this.follow === state, activate: () => { @@ -125,11 +131,12 @@ export class DshSessionFeed { this.presentQuestions() } }) } else { + if (raw.type === 'assistant-stream') { assistant.frame(raw.frame, state.lastSeq); return } if (raw.type !== 'event') throw new Error('Unexpected session snapshot.') const entry = decodeHistory([raw])[0] if (entry === undefined || entry.event.seq !== state.lastSeq + 1) throw new Error('Non-contiguous DSH session events.') state.lastSeq = entry.event.seq - this.mux({ type: 'session/event', sessionId, event: entry.event }) + assistant.durable(entry) } }, error => { state.reject(error) }) }) @@ -241,6 +248,20 @@ export class DshSessionFeed { } if (frame.type !== 'emit' || typeof frame.event !== 'string' || !Array.isArray(frame.args)) throw new Error('Invalid remote event.') const [id, value] = frame.args + if (frame.event === 'commands/change' && frame.args.length === 0) { + this.host({ type: 'host/commands-changed' }) + } else if (frame.event === 'llm/adapters-updated' && frame.args.length === 0) { + this.host({ type: 'host/models-changed' }) + } else if (frame.event === 'credentials/reference-updated' && typeof id === 'string') { + // A credential reference is only an invalidation hint; never expose it to the Webview. + this.host({ type: 'host/credentials-changed' }) + } else if (frame.event === 'settings/document-updated' && typeof id === 'string' + && Number.isSafeInteger(value) && (value as number) >= 0) { + this.host({ type: 'host/settings-changed', ns: id, revision: value }) + } else if (frame.event === 'agent-preset/selected' && typeof id === 'string' && typeof value === 'string') { + // Selection itself comes from the sequenced projection, not this unsequenced hint. + this.host({ type: 'host/session-composition-changed', sessionId: id }) + } if (frame.event === 'api-session/added' && wireRecord(id)) this.host({ ...id, type: 'host/session-added' }) else if (typeof id === 'string') { if (frame.event === 'api-session/status' && typeof value === 'boolean') { diff --git a/src/extension.ts b/src/extension.ts index 25357bd..783a2a2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -47,6 +47,7 @@ import { } from './image-limits.js' import { jobsSnapshotOf, type JobItem } from './jobs.js' import { queueSnapshotOf, type QueueItemState } from './queue.js' +import type { AssistantStreamUpdate } from './dsh-assistant-stream.js' import { DshRuntime, type RuntimeOwnership, type RuntimeState } from './runtime.js' import { toolWriteIntents } from './tool-write-guard.js' import { pageConversationMessage } from './tool-output-page.js' @@ -208,8 +209,9 @@ function initialState(cwd: string): ChatViewState { } } -class DshChatController implements vscode.Disposable { +export class DshChatController implements vscode.Disposable { private readonly changes = new vscode.EventEmitter() + private readonly settingsChanges = new vscode.EventEmitter() private readonly projector = new ConversationProjector() private client: DshClient | undefined private clientDisposables: Array<() => void> = [] @@ -217,6 +219,8 @@ class DshChatController implements vscode.Disposable { private _state: ChatViewState private generation = 0 private sessionLoadGeneration = 0 + private readonly discoveryRequests = new Map() + private readonly settingsOwners = new WeakMap() private readonly guardedDirtyCalls = new Set() private queueRawText = new Map() private readonly attachmentResults = new Map>() @@ -229,6 +233,7 @@ class DshChatController implements vscode.Disposable { private static readonly unreadStorageKey = 'deepseekHarness.unreadSessions' readonly onDidChangeState = this.changes.event + readonly onDidChangeRuntimeSettings = this.settingsChanges.event constructor( private readonly runtime: DshRuntime, @@ -410,7 +415,7 @@ class DshChatController implements vscode.Disposable { if (this.client !== client || this._state.sessionId !== sessionId || loadGeneration !== this.sessionLoadGeneration) return const unseenEntries = unseenHistoryEntries(this.historyEntries, page.events) this.historyEntries = mergeHistoryEntries(this.historyEntries, unseenEntries) - this.projector.reset(this.historyEntries) + this.projector.reset(this.historyEntries, true) this.publish({ messages: this.projectedMessages(), changedFiles: this.diffReviews.prependHistory(sessionId, this.cwd, unseenEntries), @@ -435,17 +440,19 @@ class DshChatController implements vscode.Disposable { ): Promise { const normalized = text.trim() if ((normalized === '' && images.length === 0) || this._state.sessionId === '') return - const slash = this.slashRoute(normalized) + const client = this.requireClient() + const sessionId = this._state.sessionId + const generation = this.sessionLoadGeneration + const { route: slash, command } = await this.resolveInput(normalized) + if (this.client !== client || generation !== this.sessionLoadGeneration) throw new Error('The conversation changed. Send the message again in the intended conversation.') if (slash.kind === 'command') { - const command = this._state.commands.find(item => item.name === slash.name) - if (images.length > 0 && command?.input?.images !== true) { + if (images.length > 0 && (command?.input?.attachments ?? command?.input?.images) !== true) { throw new Error(`${slash.token} does not accept image attachments; remove them first.`) } - const usesRc8Envelope = this._state.commands.some(item => item.input?.images === true) - const execution = await this.requireClient().executeCommand( - this._state.sessionId, + const execution = await client.executeCommand( + sessionId, normalized, - usesRc8Envelope && images.length > 0 ? images : undefined, + images.length > 0 ? images : undefined, ) if (execution === undefined) throw new Error(`DeepSeek did not recognize ${slash.token}.`) if (images.length > 0 && execution.result.kind === 'error') { @@ -453,19 +460,34 @@ class DshChatController implements vscode.Disposable { } return } - await this.requireClient().prompt( - this._state.sessionId, + await client.prompt( + sessionId, slash.kind === 'skill' || ideContext === undefined ? normalized : withIdeContext(normalized, ideContext), images, mode, ) - const summary = this.summaries.find(item => item.sessionId === this._state.sessionId) + if (this.client !== client) return + const summary = this.summaries.find(item => item.sessionId === sessionId) if (summary !== undefined) summary.blank = false - this.publish({ agentPreset: lockAgentPresetState(this._state.agentPreset) }) + if (generation === this.sessionLoadGeneration) this.publish({ agentPreset: lockAgentPresetState(this._state.agentPreset) }) + } + + async slashRoute(text: string): Promise { + return (await this.resolveInput(text)).route } - slashRoute(text: string): SlashRoute { - return routeSlashInput(text, this._state.commands, this._state.skills) + private async resolveInput(text: string): Promise<{ route: SlashRoute; command: CommandDescriptor | undefined }> { + if (!text.trimStart().startsWith('/')) return { route: { kind: 'prompt' }, command: undefined } + const client = this.requireClient() + const sessionId = this._state.sessionId + const generation = this.sessionLoadGeneration + // An invalidated catalog must settle before an unknown slash name can become a prompt. + const [commands, skills] = await Promise.all([client.listCommands(sessionId), client.listSkills(sessionId)]) + if (this.client !== client || generation !== this.sessionLoadGeneration || this._state.phase !== 'ready') { + throw new Error('The conversation changed. Send the message again in the intended conversation.') + } + const route = routeSlashInput(text, commands.filter(command => command.name !== 'export'), skills) + return { route, command: route.kind === 'command' ? commands.find(command => command.name === route.name) : undefined } } async selectAgentPreset(id: string): Promise { @@ -474,15 +496,15 @@ class DshChatController implements vscode.Disposable { if (!current.options.some(option => option.id === id)) throw new Error(`Unknown DeepSeek agent preset: ${id}`) const client = this.requireClient() const sessionId = this._state.sessionId + const generation = this.sessionLoadGeneration if (sessionId === '') return this.publish({ agentPreset: selectAgentPresetState(current, id, true) }) try { - const selected = await client.selectAgentPreset(sessionId, id) - if (this.client !== client || this._state.sessionId !== sessionId) return - const summary = this.summaries.find(item => item.sessionId === sessionId) - if (summary !== undefined) summary.agentPreset = selected.agentPreset + await client.selectAgentPreset(sessionId, id) + if (this.client !== client || generation !== this.sessionLoadGeneration) return this.publish({ - agentPreset: selectAgentPresetState(this._state.agentPreset, selected.agentPreset, false), + // The sequenced projection may already include a newer selection from another client. + agentPreset: { ...this._state.agentPreset, busy: false }, commands: [], skills: [], }) @@ -492,8 +514,9 @@ class DshChatController implements vscode.Disposable { this.loadModels(sessionId), ]) } catch (error) { - if (this.client === client && this._state.sessionId === sessionId) { - this.publish({ agentPreset: { ...current, busy: false } }) + if (this.client === client && generation === this.sessionLoadGeneration) { + const selected = this.summaries.find(item => item.sessionId === sessionId)?.agentPreset ?? current.current + this.publish({ agentPreset: selectAgentPresetState(this._state.agentPreset, selected, false) }) } throw error } @@ -525,21 +548,33 @@ class DshChatController implements vscode.Disposable { } async selectModel(selection: ModelSelection): Promise { - if (this._state.sessionId === '') return - await this.requireClient().selectModel(this._state.sessionId, selection) - await this.loadModels(this._state.sessionId) + const sessionId = this._state.sessionId + const client = this.requireClient() + const generation = this.sessionLoadGeneration + if (sessionId === '') return + await client.selectModel(sessionId, selection) + if (this.client === client && generation === this.sessionLoadGeneration) await this.loadModels(sessionId) } - pluginInventory(): Promise { - return this.requireClient().pluginInventory() + async pluginInventory(): Promise { + const client = this.requireClient() + const inventory = await client.pluginInventory() + if (this.client !== client) throw new Error('The DSH runtime changed. Refresh the plugin inventory.') + return inventory } - settings(): Promise { - return this.requireClient().settings() + async settings(): Promise { + const client = this.requireClient() + const description = await client.settings() + if (this.client !== client) throw new Error('The DSH runtime changed. Reopen runtime settings.') + for (const namespace of description.namespaces) this.settingsOwners.set(namespace, client) + return description } - mutateSettings(ns: string, ops: SettingsMutation[], expectedRevision: number): Promise { - return this.requireClient().mutateSettings(ns, ops, expectedRevision) + mutateSettings(namespace: SettingsNamespace, ops: SettingsMutation[]): Promise { + const client = this.requireClient() + if (this.settingsOwners.get(namespace) !== client) throw new Error('The DSH runtime changed. Reopen runtime settings before saving.') + return client.mutateSettings(namespace.ns, ops, namespace.revision) } async answerApproval(rpcId: string, approvalId: string, outcome: 'allowed-once' | 'rejected'): Promise { @@ -578,6 +613,7 @@ class DshChatController implements vscode.Disposable { ++this.generation this.disconnectClient() this.changes.dispose() + this.settingsChanges.dispose() } private requireClient(): DshClient { @@ -589,6 +625,7 @@ class DshChatController implements vscode.Disposable { for (const dispose of this.clientDisposables.splice(0)) dispose() this.client?.dispose() this.client = undefined + this.settingsChanges.fire() } private async loadSessions(preferredId?: string): Promise { @@ -716,14 +753,15 @@ class DshChatController implements vscode.Disposable { } private async loadCommands(client: DshClient, sessionId: string): Promise { + const current = this.discoveryRequest('commands', client, sessionId) try { const commands = await client.listCommands(sessionId) - if (this.client === client && this._state.sessionId === sessionId) { + if (current()) { const visibleCommands = commands.filter(command => command.name !== 'export') this.publish({ commands: visibleCommands, plan: planModeWithCommandAvailability( - this._state.plan, + planModeStateOf(this.summaries.find(item => item.sessionId === sessionId)?.projections?.values?.plan), visibleCommands.some(command => command.name === 'plan'), ), }) @@ -735,9 +773,10 @@ class DshChatController implements vscode.Disposable { } private async loadSkills(client: DshClient, sessionId: string): Promise { + const current = this.discoveryRequest('skills', client, sessionId) try { const skills = await client.listSkills(sessionId) - if (this.client === client && this._state.sessionId === sessionId) this.publish({ skills }) + if (current()) this.publish({ skills }) } catch (error) { const message = error instanceof Error ? error.message : String(error) this.output.appendLine(`[skills] Discovery unavailable: ${message}`) @@ -745,11 +784,12 @@ class DshChatController implements vscode.Disposable { } private async loadAgentPresets(client: DshClient, sessionId: string): Promise { + const current = this.discoveryRequest('presets', client, sessionId) try { const roster = await client.listAgentPresets() - if (this.client !== client || this._state.sessionId !== sessionId) return + if (!current()) return const summary = this.summaries.find(item => item.sessionId === sessionId) - this.publish({ agentPreset: agentPresetStateOf(roster.presets, summary?.agentPreset, summary?.blank ?? false) }) + this.publish({ agentPreset: agentPresetStateOf(roster.presets, summary?.agentPreset, summary?.blank ?? false, this._state.agentPreset.busy) }) } catch (error) { const message = error instanceof Error ? error.message : String(error) this.output.appendLine(`[agent-preset] Discovery unavailable: ${message}`) @@ -758,8 +798,29 @@ class DshChatController implements vscode.Disposable { private async loadModels(sessionId: string): Promise { const client = this.requireClient() - const models = await client.models(sessionId) - if (this.client === client && this._state.sessionId === sessionId) this.publish(this.modelPatch(models)) + const current = this.discoveryRequest('models', client, sessionId) + try { + const models = await client.models(sessionId) + if (current()) this.publish(this.modelPatch(models)) + } catch (error) { + if (current()) this.output.appendLine(`[models] Discovery unavailable: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private discoveryRequest(kind: string, client: DshClient, sessionId: string): () => boolean { + const request = (this.discoveryRequests.get(kind) ?? 0) + 1 + this.discoveryRequests.set(kind, request) + const generation = this.sessionLoadGeneration + return () => this.client === client && this._state.sessionId === sessionId + && this._state.phase === 'ready' && this.sessionLoadGeneration === generation && this.discoveryRequests.get(kind) === request + } + + private refreshComposition(sessionId: string): void { + if (this.client === undefined || sessionId !== this._state.sessionId || this._state.phase !== 'ready') return + this.publish({ commands: [], skills: [], plan: { ...this._state.plan, available: false } }) + void this.loadCommands(this.client, sessionId) + void this.loadSkills(this.client, sessionId) + void this.loadAgentPresets(this.client, sessionId) } private modelPatch(models: SessionModels): Pick { @@ -794,6 +855,23 @@ class DshChatController implements vscode.Disposable { const type = typeof payload.type === 'string' ? payload.type : '' const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : '' + if (frame.channel === 'host') { + if (type === 'host/settings-changed' || type === 'host/credentials-changed') this.settingsChanges.fire() + if (this.client !== undefined && this._state.phase === 'ready' && this._state.sessionId !== '') { + const activeId = this._state.sessionId + if (type === 'host/commands-changed') void this.loadCommands(this.client, activeId) + if (type === 'host/models-changed' || type === 'host/settings-changed' || type === 'host/credentials-changed') void this.loadModels(activeId) + if (type === 'host/settings-changed' && payload.ns === 'agent-presets') void this.loadAgentPresets(this.client, activeId) + if (type === 'host/session-composition-changed') this.refreshComposition(sessionId) + } + } + + if (frame.channel === 'mux' && type === 'session/assistant-stream' && sessionId === this._state.sessionId) { + this.projector.applyStream(payload.update as AssistantStreamUpdate) + this.publish({ messages: this.projectedMessages() }) + return + } + if (frame.channel === 'mux' && type === 'session/event' && sessionId === this._state.sessionId) { const event = payload.event if (typeof event === 'object' && event !== null) { @@ -807,26 +885,6 @@ class DshChatController implements vscode.Disposable { } this.publish({ agentPreset: lockAgentPresetState(this._state.agentPreset) }) } - if (dshEvent.type === 'agent-preset/selected') { - const data = typeof dshEvent.data === 'object' && dshEvent.data !== null - ? dshEvent.data as Record - : undefined - const agentPreset = typeof data?.agentPreset === 'string' ? data.agentPreset : undefined - if (agentPreset !== undefined) { - const summary = this.summaries.find(item => item.sessionId === sessionId) - if (summary !== undefined) summary.agentPreset = agentPreset - this.publish({ - agentPreset: selectAgentPresetState(this._state.agentPreset, agentPreset, false), - commands: [], - skills: [], - }) - const client = this.client - if (client !== undefined) { - void this.loadCommands(client, sessionId) - void this.loadSkills(client, sessionId) - } - } - } this.historyEntries = mergeHistoryEntries(this.historyEntries, [{ event: dshEvent, view: payload.view }]) const conflict = this.dirtyConflict(dshEvent, payload.view) if (conflict !== undefined) void this.cancelForDirtyConflict(conflict) @@ -946,7 +1004,14 @@ class DshChatController implements vscode.Disposable { this.publish({ permissions: permissionPresetsOf(payload.value) }) } if (payload.key === 'plan' && sessionId === this._state.sessionId) { - this.publish({ plan: planModeStateOf(payload.value) }) + this.publish({ plan: planModeWithCommandAvailability(planModeStateOf(payload.value), this._state.commands.some(command => command.name === 'plan')) }) + } + if (payload.key === 'agentPreset' && typeof payload.value === 'string') { + if (summary !== undefined) summary.agentPreset = payload.value + if (sessionId === this._state.sessionId) { + this.publish({ agentPreset: selectAgentPresetState(this._state.agentPreset, payload.value, false) }) + this.refreshComposition(sessionId) + } } if (payload.key === 'modelSelection' && sessionId === this._state.sessionId) { const models = this.client?.currentModels(sessionId) @@ -1473,10 +1538,11 @@ class DshSurface implements vscode.Disposable { return } } - const slash = this.controller.slashRoute(value.text) - const carriesIdeContext = slash.kind === 'prompt' + let carriesIdeContext = false let ideContext: IdeContextSnapshot | undefined try { + const slash = await this.controller.slashRoute(value.text) + carriesIdeContext = slash.kind === 'prompt' ideContext = carriesIdeContext ? await this.editorContext.snapshotForPrompt(value.text) : undefined } catch (error) { await this.restoreDraft(sessionId, requestId, value.text) diff --git a/src/plugin-manager.ts b/src/plugin-manager.ts index b63caf9..fc6602a 100644 --- a/src/plugin-manager.ts +++ b/src/plugin-manager.ts @@ -1,5 +1,8 @@ import { spawn } from 'node:child_process' import * as vscode from 'vscode' +import { DshConnectionError } from './dsh-connection.js' +import { pickRuntimeSettingsNamespace } from './runtime-settings-picker.js' +import { PLUGIN_STATUS_ICONS, PLUGIN_STATUS_LABELS, runtimePluginGroups } from './runtime-plugin-inventory.js' import { resolveLaunch } from './launch.js' import { terminateProcessTree } from './process-tree.js' import { @@ -14,7 +17,6 @@ import { readInstalledPlugins, resolveDshHome, type InstalledPlugin, - type InstalledPluginStatus, type PluginInventorySnapshot, } from './plugin-profile.js' import { @@ -37,6 +39,7 @@ interface CatalogCache { } interface PluginController { + readonly onDidChangeRuntimeSettings: vscode.Event readonly cwd: string readonly runtimeOwnership: 'external' | 'managed' | undefined readonly state: { @@ -46,7 +49,7 @@ interface PluginController { } pluginInventory(): Promise settings(): Promise - mutateSettings(ns: string, ops: SettingsMutation[], expectedRevision: number): Promise + mutateSettings(namespace: SettingsNamespace, ops: SettingsMutation[]): Promise restart(): Promise } @@ -54,29 +57,12 @@ type PluginPick = vscode.QuickPickItem & ( | { action: 'install' } | { action: 'browse' } | { action: 'configure' } + | { action: 'inventory' } | { action: 'refresh' } | { action: 'plugin'; plugin: InstalledPlugin } | { action: 'empty' } ) -const STATUS_LABELS: Record = { - active: 'Active', - failed: 'Failed', - disabled: 'Disabled', - loading: 'Loading', - inactive: 'Inactive', - unknown: 'Status unavailable', -} - -const STATUS_ICONS: Record = { - active: 'pass-filled', - failed: 'error', - disabled: 'circle-slash', - loading: 'loading~spin', - inactive: 'circle-outline', - unknown: 'extensions', -} - /** Manage runtime-capability bundles through the official DSH profile CLI. */ export class DshPluginManager { constructor( @@ -87,11 +73,15 @@ export class DshPluginManager { async show(): Promise { while (true) { - const plugins = readInstalledPlugins(resolveDshHome()) + // An external server need not use this machine's web profile. + const external = this.controller.runtimeOwnership === 'external' + const plugins = external ? [] : readInstalledPlugins(resolveDshHome()) const inventory = await this.tryInventory() const selected = await vscode.window.showQuickPick(this.items(plugins, inventory), { title: 'DeepSeek Harness Runtime Plugins', - placeHolder: 'Tools, skills, MCP, memory, and agent hooks loaded by the DSH web profile', + placeHolder: this.controller.runtimeOwnership === 'external' + ? 'Connected DSH runtime · use its CLI to install or remove plugins' + : 'Tools, skills, MCP, memory, and agent hooks loaded by the DSH web profile', matchOnDescription: true, matchOnDetail: true, }) @@ -104,6 +94,10 @@ export class DshPluginManager { await this.configureSettings() continue } + if (selected.action === 'inventory') { + await this.showInventory() + continue + } if (selected.action === 'refresh') continue if (selected.action === 'install') { await this.install() @@ -126,21 +120,33 @@ export class DshPluginManager { { action: 'browse', label: '$(search) Find community runtime plugins…', - detail: 'Search tools, skills, MCP integrations, memory, and agent hooks; install with one click', + detail: this.controller.runtimeOwnership === 'external' + ? 'Browse plugin sources; install using the external runtime’s CLI' + : 'Search tools, skills, MCP integrations, memory, and agent hooks; install with one click', }, { action: 'configure', label: '$(settings-gear) Configure runtime settings…', detail: 'Edit settings namespaces registered by built-in and community DSH plugins', }, + { + action: 'inventory', + label: '$(list-tree) View runtime plugin inventory…', + detail: 'Read-only plugin status, grouped by Agent preset and global runtime', + }, { action: 'refresh', label: '$(refresh) Refresh', - detail: 'Read the current web profile and Loader status again', + detail: this.controller.runtimeOwnership === 'external' + ? 'Read the connected runtime’s Loader status again' + : 'Read the current web profile and Loader status again', }, { action: 'empty', label: 'Installed for the DSH web profile', kind: vscode.QuickPickItemKind.Separator }, ] + if (this.controller.runtimeOwnership === 'external') { + return items.filter(item => item.action !== 'install' && item.action !== 'empty') + } if (plugins.length === 0) { items.push({ action: 'empty', @@ -155,14 +161,38 @@ export class DshPluginManager { items.push({ action: 'plugin', plugin, - label: `$(${STATUS_ICONS[status]}) ${plugin.name}`, - description: plugin.bundle ? STATUS_LABELS[status] : 'Not a DSH bundle', + label: `$(${PLUGIN_STATUS_ICONS[status]}) ${plugin.name}`, + description: plugin.bundle ? PLUGIN_STATUS_LABELS[status] : 'Not a DSH bundle', detail: `${plugin.spec} · Applies to every project using the web profile`, }) } return items } + private async showInventory(): Promise { + while (true) { + const inventory = await this.tryInventory() + if (inventory === undefined) { + await vscode.window.showWarningMessage('Runtime inventory is unavailable. Connect to DSH and try again.') + return + } + const rows = runtimePluginGroups(inventory).flatMap(group => [ + { label: group.label, kind: vscode.QuickPickItemKind.Separator }, + ...group.rows, + ...(group.rows.length === 0 ? [{ label: 'No entries in this group' }] : []), + ]) + const refresh = { label: '$(refresh) Refresh inventory' } + const selected = await vscode.window.showQuickPick([refresh, ...rows], { + title: 'DSH Runtime Plugin Inventory', + placeHolder: 'Loader status only; an active MCP plugin does not confirm its server is connected', + matchOnDescription: true, + matchOnDetail: true, + }) + if (selected === undefined) return + // All rows are informational; reopening also fetches a fresh owner snapshot. + } + } + private async tryInventory(): Promise { if (this.controller.state.phase !== 'ready') return undefined try { @@ -225,7 +255,8 @@ export class DshPluginManager { location: vscode.ProgressLocation.Notification, title: 'Loading DSH community runtime plugins…', }, async () => this.loadCatalog()) - const installed = new Set(readInstalledPlugins(resolveDshHome()).map(plugin => plugin.name)) + const installed = new Set(this.controller.runtimeOwnership === 'external' + ? [] : readInstalledPlugins(resolveDshHome()).map(plugin => plugin.name)) const selected = await vscode.window.showQuickPick(plugins.map(plugin => { const alreadyInstalled = installed.has(plugin.npm ?? plugin.name) const stars = plugin.stars === undefined ? '' : ` · ★ ${String(plugin.stars)}` @@ -243,7 +274,7 @@ export class DshPluginManager { matchOnDetail: true, }) if (selected === undefined) return - const actions = selected.alreadyInstalled + const actions = selected.alreadyInstalled || this.controller.runtimeOwnership === 'external' ? [{ label: '$(github) Open on GitHub', action: 'open' as const }] : [ { label: '$(cloud-download) Install', action: 'install' as const }, @@ -330,32 +361,23 @@ export class DshPluginManager { await vscode.window.showWarningMessage('Start DeepSeek Harness before configuring runtime settings.') return } - let description = await this.controller.settings() - if (!description.writable) { - await vscode.window.showWarningMessage('The active DSH profile does not expose writable runtime settings.') - return - } while (true) { - const selected = await vscode.window.showQuickPick(description.namespaces.map(namespace => ({ - label: `$(settings-gear) ${namespace.ns}`, - description: hasSettingsOverrides(namespace) ? 'Customized' : 'Default', - detail: `${namespace.applies === 'restart' ? 'Requires a runtime restart' : 'Applies immediately'} · ${String(runtimeSettingFields(namespace).length)} settings`, - namespace, - })), { - title: 'DeepSeek Harness Runtime Settings', - placeHolder: 'Choose a settings namespace registered by DSH or a runtime plugin', - matchOnDescription: true, - matchOnDetail: true, - }) + const selected = await pickRuntimeSettingsNamespace(this.controller) if (selected === undefined) return - const changed = await this.configureNamespace(selected.namespace) + let changed: boolean + try { + changed = await this.configureNamespace(selected) + } catch (error) { + if (!(error instanceof DshConnectionError) || error.code !== 'settings/conflict') throw error + await vscode.window.showWarningMessage('These runtime settings changed elsewhere. Nothing was overwritten; choose the setting again to edit the latest version.') + continue + } if (!changed) continue - if (selected.namespace.applies === 'restart') { - await this.restartAfterChange(`${selected.namespace.ns} updated`) + if (selected.applies === 'restart') { + await this.restartAfterChange(`${selected.ns} updated`) return } - await vscode.window.showInformationMessage(`${selected.namespace.ns} updated.`) - description = await this.controller.settings() + await vscode.window.showInformationMessage(`${selected.ns} updated.`) } } @@ -393,12 +415,12 @@ export class DshPluginManager { .filter(field => field.overridden) .map(field => ({ op: 'unset', path: field.path })) if (resets.length === 0) return false - await this.controller.mutateSettings(namespace.ns, resets, namespace.revision) + await this.controller.mutateSettings(namespace, resets) return true } const mutation = await this.editSetting(namespace, selected.field) if (mutation === undefined) return false - await this.controller.mutateSettings(namespace.ns, [mutation], namespace.revision) + await this.controller.mutateSettings(namespace, [mutation]) return true } @@ -510,6 +532,10 @@ export class DshPluginManager { } private async runOfficialPluginCommand(args: readonly string[], title: string): Promise { + if (this.controller.runtimeOwnership === 'external') { + throw new Error('Install or remove plugins using the CLI of the external DSH runtime, or switch to a managed runtime. No local profile was changed.') + } + if (!await this.requireIdle()) throw new Error('Plugin operation cancelled because a DeepSeek task is running.') const defaultWorkspace = vscode.workspace.workspaceFolders?.[0]?.uri const cwd = this.controller.cwd || defaultWorkspace?.fsPath || process.cwd() const workspace = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(cwd))?.uri ?? defaultWorkspace @@ -530,6 +556,10 @@ export class DshPluginManager { title, cancellable: true, }, async (_progress, token) => new Promise((resolvePromise, rejectPromise) => { + if (this.controller.runtimeOwnership === 'external' || this.controller.state.running) { + rejectPromise(new Error('The runtime changed or a task started. Reopen plugin management before making changes.')) + return + } let diagnostics = '' let cancelled = false let settled = false diff --git a/src/plugin-profile.ts b/src/plugin-profile.ts index 1605f9b..2ce00e4 100644 --- a/src/plugin-profile.ts +++ b/src/plugin-profile.ts @@ -13,6 +13,24 @@ export interface PluginInventoryEntry { export interface PluginInventorySnapshot { entries: readonly PluginInventoryEntry[] + agentPresets?: readonly AgentPresetPluginGroup[] +} + +export interface AgentPresetPluginRow { + entryId: string | null + moduleName: string + enabled: boolean | 'conditional' + condition?: string + fiberPhase: PluginFiberPhase +} + +export interface AgentPresetPluginGroup { + id: string + trust: 'system' | 'user' + name?: string + isDefault: boolean + broken?: string + rows: readonly AgentPresetPluginRow[] } export interface InstalledPlugin { @@ -21,7 +39,7 @@ export interface InstalledPlugin { bundle: boolean } -export type InstalledPluginStatus = 'active' | 'failed' | 'disabled' | 'loading' | 'inactive' | 'unknown' +export type InstalledPluginStatus = 'active' | 'failed' | 'disabled' | 'loading' | 'unloading' | 'configured' | 'conditional' | 'inactive' | 'unknown' interface ProfileManifest { dependencies?: Record @@ -76,23 +94,31 @@ export function readInstalledPlugins(dshHome: string): InstalledPlugin[] { .sort((left, right) => left.name.localeCompare(right.name)) } -/** Match one installed bundle to Loader entries without claiming unavailable provenance. */ +/** A configured preset row without a live Fiber is not an active plugin. */ +export function pluginEntryStatus(entry: AgentPresetPluginRow, preset: boolean): InstalledPluginStatus { + if (entry.enabled === false) return 'disabled' + if (entry.enabled === 'conditional') return 'conditional' + if (entry.fiberPhase === 'pending' || entry.fiberPhase === 'loading') return 'loading' + if (entry.fiberPhase !== null) return entry.fiberPhase + return preset ? 'configured' : 'inactive' +} + +/** Match a bundle to observed entries and preset declarations, without inferring MCP connectivity. */ export function installedPluginStatus( pluginName: string, inventory: PluginInventorySnapshot | undefined, ): InstalledPluginStatus { if (inventory === undefined) return 'unknown' - const entries = inventory.entries.filter(entry => ( - entry.moduleName === pluginName || entry.moduleName.startsWith(`${pluginName}/`) - )) - if (entries.length === 0) return 'unknown' - if (entries.some(entry => entry.enabled && entry.fiberPhase === 'failed')) return 'failed' - if (entries.some(entry => entry.enabled && entry.fiberPhase === 'active')) return 'active' - if (entries.every(entry => !entry.enabled)) return 'disabled' - if (entries.some(entry => entry.enabled && (entry.fiberPhase === 'pending' || entry.fiberPhase === 'loading'))) { - return 'loading' + const matches = (entry: AgentPresetPluginRow): boolean => entry.moduleName === pluginName || entry.moduleName.startsWith(`${pluginName}/`) + const states = [ + ...inventory.entries.filter(matches).map(entry => pluginEntryStatus(entry, false)), + ...(inventory.agentPresets ?? []).filter(preset => preset.broken === undefined) + .flatMap(preset => preset.rows.filter(matches).map(entry => pluginEntryStatus(entry, true))), + ] + for (const status of ['failed', 'loading', 'unloading', 'active', 'configured', 'conditional', 'inactive', 'disabled'] as const) { + if (states.includes(status)) return status } - return 'inactive' + return 'unknown' } /** Accept one pnpm package spec while preventing option injection into the official CLI forwarder. */ diff --git a/src/remote-read.ts b/src/remote-read.ts new file mode 100644 index 0000000..3768017 --- /dev/null +++ b/src/remote-read.ts @@ -0,0 +1,30 @@ +/** Shares catalog reads and discards responses invalidated by a newer Host notification. */ +export class RemoteRead { + private revision = 0 + private cached: { revision: number; value: T } | undefined + private pending: Promise | undefined + + constructor(private readonly fetch: () => Promise, private readonly signal: AbortSignal) {} + + get current(): T | undefined { return this.cached?.value } + + invalidate(): void { this.revision++ } + + async read(): Promise { + for (;;) { + this.signal.throwIfAborted() + if (this.cached?.revision === this.revision) return this.cached.value + if (this.pending === undefined) { + const revision = this.revision + const request = this.fetch().then(value => { + if (!this.signal.aborted && revision === this.revision) this.cached = { revision, value } + }, error => { + // A superseded request cannot fail a read of the replacement directory. + if (revision === this.revision) throw error + }).finally(() => { if (this.pending === request) this.pending = undefined }) + this.pending = request + } + await this.pending + } + } +} diff --git a/src/runtime-plugin-inventory.ts b/src/runtime-plugin-inventory.ts new file mode 100644 index 0000000..027e06e --- /dev/null +++ b/src/runtime-plugin-inventory.ts @@ -0,0 +1,37 @@ +import { pluginEntryStatus, type InstalledPluginStatus, type PluginInventorySnapshot } from './plugin-profile.js' + +export const PLUGIN_STATUS_LABELS: Record = { + active: 'Active', failed: 'Failed', disabled: 'Disabled', loading: 'Loading', unloading: 'Unloading', + configured: 'Configured · not running', conditional: 'Conditional · not evaluated', inactive: 'Not observed', unknown: 'Status unavailable', +} + +export const PLUGIN_STATUS_ICONS: Record = { + active: 'pass-filled', failed: 'error', disabled: 'circle-slash', loading: 'loading~spin', unloading: 'loading~spin', + configured: 'list-tree', conditional: 'question', inactive: 'circle-outline', unknown: 'extensions', +} + +export interface RuntimePluginGroup { + label: string + rows: Array<{ label: string; description: string; detail: string }> +} + +/** Read-only Loader inventory; never turn an unmounted preset or active MCP Fiber into a connection claim. */ +export function runtimePluginGroups(snapshot: PluginInventorySnapshot): RuntimePluginGroup[] { + return [ + ...(snapshot.agentPresets ?? []).map(preset => ({ + label: `Agent preset: ${preset.name?.trim() || preset.id}${preset.isDefault ? ' (default)' : ''}`, + rows: preset.broken !== undefined ? [{ + label: '$(error) Preset unavailable', description: preset.id, + detail: 'This composition could not be read. Check the DSH runtime logs.', + }] : preset.rows.map(row => ({ + label: `$(${PLUGIN_STATUS_ICONS[pluginEntryStatus(row, true)]}) ${row.moduleName}`, + description: PLUGIN_STATUS_LABELS[pluginEntryStatus(row, true)], + detail: `Preset: ${preset.id} · ${preset.trust} · Entry: ${row.entryId ?? '(no id)'}`, + })), + })), + { label: 'Global runtime', rows: snapshot.entries.map(row => ({ + label: `$(${PLUGIN_STATUS_ICONS[pluginEntryStatus(row, false)]}) ${row.moduleName}`, + description: PLUGIN_STATUS_LABELS[pluginEntryStatus(row, false)], detail: `Global · Entry: ${row.entryId}`, + })) }, + ] +} diff --git a/src/runtime-settings-picker.ts b/src/runtime-settings-picker.ts new file mode 100644 index 0000000..9a4875a --- /dev/null +++ b/src/runtime-settings-picker.ts @@ -0,0 +1,61 @@ +import * as vscode from 'vscode' +import { RemoteRead } from './remote-read.js' +import { hasSettingsOverrides, runtimeSettingFields, type SettingsDescription, type SettingsNamespace } from './runtime-settings.js' + +export interface RuntimeSettingsSource { + settings(): Promise + readonly onDidChangeRuntimeSettings: vscode.Event +} + +/** Keep the open native namespace picker current without changing an in-progress field draft. */ +export function pickRuntimeSettingsNamespace(source: RuntimeSettingsSource): Promise { + const picker = vscode.window.createQuickPick() + picker.title = 'DeepSeek Harness Runtime Settings' + picker.placeholder = 'Choose a settings namespace registered by DSH or a runtime plugin' + picker.matchOnDescription = picker.matchOnDetail = true + const lifetime = new AbortController() + const read = new RemoteRead(() => source.settings(), lifetime.signal) + return new Promise((resolve, reject) => { + const disposables: vscode.Disposable[] = [] + let generation = 0 + const finish = (namespace?: SettingsNamespace, error?: unknown): void => { + if (lifetime.signal.aborted) return + lifetime.abort() + for (const disposable of disposables) disposable.dispose() + picker.hide() + picker.dispose() + if (error !== undefined) reject(error) + else resolve(namespace) + } + const refresh = async (): Promise => { + const request = ++generation + read.invalidate() + picker.busy = true + try { + const description = await read.read() + if (lifetime.signal.aborted || request !== generation) return + if (!description.writable) throw new Error('The active DSH profile does not expose writable runtime settings.') + const active = picker.activeItems[0]?.namespace.ns + picker.items = description.namespaces.map(namespace => ({ + label: `$(settings-gear) ${namespace.ns}`, + description: hasSettingsOverrides(namespace) ? 'Customized' : 'Default', + detail: `${namespace.applies === 'restart' ? 'Requires a runtime restart' : 'Applies immediately'} · ${String(runtimeSettingFields(namespace).length)} settings`, + namespace, + })) + picker.selectedItems = [] + if (active !== undefined) picker.activeItems = picker.items.filter(item => item.namespace.ns === active) + } catch (error) { + if (!lifetime.signal.aborted && request === generation) finish(undefined, error) + } finally { + if (!lifetime.signal.aborted && request === generation) picker.busy = false + } + } + disposables.push( + picker.onDidHide(() => finish()), + picker.onDidAccept(() => { if (!picker.busy) finish(picker.selectedItems[0]?.namespace) }), + source.onDidChangeRuntimeSettings(() => { void refresh() }), + ) + picker.show() + void refresh() + }) +} diff --git a/tests/collaboration-state.spec.ts b/tests/collaboration-state.spec.ts index a3e27ff..4c0d45f 100644 --- a/tests/collaboration-state.spec.ts +++ b/tests/collaboration-state.spec.ts @@ -48,6 +48,11 @@ describe('collaboration state projections', () => { expect(planModeCommand('normal')).toBe('/plan off') }) + it('removes Plan controls when the official command disappears without inventing a mode transition', () => { + expect(planModeWithCommandAvailability({ available: true, active: true, pending: false }, false)) + .toEqual({ available: false, active: true, pending: false }) + }) + it('requires confirmation only for the official full-access preset', () => { expect(requiresFullAccessConfirmation('danger-full-access')).toBe(true) expect(requiresFullAccessConfirmation('workspace-write')).toBe(false) diff --git a/tests/discovery-controller.spec.ts b/tests/discovery-controller.spec.ts new file mode 100644 index 0000000..7d92ed4 --- /dev/null +++ b/tests/discovery-controller.spec.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DshFrame, SessionModels, SkillDescriptor } from '../src/dsh-client.js' + +const mocks = vi.hoisted(() => ({ client: undefined as any })) +vi.mock('../src/dsh-client.js', () => ({ DshClient: class { constructor() { return mocks.client } } })) +vi.mock('vscode', () => ({ + EventEmitter: class { + listeners = new Set<(value: unknown) => void>() + event = (listener: (value: unknown) => void) => { this.listeners.add(listener); return { dispose: () => this.listeners.delete(listener) } } + fire(value: unknown) { for (const listener of this.listeners) listener(value) } + dispose() { this.listeners.clear() } + }, + Uri: { file: (fsPath: string) => ({ fsPath, scheme: 'file' }) }, +})) +import { DshChatController } from '../src/extension.js' + +const controllers: DshChatController[] = [] +afterEach(() => { controllers.splice(0).forEach(controller => controller.dispose()) }) +const models = (id = 'model'): SessionModels => ({ current: { provider: 'p', model: id }, routable: true, + groups: [{ id: 'p', name: 'Provider', models: [{ id, name: id }] }], failures: [] }) + +async function harness() { + const listeners = new Set<(frame: DshFrame) => void>() + const projections = { agentPreset: 'standard', plan: { active: false, pending: false } } + const client = { + onFrame: vi.fn((callback: (frame: DshFrame) => void) => { listeners.add(callback); return () => listeners.delete(callback) }), + onError: vi.fn(() => () => {}), startStreams: vi.fn(async () => {}), dispose: vi.fn(), + listWorkspaces: vi.fn(async () => ({ archivedSessionIds: [] })), + listSessions: vi.fn(async () => ({ items: ['a', 'b'].map(sessionId => ({ sessionId, cwd: '/workspace', updatedAt: 1, + blank: true, running: false, agentPreset: 'standard', projections: { values: { ...projections } } })) })), + openSession: vi.fn(async () => ({ events: [], hasMore: false, projections: { ...projections }, isCurrent: () => true, activate() {} })), + models: vi.fn(async () => models()), currentModels: vi.fn(() => models()), + listCommands: vi.fn(async () => [{ name: 'plan', description: 'Plan' }]), + listSkills: vi.fn(async (): Promise => []), + listAgentPresets: vi.fn(async () => ({ presets: [{ id: 'standard', trust: 'system', isDefault: true }, { id: 'minimal', trust: 'system', isDefault: false }] })), + selectModel: vi.fn(async () => ({})), settings: vi.fn(), mutateSettings: vi.fn(), pluginInventory: vi.fn(), + selectAgentPreset: vi.fn(), prompt: vi.fn(async () => ({})), + executeCommand: vi.fn(async () => ({ result: { kind: 'success' } })), + } + mocks.client = client + const output = { appendLine: vi.fn() } + const controller = new DshChatController({ start: vi.fn(async () => {}), connection: {}, state: { kind: 'ready' } } as any, + output as any, {} as any, { clear() {}, rebuild: () => [], accept: () => false, dispose() {} } as any, + { get: () => [], update: async () => {} } as any, '/workspace') + controllers.push(controller) + await controller.start() + await vi.waitFor(() => expect(controller.state.commands).toHaveLength(1)) + const emit = (payload: Record, channel: 'host' | 'mux' = 'host') => { + for (const listener of listeners) listener({ channel, rpcId: '', payload }) + } + return { client, controller, output, emit } +} + +describe('sidebar discovery notifications', () => { + it('accepts the new command attachment capability without enabling attachments on other commands', async () => { + const h = await harness() + const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'YWJj' } + h.client.listCommands.mockResolvedValue([{ name: 'plan', description: '', input: { hint: '', attachments: true } }] as any) + await h.controller.send('/plan inspect', [image]) + expect(h.client.executeCommand).toHaveBeenCalledWith('a', '/plan inspect', [image]) + h.client.listCommands.mockResolvedValue([{ name: 'plan', description: '', input: { hint: '', attachments: false, images: true } }] as any) + await expect(h.controller.send('/plan inspect', [image])).rejects.toThrow('does not accept image') + expect(h.client.executeCommand).toHaveBeenCalledTimes(1) + }) + + it('publishes 0.1.5 live text without adding it to history and clears it at durable settlement', async () => { + const h = await harness() + const live = (update: unknown, sessionId = 'a') => h.emit({ type: 'session/assistant-stream', sessionId, update }, 'mux') + live({ kind: 'start', attemptId: 'a:1', turn: 1, step: 1 }) + live({ kind: 'chunk', attemptId: 'a:1', chunk: { type: 'text-delta', text: 'Live text' } }) + expect(h.controller.state.messages).toMatchObject([{ text: 'Live text', streaming: true }]) + expect((h.controller as any).historyEntries).toEqual([]) + live({ kind: 'end', attemptId: 'a:1' }, 'b') + expect(h.controller.state.messages[0]?.text).toBe('Live text') + h.emit({ type: 'session/event', sessionId: 'a', event: { + type: 'assistant/message', seq: 1, time: 10, surfaceOp: 'append', + data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'Final text' }] }, stream: [] }, + } }, 'mux') + expect(h.controller.state.messages).toEqual([{ id: 'assistant:1:1', role: 'assistant', text: 'Final text' }]) + expect((h.controller as any).historyEntries).toHaveLength(1) + }) + + it('refreshes commands and models, while permissions and Plan only follow projections', async () => { + const h = await harness() + h.client.listCommands.mockResolvedValue([]) + h.emit({ type: 'host/commands-changed' }) + await vi.waitFor(() => expect(h.controller.state.commands).toEqual([])) + expect(h.controller.state.plan.available).toBe(false) + h.client.models.mockResolvedValue(models('new-model')) + h.emit({ type: 'host/models-changed' }) + await vi.waitFor(() => expect(h.controller.state.models[0]?.model).toBe('new-model')) + h.emit({ type: 'session/projection', sessionId: 'a', key: 'permissions', value: { + currentValue: 'read-only', options: [{ value: 'read-only' }, { value: 'workspace-write' }], + } }, 'mux') + expect(h.controller.state.permissions[0]?.selected).toBe(true) + h.emit({ type: 'session/projection', sessionId: 'b', key: 'plan', value: { active: true, pending: false } }, 'mux') + expect(h.controller.state.plan.active).toBe(false) + h.emit({ type: 'session/projection', sessionId: 'a', key: 'plan', value: { active: true, pending: false } }, 'mux') + expect(h.controller.state.plan).toEqual({ active: true, pending: false, available: false }) + }) + + it('ignores delayed discovery results across A → B → A switches', async () => { + const h = await harness() + const old = Promise.withResolvers>() + h.client.listCommands.mockReturnValueOnce(old.promise) + h.emit({ type: 'host/commands-changed' }) + await h.controller.selectSession('b') + h.client.listCommands.mockResolvedValue([{ name: 'current', description: '' }]) + await h.controller.selectSession('a') + await vi.waitFor(() => expect(h.controller.state.commands[0]?.name).toBe('current')) + old.resolve([{ name: 'stale', description: '' }]) + await old.promise + expect(h.controller.state.commands[0]?.name).toBe('current') + }) + + it('only refreshes the active composition and uses the projection for its selected preset', async () => { + const h = await harness() + const calls = h.client.listSkills.mock.calls.length + h.emit({ type: 'host/session-composition-changed', sessionId: 'b' }) + expect(h.client.listSkills).toHaveBeenCalledTimes(calls) + h.emit({ type: 'host/session-composition-changed', sessionId: 'a' }) + expect(h.controller.state.agentPreset.current).toBe('standard') + h.emit({ type: 'session/projection', sessionId: 'a', key: 'agentPreset', value: 'minimal' }, 'mux') + await vi.waitFor(() => expect(h.controller.state.agentPreset.current).toBe('minimal')) + expect(h.client.listSkills.mock.calls.length).toBeGreaterThan(calls) + }) + + it('preserves the working sidebar after a background discovery failure and retries on the next notification', async () => { + const h = await harness() + h.client.models.mockRejectedValueOnce(new Error('temporary catalog failure')) + h.emit({ type: 'host/settings-changed', ns: 'llm', revision: 2 }) + await vi.waitFor(() => expect(h.output.appendLine).toHaveBeenCalledWith(expect.stringContaining('temporary catalog failure'))) + expect(h.controller.state.phase).toBe('ready') + expect(h.controller.state.models[0]?.model).toBe('model') + h.client.models.mockResolvedValue(models('recovered')) + h.emit({ type: 'host/models-changed' }) + await vi.waitFor(() => expect(h.controller.state.models[0]?.model).toBe('recovered')) + }) + + it('waits for a refreshed catalog before dispatching a newly added slash command', async () => { + const h = await harness() + const pending = Promise.withResolvers>() + h.client.listCommands.mockReturnValue(pending.promise) + h.emit({ type: 'host/session-composition-changed', sessionId: 'a' }) + expect(h.controller.state.commands).toEqual([]) + const send = h.controller.send('/new-command') + expect(h.client.prompt).not.toHaveBeenCalled() + expect(h.client.executeCommand).not.toHaveBeenCalled() + pending.resolve([{ name: 'new-command', description: '' }]) + await send + expect(h.client.executeCommand).toHaveBeenCalledWith('a', '/new-command', undefined) + expect(h.client.prompt).not.toHaveBeenCalled() + }) + + it('routes from the latest catalog, preserving unknown slash text and skill input', async () => { + const h = await harness() + h.client.listCommands.mockResolvedValue([]) + await h.controller.send('/plan is plain text now') + expect(h.client.prompt).toHaveBeenCalledWith('a', '/plan is plain text now', [], 'queue') + expect(h.client.executeCommand).not.toHaveBeenCalled() + h.client.listSkills.mockResolvedValue([{ name: 'review', description: '', modelInvocable: true }]) + const context = { activeFile: { kind: 'file' as const, path: 'code.ts' }, mentions: [], pinned: [] } + await h.controller.send('/review code', [], context) + expect(h.client.prompt).toHaveBeenLastCalledWith('a', '/review code', [], 'queue') + h.client.listCommands.mockRejectedValue(new Error('catalog unavailable')) + await expect(h.controller.send('/unknown')).rejects.toThrow('catalog unavailable') + expect(h.client.prompt).toHaveBeenCalledTimes(2) + await h.controller.send('hello') + expect(h.client.prompt).toHaveBeenLastCalledWith('a', 'hello', [], 'queue') + }) + + it('does not dispatch a slash input after the conversation changed during discovery', async () => { + const h = await harness() + const pending = Promise.withResolvers>() + h.client.listCommands.mockReturnValueOnce(pending.promise) + const send = h.controller.send('/plan') + const rejected = expect(send).rejects.toThrow('conversation changed') + await h.controller.selectSession('b') + await h.controller.selectSession('a') + pending.resolve([{ name: 'plan', description: '' }]) + await rejected + expect(h.client.executeCommand).not.toHaveBeenCalled() + expect(h.client.prompt).not.toHaveBeenCalled() + }) + + it.each(['resolve', 'reject'])('does not replace a newer projected preset after a delayed local %s', async outcome => { + const h = await harness() + const pending = Promise.withResolvers<{ agentPreset: string }>() + h.client.selectAgentPreset.mockReturnValueOnce(pending.promise) + const selection = h.controller.selectAgentPreset('minimal') + const settled = selection.catch(error => error) + h.emit({ type: 'session/projection', sessionId: 'a', key: 'agentPreset', value: 'minimal' }, 'mux') + h.emit({ type: 'session/projection', sessionId: 'a', key: 'agentPreset', value: 'standard' }, 'mux') + if (outcome === 'resolve') pending.resolve({ agentPreset: 'minimal' }) + else pending.reject(new Error('old operation failed')) + await settled + expect(h.controller.state.agentPreset.current).toBe('standard') + expect(h.controller.state.agentPreset.busy).toBe(false) + }) + + it('notifies an open settings picker and refuses to save a draft into a replacement runtime', async () => { + const h = await harness() + const listener = vi.fn() + h.controller.onDidChangeRuntimeSettings(listener) + h.emit({ type: 'host/settings-changed', ns: 'test', revision: 2 }) + expect(listener).toHaveBeenCalledTimes(1) + h.emit({ type: 'host/credentials-changed' }) + expect(listener).toHaveBeenCalledTimes(2) + const namespace = { ns: 'test', revision: 1 } + h.client.settings.mockResolvedValue({ namespaces: [namespace] }) + const draft = (await h.controller.settings()).namespaces[0]! + await h.controller.mutateSettings(draft, []) + expect(h.client.mutateSettings).toHaveBeenCalledWith('test', [], 1) + const replacement = { ...h.client, mutateSettings: vi.fn() } + mocks.client = replacement + await h.controller.start() + expect(() => h.controller.mutateSettings(draft, [])).toThrow('runtime changed') + expect(replacement.mutateSettings).not.toHaveBeenCalled() + }) + + it('rejects inventory results from a replaced runtime', async () => { + const h = await harness() + const pending = Promise.withResolvers<{ entries: [] }>() + h.client.pluginInventory.mockReturnValueOnce(pending.promise) + const inventory = h.controller.pluginInventory() + const rejected = expect(inventory).rejects.toThrow('runtime changed') + mocks.client = { ...h.client } + await h.controller.start() + pending.resolve({ entries: [] }) + await rejected + }) +}) diff --git a/tests/dsh-assistant-stream.spec.ts b/tests/dsh-assistant-stream.spec.ts new file mode 100644 index 0000000..7aee1df --- /dev/null +++ b/tests/dsh-assistant-stream.spec.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { DshAssistantStream, type AssistantStreamUpdate } from '../src/dsh-assistant-stream.js' +import { ConversationProjector, assistantStreamAppend } from '../src/conversation.js' +import type { HistoryEntry } from '../src/dsh-client.js' + +const start = (attemptId = 'a', revision = 1) => ({ type: 'start', attemptId, revision, turn: 1, step: 1, startedAfterSeq: 5 }) +const chunk = (text: string, index = 0, attemptId = 'a', revision = index + 2) => ({ + type: 'chunk', attemptId, revision, index, time: 100 + index, chunk: { type: 'text-delta', index: 0, text }, +}) +const message = (text: string, seq = 6, type = 'assistant/message'): HistoryEntry => ({ + event: { type, seq, time: 200, surfaceOp: 'append', data: { turn: 1, step: 1, message: { content: [{ type: 'text', text }] }, stream: [] } }, +}) +const end = (index = 1, kind = 'committed', eventType = 'assistant/message') => ({ + type: 'end', attemptId: 'a', revision: index + 2, index, + outcome: kind === 'committed' ? { kind, eventType, seq: 6 } : { kind }, +}) +function harness() { + const live: AssistantStreamUpdate[] = [] + const durable: HistoryEntry[] = [] + const projector = new ConversationProjector() + const stream = new DshAssistantStream( + update => { live.push(update); projector.applyStream(update) }, + entry => { durable.push(entry); projector.apply(entry.event) }, + ) + return { stream, live, durable, projector } +} + +describe('DSH 0.1.5 assistant presentation stream', () => { + it('shows text before settlement, without fabricating durable history or duplicating the final message', () => { + const h = harness() + h.stream.open({ revision: 0 }, 5) + h.stream.frame(start(), 5) + h.stream.frame(chunk('Hello '), 5) + const first = h.projector.messages()[0]! + expect(first).toMatchObject({ role: 'assistant', text: 'Hello ', streaming: true }) + h.stream.frame(chunk('world', 1), 5) + expect(assistantStreamAppend(first, h.projector.messages()[0]!)).toBe('world') + expect(h.durable).toEqual([]) + h.stream.durable(message('Hello world')) + expect(h.durable).toEqual([]) + h.stream.frame(end(2), 6) + expect(h.durable).toEqual([message('Hello world')]) + expect(h.projector.messages()).toEqual([{ id: 'assistant:1:1', role: 'assistant', text: 'Hello world' }]) + }) + + it('restores a compact live prefix and continues from the exact revision and chunk index', () => { + const h = harness() + h.stream.open({ revision: 4, activeAttempt: { ...start(), nextIndex: 3, stream: [ + { type: 'text-chunks', time0: 20, index: 0, texts: ['Hello ', 'world'], dt: [5] }, + { type: 'chunk', time: 26, chunk: { type: 'usage', usage: {} } }, + ] } }, 5) + expect(h.projector.messages()[0]?.text).toBe('Hello world') + h.stream.frame(chunk('!', 3, 'a', 5), 5) + expect(h.projector.messages()[0]?.text).toBe('Hello world!') + expect(h.durable).toEqual([]) + }) + + it('preserves a live prefix while loading older history, but clears it when switching sessions', () => { + const h = harness() + h.stream.frame(start(), 5); h.stream.frame(chunk('Current'), 5) + const before = h.projector.messages()[0]! + h.projector.reset([message('Older', 2)], true) + expect(h.projector.messages().map(m => m.text)).toEqual(['Older', 'Current']) + h.stream.frame(chunk(' response', 1), 5) + expect(assistantStreamAppend(before, h.projector.messages()[1]!)).toBe(' response') + h.projector.reset([]) + expect(h.projector.messages()).toEqual([]) + }) + + it('does not duplicate a settlement already present in the opening snapshot', () => { + const h = harness() + const settled = message('Complete') + h.projector.reset([settled]) + h.stream.open({ revision: 2, activeAttempt: { ...start(), nextIndex: 1, + stream: [{ type: 'text-chunks', time0: 10, index: 0, texts: ['Complete'], dt: [] }], + } }, 6, [settled]) + expect(h.live).toEqual([]) + h.stream.frame(end(), 6) + expect(h.durable).toEqual([]) + expect(h.projector.messages()).toEqual([{ id: 'assistant:1:1', role: 'assistant', text: 'Complete' }]) + }) + + it.each(['abandoned', 'failed'])('discards an %s attempt before retrying the same step', outcome => { + const h = harness() + h.stream.frame(start(), 5); h.stream.frame(chunk('Wrong prefix'), 5) + if (outcome === 'failed') h.stream.durable(message('', 6, 'assistant/attempt')) + h.stream.frame(end(1, outcome === 'failed' ? 'committed' : 'abandoned', 'assistant/attempt'), 6) + expect(h.projector.messages()).toEqual([]) + h.stream.frame(start('retry', 4), 6) + h.stream.frame(chunk('Correct', 0, 'retry', 5), 6) + expect(h.projector.messages().map(m => m.text)).toEqual(['Correct']) + expect(h.durable).toHaveLength(outcome === 'failed' ? 1 : 0) + }) + + it('replaces a cancelled prefix with the actual interrupted durable content', () => { + const h = harness() + h.stream.frame(start(), 5); h.stream.frame(chunk('Unfinished output'), 5) + const settled = message('Delivered prefix') + h.stream.durable({ event: { ...settled.event, data: { ...(settled.event.data as object), interrupted: true } } }) + h.stream.frame(end(), 6) + expect(h.projector.messages()).toEqual([{ id: 'assistant:1:1', role: 'assistant', text: 'Delivered prefix' }]) + }) + + it('does not hide unrelated durable messages behind an active attempt', () => { + const h = harness() + h.stream.frame(start(), 5) + const other = message('Other', 6) + other.event.data = { ...(other.event.data as object), step: 0 } + h.stream.durable(other) + const replacement = message('Replacement', 7) + replacement.event.surfaceOp = { op: 'replace', startSeq: 0, endSeq: 0 } + h.stream.durable(replacement) + expect(h.durable).toEqual([other, replacement]) + }) + + it('keeps the old durable-chunk path and ignores an orphan transient suffix', () => { + const h = harness() + h.stream.open(undefined, 5) + const legacy = { event: { type: 'assistant/chunk', seq: 6, time: 10, data: { turn: 1, step: 1, chunk: chunk('Old runtime').chunk } } } + h.stream.durable(legacy) + expect(h.projector.messages()[0]?.text).toBe('Old runtime') + const late = harness() + late.stream.open({ revision: 8 }, 5) + late.stream.frame(chunk('Incomplete suffix', 3, 'a', 9), 5) + late.stream.durable(message('Full message')) + late.stream.frame({ ...end(4), revision: 10 }, 6) + expect(late.projector.messages().map(m => m.text)).toEqual(['Full message']) + }) + + it('restores mixed reasoning, tool, text and image prefix records without showing tool arguments as prose', () => { + const h = harness() + h.stream.open({ revision: 5, activeAttempt: { ...start(), nextIndex: 4, stream: [ + { type: 'reasoning-chunks', time0: 10, index: 0, texts: ['Private reasoning'], dt: [] }, + { type: 'tool-call-chunks', time0: 11, index: 1, id: 'call', name: 'Read', args: ['{"path":"file"}'], dt: [] }, + { type: 'text-chunks', time0: 12, index: 2, texts: ['Visible'], dt: [] }, + { type: 'chunk', time: 13, chunk: { type: 'block-end', index: 3, block: { type: 'image', attachment: { attachmentId: 'img', mediaType: 'image/png', width: 1, height: 1 } } } }, + ] } }, 5) + expect(h.projector.messages()).toMatchObject([{ text: 'Visible', images: [{ attachmentId: 'img' }] }]) + h.stream.frame(chunk(' text', 4, 'a', 6), 5) + expect(h.projector.messages()).toMatchObject([{ text: 'Visible text', images: [{ attachmentId: 'img' }] }]) + }) + + it.each([ + { ...chunk('gap'), revision: 3 }, + { ...chunk('wrong index'), index: 1 }, + { ...chunk('wrong attempt'), attemptId: 'other' }, + { ...chunk('invalid'), chunk: [] }, + { ...start('overlap', 2) }, + ])('fails closed on invalid or discontinuous live frames: %j', frame => { + const h = harness(); h.stream.frame(start(), 5) + expect(() => h.stream.frame(frame, 5)).toThrow('assistant stream') + expect(h.durable).toEqual([]) + }) + + it('rejects an end marker that does not identify the staged settlement', () => { + const h = harness(); h.stream.frame(start(), 5); h.stream.frame(chunk('Hello'), 5) + h.stream.durable(message('Hello')) + expect(() => h.stream.frame({ ...end(), outcome: { kind: 'committed', seq: 7, eventType: 'assistant/message' } }, 6)).toThrow() + expect(h.durable).toEqual([]) + }) + + it.each([ + { revision: -1 }, + { revision: 2, activeAttempt: { ...start(), nextIndex: 1, stream: [] } }, + { revision: 2, activeAttempt: { ...start(), nextIndex: 1, stream: [{ type: 'text-chunks', time0: 1, index: 0, texts: ['a'], dt: [1] }] } }, + { revision: 2, activeAttempt: { ...start(), nextIndex: 2, stream: [{ type: 'text-chunks', time0: Number.MAX_SAFE_INTEGER, index: 0, texts: ['a', 'b'], dt: [1] }] } }, + { revision: 1, activeAttempt: { ...start(), startedAfterSeq: 7, nextIndex: 0, stream: [] } }, + ])('rejects corrupt reconnect prefixes: %j', baseline => { + expect(() => harness().stream.open(baseline, 5)).toThrow('assistant stream') + }) +}) diff --git a/tests/dsh-client.spec.ts b/tests/dsh-client.spec.ts index 53db3d7..ad326ae 100644 --- a/tests/dsh-client.spec.ts +++ b/tests/dsh-client.spec.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' import { DshClient, type DshFrame } from '../src/dsh-client.js' import type { DshConnection } from '../src/dsh-connection.js' +import { DshConnectionError } from '../src/dsh-connection.js' import { decodeHistory } from '../src/dsh-history.js' import { ConversationProjector } from '../src/conversation.js' @@ -47,6 +48,9 @@ function harness(autoSnapshot = true) { const connection = { call: vi.fn(async (endpoint: string, args: unknown) => { requests.push({ endpoint, args }) + if (endpoint === 'commands/execute' && (args as any).line === '') { + throw new DshConnectionError('gateway/arguments-invalid', 'typert gateway: commands/execute: args fields do not match the descriptor: missing "images"; unexpected "submittedAttachments"') + } return results[endpoint] ?? { accepted: true } }), openStreamSocket: vi.fn(() => { @@ -62,6 +66,71 @@ function harness(autoSnapshot = true) { } describe('DSH 0.1.2 chat transport', () => { + it('forwards the actual discovery events and does not publish credential references', async () => { + const h = harness() + await h.client.startStreams() + for (const [event, args] of [ + ['commands/change', []], ['llm/adapters-updated', []], ['credentials/reference-updated', ['SECRET_REFERENCE']], + ['settings/document-updated', ['llm-deepseek', 3]], ['agent-preset/selected', ['s', 'minimal']], + ]) h.push('$events', { type: 'emit', event, args }) + expect(h.frames.map(f => f.payload)).toEqual([ + { type: 'host/commands-changed' }, { type: 'host/models-changed' }, { type: 'host/credentials-changed' }, + { type: 'host/settings-changed', ns: 'llm-deepseek', revision: 3 }, + { type: 'host/session-composition-changed', sessionId: 's' }, + ]) + expect(JSON.stringify(h.frames)).not.toContain('SECRET_REFERENCE') + expect(h.errors).toEqual([]) + }) + + it('invalidates command catalogs globally, skills only for the recomposed session, and presets on settings changes', async () => { + const h = harness() + h.results['commands/list'] = [{ name: 'plan', description: 'Plan' }] + await h.client.startStreams() + await h.client.listCommands('a'); await h.client.listCommands('b') + await h.client.listSkills('a'); await h.client.listSkills('b') + await h.client.listAgentPresets() + const count = (endpoint: string) => h.requests.filter(r => r.endpoint === endpoint).length + h.push('$events', { type: 'emit', event: 'commands/change', args: [] }) + h.results['commands/list'] = [] + expect(await h.client.listCommands('a')).toEqual([]) + expect(await h.client.listCommands('b')).toEqual([]) + expect(count('commands/list')).toBe(4) + h.push('$events', { type: 'emit', event: 'agent-preset/selected', args: ['a', 'minimal'] }) + await h.client.listSkills('a'); await h.client.listSkills('b') + expect(count('skills/list')).toBe(3) + h.push('$events', { type: 'emit', event: 'settings/document-updated', args: ['agent-presets', 1] }) + await h.client.listAgentPresets() + expect(count('agentPresets/list')).toBe(2) + }) + + it('discards an old model catalog when settings or credentials change during its request', async () => { + const h = harness() + await h.client.startStreams() + const first = Promise.withResolvers() + vi.mocked(h.connection.call).mockImplementationOnce(() => first.promise as any) + const pending = h.client.models('s') + h.push('$events', { type: 'emit', event: 'llm/adapters-updated', args: [] }) + h.push('$events', { type: 'emit', event: 'settings/document-updated', args: ['llm-deepseek', 1] }) + h.push('$events', { type: 'emit', event: 'credentials/reference-updated', args: ['API_KEY'] }) + h.results['session/modelCatalog'] = { default: { provider: 'new', model: 'new' }, routableProviders: ['new'], groups: [], failures: [] } + first.resolve({ default: { provider: 'old', model: 'old' }, routableProviders: [], groups: [], failures: [] }) + expect(await pending).toMatchObject({ current: { provider: 'new', model: 'new' }, routable: true }) + expect(h.client.currentModels('s')).toMatchObject({ current: { provider: 'new', model: 'new' } }) + }) + + it('reopening a session refetches its command and skill catalogs', async () => { + const h = harness() + await h.client.startStreams() + h.results['commands/list'] = [{ name: 'old' }] + await h.client.listCommands('s'); await h.client.listSkills('s') + const opening = await h.client.openSession('s') + opening.activate() + h.results['commands/list'] = [{ name: 'new' }] + expect(await h.client.listCommands('s')).toEqual([{ name: 'new' }]) + await h.client.listSkills('s') + expect(h.requests.filter(r => r.endpoint === 'skills/list')).toHaveLength(2) + }) + it('multiplexes all baselines through one connection and uses named Remote arguments', async () => { const h = harness() await h.client.startStreams() @@ -100,6 +169,7 @@ describe('DSH 0.1.2 chat transport', () => { { endpoint: 'settings/mutate', args: { ns: 'ns', ops: [{ op: 'set', path: ['x'], value: 1 }], expectedRevision: 3 } }, { endpoint: 'skills/list', args: { request: { sessionId: 's' } } }, { endpoint: 'agentPresets/select', args: { agentId: 's', agentPreset: 'coding' } }, + { endpoint: 'commands/execute', args: { agentId: 's', line: '', submittedAttachments: [] } }, { endpoint: 'commands/execute', args: { agentId: 's', line: '/compact', images: [] } }, { endpoint: 'commands/execute', args: { agentId: 's', line: '/plan inspect', images: [{ mediaType: 'image/png', data: 'YWJj', name: 'image.png' }] } }, ]) @@ -122,6 +192,59 @@ describe('DSH 0.1.2 chat transport', () => { expect(h.requests.at(-1)).toEqual({ endpoint: 'session/page', args: { request: { address: { kind: 'session', sessionId: 's' }, throughSeq: 5, beforeSeq: 5, maxMessages: 100 } } }) }) + it('opts into 0.1.5 streaming, buffers the live baseline and settlement, and keeps history cursors durable', async () => { + const h = harness(false) + await h.client.startStreams() + const pending = h.client.openSession('s') + expect(h.outgoing.at(-1).payload.args.request.assistantStream).toBe(true) + h.push('session/follow', { ...snapshot(), records: [], assistantStream: { revision: 2, activeAttempt: { + attemptId: 'a', turn: 1, step: 1, startedAfterSeq: 5, nextIndex: 1, + stream: [{ type: 'text-chunks', time0: 10, index: 0, dt: [], texts: ['Hello'] }], + } } }) + const opening = await pending + h.push('session/follow', { type: 'assistant-stream', frame: { + type: 'chunk', attemptId: 'a', revision: 3, index: 1, time: 20, chunk: { type: 'text-delta', index: 0, text: ' world' }, + } }) + h.push('session/follow', { type: 'event', event: { type: 'assistant/message', seq: 6, time: 30, surfaceOp: 'append', + data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'Hello world' }] }, stream: [] }, + } }) + h.push('session/follow', { type: 'assistant-stream', frame: { type: 'end', attemptId: 'a', revision: 4, index: 2, + outcome: { kind: 'committed', eventType: 'assistant/message', seq: 6 }, + } }) + expect(opening.events).toEqual([]) + expect(h.frames).toEqual([]) + opening.activate() + const projector = new ConversationProjector() + for (const frame of h.frames) { + if (frame.payload.type === 'session/assistant-stream') projector.applyStream(frame.payload.update as any) + if (frame.payload.type === 'session/event') projector.apply(frame.payload.event as any) + } + expect(projector.messages()).toEqual([{ id: 'assistant:1:1', role: 'assistant', text: 'Hello world' }]) + expect(h.frames.filter(f => f.payload.type === 'session/event')).toHaveLength(1) + await h.client.history('s', 5) + expect(h.requests.at(-1)?.args.request).toMatchObject({ throughSeq: 5, beforeSeq: 5 }) + // The next durable seq follows 6, not the number of transient chunks. + h.push('session/follow', { type: 'event', event: { seq: 7, time: 40, type: 'turn/end', data: { turn: 1 } } }) + expect(h.errors).toEqual([]) + }) + + it('does not leak a cancelled live prefix into another subscription', async () => { + const h = harness(false) + await h.client.startStreams() + const first = h.client.openSession('a') + h.push('session/follow', { ...snapshot('a'), assistantStream: { revision: 0 } }) + const old = await first + const oldId = h.ids.get('session/follow')! + h.push('session/follow', { type: 'assistant-stream', frame: { type: 'start', attemptId: 'old', revision: 1, turn: 1, step: 1, startedAfterSeq: 5 } }) + const second = h.client.openSession('b') + h.push('session/follow', { ...snapshot('b'), assistantStream: { revision: 0 } }) + const current = await second + old.activate(); current.activate() + h.receive(oldId, { type: 'assistant-stream', frame: { type: 'chunk', attemptId: 'old', revision: 2, index: 0, time: 10, chunk: { type: 'text-delta', text: 'Stale' } } }) + expect(h.frames.some(f => f.payload.type === 'session/assistant-stream')).toBe(false) + expect(h.errors).toEqual([]) + }) + it('cancels stale subscriptions including A → B → A and ignores late frames', async () => { const h = harness(false) await h.client.startStreams() diff --git a/tests/dsh-command-transport.spec.ts b/tests/dsh-command-transport.spec.ts new file mode 100644 index 0000000..7d9150c --- /dev/null +++ b/tests/dsh-command-transport.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { DshCommandTransport } from '../src/dsh-command-transport.js' +import { DshConnectionError } from '../src/dsh-connection.js' + +const legacy = () => new DshConnectionError('gateway/arguments-invalid', + 'typert gateway: commands/execute: args fields do not match the descriptor: missing "images"; unexpected "submittedAttachments"') +const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'YWJj', name: 'example.png' } +const ack = { commandId: 'command', result: { kind: 'success' } } + +describe('command attachment format negotiation', () => { + it('probes once using an empty line, then sends typed attachments to 0.1.5', async () => { + const call = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValue(ack) + const transport = new DshCommandTransport(call) + await transport.execute('s', '/plan inspect', [image]) + await transport.execute('s', '/permission read-only', []) + expect(call.mock.calls).toEqual([ + ['commands/execute', { agentId: 's', line: '', submittedAttachments: [] }, 10_000], + ['commands/execute', { agentId: 's', line: '/plan inspect', submittedAttachments: [image] }, 300_000], + ['commands/execute', { agentId: 's', line: '/permission read-only', submittedAttachments: [] }, 300_000], + ]) + }) + + it('uses 0.1.2 images only after its explicit pre-dispatch descriptor rejection', async () => { + const call = vi.fn().mockRejectedValueOnce(legacy()).mockResolvedValue(ack) + const transport = new DshCommandTransport(call) + await transport.execute('s', '/plan inspect', [image]) + await transport.execute('other', '/plan off', []) + expect(call.mock.calls.slice(1)).toEqual([ + ['commands/execute', { agentId: 's', line: '/plan inspect', images: [{ mediaType: image.mediaType, data: image.data, name: image.name }] }, 300_000], + ['commands/execute', { agentId: 'other', line: '/plan off', images: [] }, 300_000], + ]) + }) + + it('coalesces concurrent probes without dropping or replaying either command', async () => { + const ready = Promise.withResolvers() + const call = vi.fn().mockReturnValueOnce(ready.promise).mockResolvedValue(ack) + const transport = new DshCommandTransport(call) + const first = transport.execute('a', '/compact', []) + const second = transport.execute('b', '/plan', []) + expect(call).toHaveBeenCalledTimes(1) + ready.resolve(undefined) + await Promise.all([first, second]) + expect(call).toHaveBeenCalledTimes(3) + expect(call.mock.calls.slice(1).map(([, args]) => args.line)).toEqual(['/compact', '/plan']) + }) + + it.each(['authentication-required', 'transport-error', 'cancelled', 'gateway/arguments-invalid'])('does not downgrade on %s or send the command after a failed probe', async code => { + const call = vi.fn().mockRejectedValueOnce(new DshConnectionError(code, 'Other failure')) + const transport = new DshCommandTransport(call) + await expect(transport.execute('s', '/compact', [])).rejects.toThrow('Other failure') + expect(call).toHaveBeenCalledTimes(1) + call.mockResolvedValueOnce(undefined).mockResolvedValueOnce(ack) + await transport.execute('s', '/plan', []) + expect(call.mock.calls.slice(1).map(([, args]) => args.line)).toEqual(['', '/plan']) + }) + + it('never retries a real command, even when its error looks like a format mismatch', async () => { + const call = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(legacy()) + const transport = new DshCommandTransport(call) + await expect(transport.execute('s', '/compact', [])).rejects.toThrow('descriptor') + expect(call).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/plugin-manager.spec.ts b/tests/plugin-manager.spec.ts new file mode 100644 index 0000000..bcbf1eb --- /dev/null +++ b/tests/plugin-manager.spec.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + pick: vi.fn(), warning: vi.fn(), input: vi.fn(), progress: vi.fn(), installed: vi.fn(), spawn: vi.fn(), +})) +vi.mock('vscode', () => ({ + QuickPickItemKind: { Separator: -1 }, ProgressLocation: { Notification: 15 }, + window: { showQuickPick: mocks.pick, showWarningMessage: mocks.warning, showInputBox: mocks.input, withProgress: mocks.progress }, + workspace: { getWorkspaceFolder: () => undefined, getConfiguration: () => ({ get: () => '' }) }, + Uri: { file: (fsPath: string) => ({ fsPath }) }, +})) +vi.mock('node:child_process', () => ({ spawn: mocks.spawn })) +vi.mock('../src/launch.js', () => ({ resolveLaunch: () => ({ command: 'dsh', args: [], sourceCheckout: false }) })) +vi.mock('../src/plugin-profile.js', async importOriginal => ({ + ...await importOriginal(), readInstalledPlugins: mocks.installed, +})) +import { DshPluginManager } from '../src/plugin-manager.js' + +function harness(ownership: 'external' | 'managed' = 'managed') { + const controller = { + runtimeOwnership: ownership, cwd: '/workspace', state: { phase: 'ready', statusText: '', running: false }, + pluginInventory: vi.fn(async () => ({ entries: [], agentPresets: [{ id: 'coding', trust: 'system', isDefault: true, + rows: [{ entryId: 'mcp', moduleName: '@deepseek-ai/dsh-mcp-client', enabled: true, fiberPhase: null }] }] })), + restart: vi.fn(), + } + const manager = new DshPluginManager({ extensionUri: { fsPath: '/extension' } } as any, controller as any, + { appendLine: vi.fn() } as any) + return { controller, manager } +} +beforeEach(() => { vi.clearAllMocks(); mocks.installed.mockReturnValue([]) }) + +describe('runtime plugin management', () => { + it('shows preset and global inventory with read-only rows and fetches again on refresh', async () => { + const h = harness() + mocks.pick.mockImplementationOnce(items => items.find((item: any) => item.action === 'inventory')) + .mockImplementationOnce((items, options) => { + expect(items.some((item: any) => item.description === 'Configured · not running')).toBe(true) + expect(items.some((item: any) => item.label === 'Global runtime')).toBe(true) + expect(options.placeHolder).toContain('does not confirm') + expect(items.every((item: any) => item.action === undefined)).toBe(true) + return items[0] + }).mockResolvedValue(undefined) + await h.manager.show() + expect(h.controller.pluginInventory).toHaveBeenCalledTimes(4) + expect(mocks.spawn).not.toHaveBeenCalled() + expect(h.controller.restart).not.toHaveBeenCalled() + }) + + it('never reads or offers removal of local profile dependencies for an external runtime', async () => { + const h = harness('external') + mocks.pick.mockImplementationOnce(items => { + expect(items.map((item: any) => item.action)).toEqual(['browse', 'configure', 'inventory', 'refresh']) + return undefined + }) + await h.manager.show() + expect(mocks.installed).not.toHaveBeenCalled() + expect(mocks.spawn).not.toHaveBeenCalled() + }) + + it('refuses a local install when the runtime becomes external during confirmation', async () => { + const h = harness() + mocks.pick.mockImplementationOnce(items => items.find((item: any) => item.action === 'install')) + mocks.input.mockResolvedValue('@example/plugin') + mocks.warning.mockImplementationOnce(() => { h.controller.runtimeOwnership = 'external'; return 'Install' }) + await expect(h.manager.show()).rejects.toThrow('No local profile was changed') + expect(mocks.spawn).not.toHaveBeenCalled() + expect(h.controller.restart).not.toHaveBeenCalled() + }) + + it('rechecks for a running task before starting the package-manager process', async () => { + const h = harness() + mocks.pick.mockImplementationOnce(items => items.find((item: any) => item.action === 'install')) + mocks.input.mockResolvedValue('@example/plugin') + mocks.warning.mockResolvedValue('Install') + mocks.progress.mockImplementationOnce((_options, operation) => { + h.controller.state.running = true + return operation({}, {}) + }) + await expect(h.manager.show()).rejects.toThrow('runtime changed or a task started') + expect(mocks.spawn).not.toHaveBeenCalled() + }) +}) diff --git a/tests/remote-read.spec.ts b/tests/remote-read.spec.ts new file mode 100644 index 0000000..5f088b0 --- /dev/null +++ b/tests/remote-read.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteRead } from '../src/remote-read.js' + +describe('RemoteRead', () => { + it('shares reads and re-reads once after a burst of invalidations during a request', async () => { + const first = Promise.withResolvers() + const fetch = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue('new') + const read = new RemoteRead(fetch, new AbortController().signal) + const a = read.read(), b = read.read() + read.invalidate(); read.invalidate(); read.invalidate() + first.resolve('stale') + expect(await Promise.all([a, b])).toEqual(['new', 'new']) + expect(fetch).toHaveBeenCalledTimes(2) + expect(await read.read()).toBe('new') + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('ignores errors from superseded requests, but reports and allows retry after a current failure', async () => { + const first = Promise.withResolvers() + const fetch = vi.fn().mockReturnValueOnce(first.promise).mockRejectedValueOnce(new Error('current failure')).mockResolvedValue('retry') + const read = new RemoteRead(fetch, new AbortController().signal) + const pending = read.read() + read.invalidate() + first.reject(new Error('stale failure')) + await expect(pending).rejects.toThrow('current failure') + expect(await read.read()).toBe('retry') + }) + + it('does not return or cache a late response after disposal', async () => { + const first = Promise.withResolvers(), lifetime = new AbortController() + const read = new RemoteRead(() => first.promise, lifetime.signal) + const pending = read.read() + lifetime.abort(new Error('disposed')) + first.resolve('late') + await expect(pending).rejects.toThrow('disposed') + await expect(read.read()).rejects.toThrow('disposed') + expect(read.current).toBeUndefined() + }) +}) diff --git a/tests/runtime-plugin-inventory.spec.ts b/tests/runtime-plugin-inventory.spec.ts new file mode 100644 index 0000000..ef71fcb --- /dev/null +++ b/tests/runtime-plugin-inventory.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { runtimePluginGroups } from '../src/runtime-plugin-inventory.js' +import { installedPluginStatus, pluginEntryStatus, type AgentPresetPluginGroup, type AgentPresetPluginRow } from '../src/plugin-profile.js' + +const row = (overrides: Partial = {}): AgentPresetPluginRow => ({ + entryId: 'tool', moduleName: '@example/plugin/tool', enabled: true, fiberPhase: null, ...overrides, +}) +const preset = (rows: AgentPresetPluginRow[], overrides: Partial = {}): AgentPresetPluginGroup => ({ + id: 'coding', name: 'Coding', trust: 'system', isDefault: true, rows, ...overrides, +}) + +describe('rc.1 runtime inventory', () => { + it('does not report an unmounted or conditional preset plugin as active', () => { + expect(pluginEntryStatus(row(), true)).toBe('configured') + expect(pluginEntryStatus(row(), false)).toBe('inactive') + expect(pluginEntryStatus(row({ enabled: 'conditional' }), true)).toBe('conditional') + expect(pluginEntryStatus(row({ enabled: false, fiberPhase: 'failed' }), true)).toBe('disabled') + expect(pluginEntryStatus(row({ fiberPhase: 'unloading' }), true)).toBe('unloading') + expect(pluginEntryStatus(row({ fiberPhase: 'pending' }), true)).toBe('loading') + }) + + it('includes preset-only modules in bundle status, prioritizing incomplete or failed loads', () => { + const inventory = { entries: [], agentPresets: [preset([row()])] } + expect(installedPluginStatus('@example/plugin', inventory)).toBe('configured') + inventory.agentPresets[0]!.rows = [row({ fiberPhase: 'active' }), row({ fiberPhase: 'failed' })] + expect(installedPluginStatus('@example/plugin', inventory)).toBe('failed') + inventory.agentPresets[0]!.rows = [row({ fiberPhase: 'active' }), row({ fiberPhase: 'loading' })] + expect(installedPluginStatus('@example/plugin', inventory)).toBe('loading') + expect(installedPluginStatus('@example/plug', inventory)).toBe('unknown') + }) + + it('keeps global and per-preset status separate, including broken and empty presets', () => { + const groups = runtimePluginGroups({ + entries: [{ entryId: 'mcp', moduleName: '@deepseek-ai/dsh-mcp-client', enabled: true, fiberPhase: 'active' }], + agentPresets: [preset([row()]), preset([], { id: 'empty', name: '', isDefault: false }), + preset([], { id: 'broken', broken: 'private loader diagnostics', isDefault: false })], + }) + expect(groups.map(group => group.label)).toEqual(['Agent preset: Coding (default)', 'Agent preset: empty', 'Agent preset: Coding', 'Global runtime']) + expect(groups[0]?.rows[0]).toMatchObject({ description: 'Configured · not running', detail: 'Preset: coding · system · Entry: tool' }) + expect(groups[1]?.rows).toEqual([]) + expect(groups[2]?.rows[0]?.label).toContain('Preset unavailable') + expect(groups[3]?.rows[0]?.description).toBe('Active') + expect(JSON.stringify(groups)).not.toMatch(/connected|private loader diagnostics/) + }) + + it('does not assume preset support or infer bundle provenance from unrelated modules', () => { + expect(runtimePluginGroups({ entries: [] })).toEqual([{ label: 'Global runtime', rows: [] }]) + expect(installedPluginStatus('bundle-wrapper', { entries: [], agentPresets: [preset([row()])] })).toBe('unknown') + expect(installedPluginStatus('@example/plugin', { entries: [], agentPresets: [preset([row()], { broken: 'error' })] })).toBe('unknown') + }) +}) diff --git a/tests/runtime-settings-picker.spec.ts b/tests/runtime-settings-picker.spec.ts new file mode 100644 index 0000000..d8075a8 --- /dev/null +++ b/tests/runtime-settings-picker.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SettingsDescription, SettingsNamespace } from '../src/runtime-settings.js' + +const mocks = vi.hoisted(() => ({ picker: undefined as any })) +vi.mock('vscode', () => ({ window: { createQuickPick: () => mocks.picker } })) +import { pickRuntimeSettingsNamespace } from '../src/runtime-settings-picker.js' + +const namespace = (revision: number): SettingsNamespace => ({ ns: 'test', schema: { uid: 0, refs: { '0': { type: 'object', dict: {} } } }, + value: {}, secrets: [], applies: 'live', revision }) +const description = (revision: number): SettingsDescription => ({ writable: true, hasDocument: true, namespaces: [namespace(revision)] }) +function harness() { + const changed = new Set<() => void>(), hidden = new Set<() => void>(), accepted = new Set<() => void>() + const event = (set: Set<() => void>) => (listener: () => void) => { set.add(listener); return { dispose: () => set.delete(listener) } } + const picker = { items: [] as any[], activeItems: [] as any[], selectedItems: [] as any[], busy: false, + onDidHide: event(hidden), onDidAccept: event(accepted), show: vi.fn(), hide: vi.fn(), dispose: vi.fn() } + mocks.picker = picker + const source = { settings: vi.fn(async () => description(1)), onDidChangeRuntimeSettings: event(changed) } + return { picker, source, changed, hide: () => hidden.forEach(f => f()), accept: () => accepted.forEach(f => f()), refresh: () => changed.forEach(f => f()) } +} + +describe('runtime settings native picker', () => { + it('refreshes an open picker after external changes and returns the latest namespace revision', async () => { + const h = harness() + const selected = pickRuntimeSettingsNamespace(h.source) + await vi.waitFor(() => expect(h.picker.items).toHaveLength(1)) + h.picker.activeItems = [h.picker.items[0]] + h.source.settings.mockResolvedValue(description(2)) + h.refresh() + await vi.waitFor(() => expect(h.picker.items[0].namespace.revision).toBe(2)) + expect(h.picker.activeItems[0].namespace.revision).toBe(2) + h.picker.selectedItems = [h.picker.items[0]] + h.accept() + expect((await selected)?.revision).toBe(2) + expect(h.changed.size).toBe(0) + expect(h.picker.dispose).toHaveBeenCalledTimes(1) + }) + + it('does not allow a stale selection while reloading and drops callbacks after the picker closes', async () => { + const h = harness() + const selected = pickRuntimeSettingsNamespace(h.source) + await vi.waitFor(() => expect(h.picker.items).toHaveLength(1)) + h.picker.selectedItems = [h.picker.items[0]] + const pending = Promise.withResolvers() + h.source.settings.mockReturnValueOnce(pending.promise) + h.refresh(); h.accept() + expect(h.picker.dispose).not.toHaveBeenCalled() + h.hide() + expect(await selected).toBeUndefined() + pending.resolve(description(2)) + await pending.promise + expect(h.picker.items[0].namespace.revision).toBe(1) + expect(h.changed.size).toBe(0) + expect(h.picker.dispose).toHaveBeenCalledTimes(1) + }) + + it('coalesces invalidations while loading and does not expose the stale response', async () => { + const h = harness(), pending = Promise.withResolvers() + h.source.settings.mockReturnValueOnce(pending.promise).mockResolvedValue(description(3)) + const selected = pickRuntimeSettingsNamespace(h.source) + h.refresh(); h.refresh() + pending.resolve(description(1)) + await vi.waitFor(() => expect(h.picker.items[0]?.namespace.revision).toBe(3)) + expect(h.source.settings).toHaveBeenCalledTimes(2) + h.hide() + await selected + }) + + it('closes on a settings load failure without leaking subscriptions', async () => { + const h = harness() + h.source.settings.mockRejectedValue(new Error('not connected')) + await expect(pickRuntimeSettingsNamespace(h.source)).rejects.toThrow('not connected') + expect(h.changed.size).toBe(0) + expect(h.picker.dispose).toHaveBeenCalledTimes(1) + }) +}) From 4397dae988bb61d17a32d42dad6efafa15ad2bd2 Mon Sep 17 00:00:00 2001 From: Lixxx1 Date: Sat, 12 Sep 2026 10:13:13 +0800 Subject: [PATCH 05/14] fix: reconcile DSH queue state and restore shell result cards --- src/dsh-session-feed.ts | 28 ++++++++++++++++-- src/extension.ts | 22 ++++++++++---- src/tool-presentation.ts | 21 ++++++++++++-- src/webview.ts | 29 ++++++++++--------- tests/discovery-controller.spec.ts | 46 ++++++++++++++++++++++++++++++ tests/dsh-client.spec.ts | 41 ++++++++++++++++++++++++++ tests/tool-presentation.spec.ts | 42 ++++++++++++++++++++++++++- tests/webview.spec.ts | 43 ++++++++++++++++++++++++++++ 8 files changed, 248 insertions(+), 24 deletions(-) diff --git a/src/dsh-session-feed.ts b/src/dsh-session-feed.ts index 38f5352..e0535f1 100644 --- a/src/dsh-session-feed.ts +++ b/src/dsh-session-feed.ts @@ -12,6 +12,7 @@ interface Follow { lastSeq: number active: boolean pending: DshFrame[] + committedMessages: Set cancel: () => void reject: (error: Error) => void } @@ -93,7 +94,7 @@ export class DshSessionFeed { open(sessionId: string): Promise { this.closeFollow() return new Promise((resolve, reject) => { - const state: Follow = { sessionId, cursor: -1, lastSeq: -1, active: false, pending: [], cancel: () => {}, reject } + const state: Follow = { sessionId, cursor: -1, lastSeq: -1, active: false, pending: [], committedMessages: new Set(), cancel: () => {}, reject } this.follow = state const assistant = new DshAssistantStream( update => this.mux({ type: 'session/assistant-stream', sessionId, update }), @@ -115,6 +116,7 @@ export class DshSessionFeed { const events = decodeHistory(raw.records) const latest = events.at(-1)?.event.seq if (latest !== undefined && latest > state.cursor) throw new Error('History exceeds snapshot cursor.') + for (const entry of events) this.acceptQueuedMessage(state, entry) this.installProjections(sessionId, state.cursor, raw.projections.values) assistant.open(raw.assistantStream, state.cursor, events) opened = true @@ -122,7 +124,7 @@ export class DshSessionFeed { resolve({ events, hasMore: raw.hasMore, projections: this.projectionValues(sessionId), isCurrent: () => this.follow === state, activate: () => { if (this.follow !== state || state.active) return state.active = true - this.mux({ type: 'session/queue', sessionId, items: this.queues.get(sessionId) ?? [] }) + this.mux({ type: 'session/queue', sessionId, items: this.queueItems(sessionId) }) this.mux({ type: 'session/jobs', sessionId, jobs: this.jobs.get(sessionId) ?? [] }) for (const [key, projection] of this.projections.get(sessionId) ?? []) { this.mux({ type: 'session/projection', sessionId, key, value: projection.value }) @@ -136,6 +138,7 @@ export class DshSessionFeed { const entry = decodeHistory([raw])[0] if (entry === undefined || entry.event.seq !== state.lastSeq + 1) throw new Error('Non-contiguous DSH session events.') state.lastSeq = entry.event.seq + this.acceptQueuedMessage(state, entry) assistant.durable(entry) } }, error => { state.reject(error) }) @@ -218,7 +221,7 @@ export class DshSessionFeed { if (typeof id !== 'string') throw new Error('Invalid control session.') if (frame.type === 'queue' && Array.isArray(frame.items)) { this.queues.set(id, frame.items) - if (this.follow?.active) this.mux({ type: 'session/queue', sessionId: id, items: frame.items }) + if (this.follow?.active) this.mux({ type: 'session/queue', sessionId: id, items: this.queueItems(id) }) } else if (frame.type === 'jobs' && Array.isArray(frame.jobs)) { this.jobs.set(id, frame.jobs) if (this.follow?.active) this.mux({ type: 'session/jobs', sessionId: id, jobs: frame.jobs }) @@ -233,6 +236,25 @@ export class DshSessionFeed { } else throw new Error('Invalid control update.') } + private queueItems(sessionId: string): unknown[] { + const items = this.queues.get(sessionId) ?? [] + const state = this.follow + if (state?.sessionId !== sessionId || state.committedMessages.size === 0) return items + // Follow and control are independent streams. A late full queue snapshot + // must not resurrect a steering row already committed to this conversation. + return items.filter(item => !(wireRecord(item) && item.placement === 'steering' + && wireRecord(item.message) && typeof item.message.id === 'string' && state.committedMessages.has(item.message.id))) + } + + private acceptQueuedMessage(state: Follow, entry: HistoryEntry): void { + const { type, data } = entry.event + if (type !== 'user/message' || !wireRecord(data) || typeof data.id !== 'string') return + const before = this.queueItems(state.sessionId) + state.committedMessages.add(data.id) + const items = this.queueItems(state.sessionId) + if (state.active && before.length !== items.length) this.mux({ type: 'session/queue', sessionId: state.sessionId, items }) + } + private remoteEvent(frame: Record): void { if (frame.type === 'cancel' && typeof frame.eventId === 'string') { this.dismissQuestion(frame.eventId); return } if (frame.type === 'waterfall' && typeof frame.eventId === 'string' && typeof frame.agentId === 'string' diff --git a/src/extension.ts b/src/extension.ts index 783a2a2..aaf262c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -527,24 +527,30 @@ export class DshChatController implements vscode.Disposable { await this.requireClient().cancel(this._state.sessionId) } - async updateQueue(itemId: string, action: 'edit' | 'remove' | 'steer', text?: string): Promise { - if (this._state.sessionId === '') return + async updateQueue(sessionId: string, itemId: string, action: 'edit' | 'remove' | 'steer', text?: string): Promise { + if (sessionId === '' || sessionId !== this._state.sessionId || this._state.phase !== 'ready') { + throw new Error('The conversation changed. Use the queue in the current conversation.') + } + const item = this._state.queue.find(item => item.id === itemId) + if (item?.placement !== 'queued') throw new Error('This message is no longer queued.') + if (action === 'steer' && !this._state.running) throw new Error('Steering is available only while DeepSeek is running.') let request: QueueAction if (action === 'edit') { const replacement = text?.trim() if (replacement === undefined || replacement === '') throw new Error('Queued messages cannot be empty.') const original = this.queueRawText.get(itemId) + if (item.text === null || original === undefined) throw new Error('Messages with attachments cannot be edited.') request = { kind: 'edit', content: [{ type: 'text', - text: original === undefined ? replacement : replaceTextPreservingIdeContext(original, replacement), + text: replaceTextPreservingIdeContext(original, replacement), }], } } else { request = { kind: action } } - await this.requireClient().updateQueue(this._state.sessionId, itemId, request) + await this.requireClient().updateQueue(sessionId, itemId, request) } async selectModel(selection: ModelSelection): Promise { @@ -694,10 +700,14 @@ export class DshChatController implements vscode.Disposable { const client = this.requireClient() const loadGeneration = ++this.sessionLoadGeneration this.historyEntries = [] + this.queueRawText.clear() this.publish({ phase: 'loading', statusText: 'Loading project conversation…', sessionId, + queue: [], + approval: null, + question: null, hasMoreHistory: false, loadingHistory: false, }) @@ -1656,10 +1666,12 @@ class DshSurface implements vscode.Disposable { case 'cancel': await this.controller.cancel(); return case 'queue-action': if ( - typeof value.itemId === 'string' + typeof value.sessionId === 'string' + && typeof value.itemId === 'string' && (value.action === 'edit' || value.action === 'remove' || value.action === 'steer') ) { await this.controller.updateQueue( + value.sessionId, value.itemId, value.action, typeof value.text === 'string' ? value.text : undefined, diff --git a/src/tool-presentation.ts b/src/tool-presentation.ts index 1da2fb6..05656e7 100644 --- a/src/tool-presentation.ts +++ b/src/tool-presentation.ts @@ -62,6 +62,17 @@ export function writeCreated(event: DshEvent): boolean { return /^[^\n]*<\/path>\nfile<\/type>\n\nCreated file\n<\/content>$/.test(toolResultText(event)) } +/** Mirror the official Bash/PowerShell trailing status-marker contract on replay. */ +function shellResult(text: string): RecordValue { + const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) + if (signal?.[1] !== undefined) return { output: text.slice(0, signal.index), signal: signal[1] } + const exit = /\n\[exit code: (\d+)\]$/.exec(text) + if (exit?.[1] !== undefined && Number.isSafeInteger(Number(exit[1]))) { + return { output: text.slice(0, exit.index), exitCode: Number(exit[1]) } + } + return { output: text, exitCode: 0 } +} + export function presentToolCall(name: string, raw: unknown): RecordValue | undefined { const args = toolArguments(raw) if (args === undefined) return undefined @@ -71,7 +82,11 @@ export function presentToolCall(name: string, raw: unknown): RecordValue | undef if (name === 'read' && typeof args.file_path === 'string') return { card: 'generic', title: `Read ${args.file_path}`, locations: [{ path: args.file_path, line: typeof args.offset === 'number' ? args.offset : 1 }] } if ((name === 'bash' || name === 'pwsh') && typeof args.command === 'string') { - return { card: 'terminal', title: args.command, ...(typeof args.cwd === 'string' ? { cwd: args.cwd } : {}) } + if (args.run_in_background === true) return { card: 'generic', title: args.command, + ...(typeof args.description === 'string' ? { content: [{ type: 'text', text: args.description }] } : {}) } + const cwd = typeof args.workdir === 'string' ? args.workdir : args.cwd + return { card: 'terminal', title: args.command, ...(typeof cwd === 'string' ? { cwd } : {}), + ...(typeof args.description === 'string' ? { description: args.description } : {}) } } if ((name === 'grep' || name === 'glob') && typeof args.pattern === 'string') { return { card: 'generic', title: `${name === 'grep' ? 'Grep' : 'Glob'} ${args.pattern}` } @@ -123,6 +138,8 @@ export function presentToolResult(name: string, raw: unknown, event: DshEvent, f }) })) return { card: 'search', title: call?.title, shape: 'matches', files: meta.files, truncated: meta.truncated, total: meta.total } } - if (name === 'bash' || name === 'pwsh') return { card: 'terminal', title: call?.title, output: text } + if ((name === 'bash' || name === 'pwsh') && toolArguments(raw)?.run_in_background !== true) { + return { card: 'terminal', title: call?.title, ...shellResult(text) } + } return generic } diff --git a/src/webview.ts b/src/webview.ts index a9a2a7f..7f57d46 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -674,7 +674,8 @@ export function chatHtml(webview: vscode.Webview, deepseekMarkUri: vscode.Uri): if (card === 'terminal') { const cwd = string(callView && callView.cwd); if (cwd) body.append(node('div', 'result-title', cwd)); - appendPre(body, string(resultView && resultView.output) || message.rawResult || string(callView && callView.title)); + const output = resultView && typeof resultView.output === 'string' ? resultView.output : message.rawResult || string(callView && callView.title); + appendPre(body, output); if (resultView && (typeof resultView.exitCode === 'number' || resultView.signal)) body.append(node('div', '', resultView.signal ? 'Signal ' + resultView.signal : 'Exit ' + resultView.exitCode)); } else if (card === 'diff') { const paths = []; @@ -1371,13 +1372,15 @@ export function chatHtml(webview: vscode.Webview, deepseekMarkUri: vscode.Uri): elements.prompt.value = ''; commandIndex = 0; resetPrompt(); } function resetPrompt() { elements.prompt.placeholder = 'Ask DeepSeek about this project'; resizePrompt(); renderCommandMenu(); renderMentionMenu(); } - function postQueueAction(itemId, action, text) { - vscode.postMessage({ type: 'queue-action', itemId, action, ...(text === undefined ? {} : { text }) }); + function postQueueAction(sessionId, itemId, action, text) { + if (!state || state.phase !== 'ready' || state.sessionId !== sessionId) return; + vscode.postMessage({ type: 'queue-action', sessionId, itemId, action, ...(text === undefined ? {} : { text }) }); } function renderQueue(force) { - const queue = (state && state.queue || []).filter(item => item.placement === 'queued'); - if (queueEditing && !queue.some(item => item.id === queueEditing.id)) queueEditing = null; - const signature = JSON.stringify({ queue, running: state && state.running, editing: queueEditing }); + const sessionId = state && state.sessionId; + const queue = (state && state.phase === 'ready' && state.queue || []).filter(item => item.placement === 'queued'); + if (queueEditing && (queueEditing.sessionId !== sessionId || !queue.some(item => item.id === queueEditing.id))) queueEditing = null; + const signature = JSON.stringify({ sessionId, queue, running: state && state.running, editing: queueEditing }); if (!force && signature === queueRenderSignature) return; queueRenderSignature = signature; elements.queueDock.replaceChildren(); elements.queueDock.classList.toggle('hidden', queue.length === 0); if (!queue.length) return; @@ -1387,26 +1390,26 @@ export function chatHtml(webview: vscode.Webview, deepseekMarkUri: vscode.Uri): if (queueEditing && queueEditing.id === item.id) { const editor = node('textarea', 'queue-editor'); editor.value = queueEditing.text; editor.rows = 1; editor.setAttribute('aria-label', 'Edit queued message'); editor.addEventListener('input', () => { - queueEditing = { id: item.id, text: editor.value }; - queueRenderSignature = JSON.stringify({ queue, running: state && state.running, editing: queueEditing }); + queueEditing = { sessionId, id: item.id, text: editor.value }; + queueRenderSignature = JSON.stringify({ sessionId, queue, running: state && state.running, editing: queueEditing }); save.disabled = editor.value.trim() === ''; }); editor.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); queueEditing = null; renderQueue(true); return; } if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { - event.preventDefault(); const text = editor.value.trim(); if (!text) return; queueEditing = null; postQueueAction(item.id, 'edit', text); renderQueue(true); + event.preventDefault(); const text = editor.value.trim(); if (!text) return; queueEditing = null; postQueueAction(sessionId, item.id, 'edit', text); renderQueue(true); } }); const actions = node('div', 'queue-actions'); const save = node('button', 'queue-action', 'Save'); const cancelEdit = node('button', 'queue-action', 'Cancel'); - save.type = cancelEdit.type = 'button'; save.disabled = queueEditing.text.trim() === ''; save.addEventListener('click', () => { const text = editor.value.trim(); if (!text) return; queueEditing = null; postQueueAction(item.id, 'edit', text); renderQueue(true); }); + save.type = cancelEdit.type = 'button'; save.disabled = queueEditing.text.trim() === ''; save.addEventListener('click', () => { const text = editor.value.trim(); if (!text) return; queueEditing = null; postQueueAction(sessionId, item.id, 'edit', text); renderQueue(true); }); cancelEdit.addEventListener('click', () => { queueEditing = null; renderQueue(true); }); actions.append(save, cancelEdit); row.append(editor, actions); requestAnimationFrame(() => { editor.focus(); editor.setSelectionRange(editor.value.length, editor.value.length); }); } else { row.append(node('span', 'queue-preview', item.preview || 'Queued message')); const actions = node('div', 'queue-actions'); - const edit = node('button', 'queue-action', 'Edit'); edit.type = 'button'; edit.disabled = item.text === null; edit.title = item.text === null ? 'Messages with attachments cannot be edited' : 'Edit queued message'; edit.addEventListener('click', () => { if (item.text !== null) { queueEditing = { id: item.id, text: item.text }; renderQueue(true); } }); - const remove = node('button', 'queue-action', 'Delete'); remove.type = 'button'; remove.addEventListener('click', () => postQueueAction(item.id, 'remove')); - const steer = node('button', 'queue-action', 'Steer'); steer.type = 'button'; steer.disabled = !state.running; steer.title = state.running ? 'Apply this message to the current task now' : 'Steering is available only while DeepSeek is running'; steer.addEventListener('click', () => postQueueAction(item.id, 'steer')); + const edit = node('button', 'queue-action', 'Edit'); edit.type = 'button'; edit.disabled = item.text === null; edit.title = item.text === null ? 'Messages with attachments cannot be edited' : 'Edit queued message'; edit.addEventListener('click', () => { if (item.text !== null) { queueEditing = { sessionId, id: item.id, text: item.text }; renderQueue(true); } }); + const remove = node('button', 'queue-action', 'Delete'); remove.type = 'button'; remove.addEventListener('click', () => postQueueAction(sessionId, item.id, 'remove')); + const steer = node('button', 'queue-action', 'Steer'); steer.type = 'button'; steer.disabled = !state.running; steer.title = state.running ? 'Apply this message to the current task now' : 'Steering is available only while DeepSeek is running'; steer.addEventListener('click', () => postQueueAction(sessionId, item.id, 'steer')); actions.append(edit, remove, steer); row.append(actions); } elements.queueDock.append(row); diff --git a/tests/discovery-controller.spec.ts b/tests/discovery-controller.spec.ts index 7d92ed4..c371c80 100644 --- a/tests/discovery-controller.spec.ts +++ b/tests/discovery-controller.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { DshFrame, SessionModels, SkillDescriptor } from '../src/dsh-client.js' +import { withIdeContext } from '../src/ide-context.js' const mocks = vi.hoisted(() => ({ client: undefined as any })) vi.mock('../src/dsh-client.js', () => ({ DshClient: class { constructor() { return mocks.client } } })) @@ -35,6 +36,7 @@ async function harness() { listAgentPresets: vi.fn(async () => ({ presets: [{ id: 'standard', trust: 'system', isDefault: true }, { id: 'minimal', trust: 'system', isDefault: false }] })), selectModel: vi.fn(async () => ({})), settings: vi.fn(), mutateSettings: vi.fn(), pluginInventory: vi.fn(), selectAgentPreset: vi.fn(), prompt: vi.fn(async () => ({})), + updateQueue: vi.fn(async () => ({ accepted: true })), executeCommand: vi.fn(async () => ({ result: { kind: 'success' } })), } mocks.client = client @@ -52,6 +54,50 @@ async function harness() { } describe('sidebar discovery notifications', () => { + it('preserves captured IDE context on queue edits and rejects stale or attachment-only edits', async () => { + const h = await harness() + const original = withIdeContext('Original', { activeFile: { kind: 'file', path: 'app.ts' }, pinned: [], mentions: [] }) + h.emit({ type: 'session/queue', sessionId: 'a', items: [ + { id: 'text', placement: 'queued', message: { content: [{ type: 'text', text: original }] } }, + { id: 'image', placement: 'queued', message: { content: [{ type: 'image', name: 'test.png' }] } }, + { id: 'steering', placement: 'steering', message: { content: [{ type: 'text', text: 'Already steering' }] } }, + ] }, 'mux') + await h.controller.updateQueue('a', 'text', 'edit', 'Edited') + expect(h.client.updateQueue).toHaveBeenLastCalledWith('a', 'text', { + kind: 'edit', content: [{ type: 'text', text: original.replace('Original', 'Edited') }], + }) + await expect(h.controller.updateQueue('a', 'image', 'edit', 'Replace image')).rejects.toThrow('attachments') + await expect(h.controller.updateQueue('a', 'missing', 'remove')).rejects.toThrow('no longer queued') + await expect(h.controller.updateQueue('a', 'steering', 'edit', 'Too late')).rejects.toThrow('no longer queued') + await expect(h.controller.updateQueue('a', 'text', 'steer')).rejects.toThrow('only while') + expect(h.client.updateQueue).toHaveBeenCalledTimes(1) + h.emit({ type: 'host/session-status', sessionId: 'a', running: true }) + await h.controller.updateQueue('a', 'text', 'steer') + expect(h.client.updateQueue).toHaveBeenLastCalledWith('a', 'text', { kind: 'steer' }) + await h.controller.updateQueue('a', 'image', 'remove') + expect(h.client.updateQueue).toHaveBeenLastCalledWith('a', 'image', { kind: 'remove' }) + }) + + it('clears old interactive controls immediately and rejects old-session queue actions while loading or ready', async () => { + const h = await harness() + const items = [{ id: 'same-row', placement: 'queued', message: { content: [{ type: 'text', text: 'Original' }] } }] + h.emit({ type: 'session/queue', sessionId: 'a', items }, 'mux') + h.emit({ type: 'approval/requested', sessionId: 'a', approvalId: 'old', toolName: 'Write' }, 'mux') + const loading = Promise.withResolvers() + h.client.openSession.mockReturnValueOnce(loading.promise) + const switchSession = h.controller.selectSession('b') + expect(h.controller.state).toMatchObject({ phase: 'loading', sessionId: 'b', queue: [], approval: null, question: null }) + await expect(h.controller.updateQueue('a', 'same-row', 'remove')).rejects.toThrow('conversation changed') + await expect(h.controller.updateQueue('b', 'same-row', 'remove')).rejects.toThrow('conversation changed') + loading.resolve({ events: [], hasMore: false, projections: {}, isCurrent: () => true, activate() {} }) + await switchSession + h.emit({ type: 'session/queue', sessionId: 'b', items }, 'mux') + await expect(h.controller.updateQueue('a', 'same-row', 'remove')).rejects.toThrow('conversation changed') + expect(h.client.updateQueue).not.toHaveBeenCalled() + await h.controller.updateQueue('b', 'same-row', 'remove') + expect(h.client.updateQueue).toHaveBeenCalledExactlyOnceWith('b', 'same-row', { kind: 'remove' }) + }) + it('accepts the new command attachment capability without enabling attachments on other commands', async () => { const h = await harness() const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'YWJj' } diff --git a/tests/dsh-client.spec.ts b/tests/dsh-client.spec.ts index ad326ae..7faf693 100644 --- a/tests/dsh-client.spec.ts +++ b/tests/dsh-client.spec.ts @@ -379,6 +379,47 @@ describe('DSH 0.1.2 chat transport', () => { expect(h.frames.filter(f => f.payload.type === 'session/queue').at(-1)?.payload.items).toEqual([]) }) + it.each(['queue-first', 'event-first'] as const)('retires durable steering messages across %s delivery', async order => { + const h = harness() + await h.client.startStreams() + ;(await h.client.openSession('s')).activate() + const steering = { id: 'row', placement: 'steering', message: { id: 'message', content: [{ type: 'text', text: 'Change direction' }] } } + const queued = { id: 'next', placement: 'queued', message: { id: 'next-message', content: [{ type: 'text', text: 'Follow up' }] } } + const pushQueue = () => h.push('session/control', { type: 'queue', sessionId: 's', items: [steering, queued] }) + const pushMessage = () => h.push('session/follow', { type: 'event', event: { + seq: 6, time: 60, type: 'user/message', data: steering.message, + } }) + if (order === 'queue-first') { pushQueue(); pushMessage() } + else { pushMessage(); pushQueue() } + expect(h.frames.filter(f => f.payload.type === 'session/queue').at(-1)?.payload.items).toEqual([queued]) + expect(h.frames.filter(f => (f.payload.event as any)?.type === 'user/message')).toHaveLength(1) + // A later full control snapshot must not resurrect the same steering row. + pushQueue() + expect(h.frames.at(-1)?.payload.items).toEqual([queued]) + expect(h.errors).toEqual([]) + }) + + it('reconciles steering from both the opening history and buffered events before activation', async () => { + const h = harness(false) + await h.client.startStreams() + const row = (id: string) => ({ id: `row-${id}`, placement: 'steering', message: { id, content: [] } }) + h.push('session/control', { type: 'queue', sessionId: 's', items: [row('before'), row('after')] }) + const pending = h.client.openSession('s') + const user = (seq: number, id: string) => ({ type: 'event', event: { seq, time: seq, type: 'user/message', data: { id, content: [] } } }) + h.push('session/follow', { ...snapshot(), records: [user(5, 'before')] }) + const opening = await pending + h.push('session/follow', user(6, 'after')) + expect(h.frames).toEqual([]) + opening.activate() + expect(h.frames.find(f => f.payload.type === 'session/queue')?.payload.items).toEqual([]) + // IDs from a different session must not filter this session's queue. + const other = h.client.openSession('other') + h.push('session/follow', { ...snapshot('other'), records: [] }) + h.push('session/control', { type: 'queue', sessionId: 'other', items: [row('before')] }) + ;(await other).activate() + expect(h.frames.filter(f => f.payload.type === 'session/queue').at(-1)?.payload.items).toEqual([row('before')]) + }) + it('times out missing snapshots and cancels their logical stream', async () => { vi.useFakeTimers() const h = harness(false) diff --git a/tests/tool-presentation.spec.ts b/tests/tool-presentation.spec.ts index a4c7ce0..b786fca 100644 --- a/tests/tool-presentation.spec.ts +++ b/tests/tool-presentation.spec.ts @@ -64,7 +64,7 @@ describe('0.1.2 built-in tool presentation', () => { it('preserves shell output and does not interpret arbitrary plugin metadata as filesystem changes', () => { for (const name of ['bash', 'pwsh']) { expect(presentToolResult(name, { command: 'node app.js' }, result(undefined, 'output\n[exit code: 1]'), false)).toMatchObject({ - card: 'terminal', title: 'node app.js', output: 'output\n[exit code: 1]', + card: 'terminal', title: 'node app.js', output: 'output', exitCode: 1, }) } expect(presentToolResult('mcp__custom__edit', args, result({ diffs }), false)?.card).toBe('generic') @@ -72,4 +72,44 @@ describe('0.1.2 built-in tool presentation', () => { expect(fileMutation('edit', '{broken')).toBeUndefined() expect(diffsFromMeta({ diffs: [...diffs, { path: 'app.ts', newText: 'missing old text' }] })).toBeUndefined() }) + + it.each(['bash', 'pwsh'])('restores %s working directories and does not give background receipts an exit status', name => { + expect(presentToolCall(name, { command: 'run', workdir: 'C:\\project', cwd: 'old', description: 'Build project' })).toEqual({ + card: 'terminal', title: 'run', cwd: 'C:\\project', description: 'Build project', + }) + expect(presentToolCall(name, { command: 'run', cwd: '/legacy' })).toMatchObject({ cwd: '/legacy' }) + const args = { command: 'run server', run_in_background: true, description: 'Start local server' } + expect(presentToolCall(name, args)).toEqual({ card: 'generic', title: 'run server', content: [{ type: 'text', text: 'Start local server' }] }) + expect(presentToolResult(name, args, result(undefined, 'Started background job bash-1'), false)).toEqual({ + card: 'generic', title: 'run server', content: [{ type: 'text', text: 'Started background job bash-1' }], + }) + expect(presentToolResult(name, { command: 'bad' }, result(undefined, 'spawn failed'), true)).not.toHaveProperty('exitCode') + }) + + it.each(['bash', 'pwsh'])('recovers %s final exit/signal markers without stripping timeout or sandbox notices', name => { + const view = (text: string) => presentToolResult(name, { command: 'run' }, result(undefined, text), false) + expect(view('output')).toMatchObject({ output: 'output', exitCode: 0 }) + expect(view('out\n[exit code: 7]')).toMatchObject({ output: 'out', exitCode: 7 }) + expect(view('out\n[timed out after 10ms]\n[killed by signal: SIGTERM]')).toEqual({ + card: 'terminal', title: 'run', output: 'out\n[timed out after 10ms]', signal: 'SIGTERM', + }) + expect(view('out\n[sandbox: file access denied under read-only mode]\n[exit code: 1]')).toMatchObject({ + output: 'out\n[sandbox: file access denied under read-only mode]', exitCode: 1, + }) + expect(view('An embedded [exit code: 9]')).toMatchObject({ output: 'An embedded [exit code: 9]', exitCode: 0 }) + expect(view('out\n[exit code: 9]\nmore output')).toMatchObject({ output: 'out\n[exit code: 9]\nmore output', exitCode: 0 }) + }) + + it('keeps original shell output available while live and replayed terminal cards show the same status', () => { + const entries = [ + { type: 'tool/call', seq: 1, time: 1, data: { callId: 'call', name: 'bash', arguments: JSON.stringify({ command: 'run', workdir: '/project' }) } }, + result(undefined, 'failure output\n[exit code: 2]'), + ] + const live = new ConversationProjector(); entries.forEach(entry => live.apply(entry)) + const replay = new ConversationProjector(); replay.reset(entries) + expect(replay.messages()).toEqual(live.messages()) + expect(live.messages()[0]).toMatchObject({ + callView: { cwd: '/project' }, resultView: { output: 'failure output', exitCode: 2 }, rawResult: 'failure output\n[exit code: 2]', + }) + }) }) diff --git a/tests/webview.spec.ts b/tests/webview.spec.ts index 93678d9..5b9689b 100644 --- a/tests/webview.spec.ts +++ b/tests/webview.spec.ts @@ -22,6 +22,31 @@ describe('chat webview', () => { expect(html).toContain("href: 'https://github.com/Lixxx1/dsh-vscode'") }) + it('binds queue actions to the rendered conversation and blocks actions during loading', () => { + const webview = { cspSource: 'vscode-webview:' } as vscode.Webview + const mark = { toString: () => 'vscode-resource:/deepseek.svg' } as vscode.Uri + const html = chatHtml(webview, mark) + const start = html.indexOf('function postQueueAction(') + const end = html.indexOf('function renderQueue(', start) + expect(start).toBeGreaterThan(0) + expect(end).toBeGreaterThan(start) + const sent: unknown[] = [] + const state = { phase: 'ready', sessionId: 'a' } + const post = new Function('state', 'vscode', `${html.slice(start, end)}; return postQueueAction;`)(state, { postMessage: (value: unknown) => sent.push(value) }) + post('a', 'row', 'edit', 'Updated') + expect(sent).toEqual([{ type: 'queue-action', sessionId: 'a', itemId: 'row', action: 'edit', text: 'Updated' }]) + state.phase = 'loading' + post('a', 'row', 'remove') + state.phase = 'ready' + state.sessionId = 'b' + post('a', 'row', 'steer') + expect(sent).toHaveLength(1) + expect(html).toContain("postQueueAction(sessionId, item.id, 'remove')") + expect(html).toContain("postQueueAction(sessionId, item.id, 'steer')") + expect(html).toContain("postQueueAction(sessionId, item.id, 'edit', text)") + expect(html).toContain('queueEditing.sessionId !== sessionId') + }) + it('uses a searchable session center with official rename and archive actions', () => { const webview = { cspSource: 'vscode-webview:' } as vscode.Webview const mark = { toString: () => 'vscode-resource:/deepseek.svg' } as vscode.Uri @@ -88,6 +113,24 @@ describe('chat webview', () => { expect(script).toContain("target.textContent += continuation.textContent") }) + it('does not restore the raw exit marker when a terminal result has an empty output body', () => { + const webview = { cspSource: 'vscode-webview:' } as vscode.Webview + const mark = { toString: () => 'vscode-resource:/deepseek.svg' } as vscode.Uri + const script = /