From da904d7262d4ef1a4d156ce41c0197c9dd2d3bd5 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 27 Aug 2026 21:00:17 +0800 Subject: [PATCH 1/2] feat(sdk): Add standalone session APIs Co-authored-by: Qwen-Coder --- ...026-08-27-standalone-pr4-typescript-sdk.md | 41 ++ packages/sdk-typescript/scripts/build.js | 4 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 288 +++++++++ .../src/daemon/DaemonSessionClient.ts | 125 +++- packages/sdk-typescript/src/daemon/index.ts | 26 + .../src/daemon/standalone-sessions.ts | 413 +++++++++++++ packages/sdk-typescript/src/index.ts | 27 + .../test/unit/DaemonClientStandalone.test.ts | 566 ++++++++++++++++++ .../test/unit/DaemonSessionClient.test.ts | 153 +++++ .../test/unit/daemon-public-surface.test.ts | 40 ++ 10 files changed, 1673 insertions(+), 10 deletions(-) create mode 100644 docs/plans/2026-08-27-standalone-pr4-typescript-sdk.md create mode 100644 packages/sdk-typescript/src/daemon/standalone-sessions.ts create mode 100644 packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts diff --git a/docs/plans/2026-08-27-standalone-pr4-typescript-sdk.md b/docs/plans/2026-08-27-standalone-pr4-typescript-sdk.md new file mode 100644 index 00000000000..7b1a7923dca --- /dev/null +++ b/docs/plans/2026-08-27-standalone-pr4-typescript-sdk.md @@ -0,0 +1,41 @@ +# Standalone PR4 TypeScript SDK Implementation Plan + +## Scope + +Build the TypeScript SDK surface for the `standalone_sessions_v1` daemon API introduced by PR #10179. This stage changes only `packages/sdk-typescript` plus this implementation plan. WebUI, WebShell, daemon routes, and standalone lifecycle semantics remain out of scope. + +The implementation is stacked on PR #10179 at `fbd3bf32bd207424e39bb7063728807f873d1668`. Before publication against `main`, rebase onto the merged PR3 result and re-audit the final route contract. + +## Public API + +- Add narrow standalone session, restored session, summary, lookup, list, working-directory, metadata, batch, and creation-recovery types. +- Add capability-gated `DaemonClient` methods for create, list, exact lookup, load, resume, repair, rename, export, archive, unarchive, and delete. +- Let create accept an optional caller UUID; otherwise generate a UUID before the request. Never retry the create request. +- On a structured `standalone_creation_outcome_unknown` response, malformed successful response, or transport-level unknown outcome, perform one exact lookup and throw an error containing the generated UUID and the observed recovery state. +- Add `DaemonSessionClient` standalone create/load/resume factories and store an explicit restore strategy. Reattach workspace sessions by cwd and standalone sessions through the dedicated route. +- Runtime-validate every new JSON response before exposing it to consumers. + +## Compatibility and failure behavior + +- Every standalone method first requires `standalone_sessions_v1`; an old daemon fails before any standalone route is called. +- Standalone request types cannot express `workspaceCwd`, source, scope, branch, or worktree overrides. +- Exact lookup preserves the daemon's `202 creating`, `200 existing`, and `404 standalone_session_not_found` contract. +- A definite HTTP rejection remains a `DaemonHttpError`. Only an unknown create outcome is wrapped with recovery context. +- Browser code uses `globalThis.crypto.randomUUID()` and introduces no Node-only import. +- Existing workspace methods and the default workspace restore behavior remain source-compatible. + +## Verification + +- Request-shape tests for every route, including query encoding and client identity headers. +- Capability-absence tests proving no standalone request is sent. +- Create tests for generated and caller UUIDs, canonical response identity, structured outcome unknown, transport timeout, malformed success, and `202/200/404` lookup recovery. +- Runtime-validation tests for malformed sessions, summaries, working-directory results, metadata, and batch results. +- `DaemonSessionClient` tests for standalone create/load/resume and standalone versus workspace reattach. +- Public export type checks, TypeScript package tests, typecheck, lint, formatting, Node/browser builds, repository build, and repository typecheck. + +## Audit decisions + +- Keep HTTP ownership in `DaemonClient` and session-bound recovery in `DaemonSessionClient`; do not add another standalone client object. +- Keep validators in one standalone-specific leaf module to avoid expanding the already-large general daemon type file and to keep runtime checks reusable without UI dependencies. +- Perform exactly one automatic exact lookup after an unknown create result. Do not poll, load, resume, or issue a second create automatically; the caller retains control over further recovery. +- Do not cache capability results in PR4. Existing SDK capability checks are live probes, and adding cache invalidation would broaden this stage. diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index a229c948c55..7776114dc38 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -115,7 +115,9 @@ const rootDir = join(__dirname, '..'); // main to 199KB merges within this headroom, so no further bump is needed. // Bumped from 206KB to 208KB for transcript block change summaries used to // avoid complete Web Shell projection on every streamed text update. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 208 * 1024; +// Bumped from 208KB to 215KB for the complete standalone-session lifecycle, +// response validation, and outcome-unknown recovery surface. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 215 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 8a4d7777a32..fde4df712ef 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -230,6 +230,35 @@ import type { DaemonUnarchiveSessionsResult, } from './types.js'; import { parseSseStream } from './sse.js'; +import { + DaemonStandaloneCreationOutcomeUnknownError, + STANDALONE_SESSIONS_CAPABILITY, + isStandaloneCreationOutcomeUnknown, + isStandaloneSessionNotFoundError, + parseArchiveStandaloneSessionsResult, + parseDeleteStandaloneSessionsResult, + parseRestoredStandaloneSession, + parseStandaloneDirectoryResult, + parseStandaloneListPage, + parseStandaloneLookup, + parseStandaloneMetadataResult, + parseStandaloneSession, + parseUnarchiveStandaloneSessionsResult, + type CreateStandaloneSessionOptions, + type DaemonArchiveStandaloneSessionsResult, + type DaemonDeleteStandaloneSessionsResult, + type DaemonRestoredStandaloneSession, + type DaemonStandaloneCreationRecovery, + type DaemonStandaloneDirectoryResult, + type DaemonStandaloneMetadataResult, + type DaemonStandaloneSession, + type DaemonStandaloneSessionListOptions, + type DaemonStandaloneSessionListPage, + type DaemonStandaloneSessionLookup, + type DaemonStandaloneSessionSummary, + type DaemonUnarchiveStandaloneSessionsResult, + type RestoreStandaloneSessionRequest, +} from './standalone-sessions.js'; const WORKSPACE_MEMORY_REMEMBER_PATH = '/workspace/memory/remember'; const WORKSPACE_MEMORY_FORGET_PATH = '/workspace/memory/forget'; @@ -2525,6 +2554,265 @@ export class DaemonClient { // -- Sessions ---------------------------------------------------------- + async createStandaloneSession( + options: CreateStandaloneSessionOptions = {}, + ): Promise { + await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY); + const { sessionId: requestedSessionId, ...request } = options; + const sessionId = ( + requestedSessionId ?? globalThis.crypto.randomUUID() + ).toLowerCase(); + try { + const response = await this.jsonRequest( + '/standalone/sessions', + 'POST /standalone/sessions', + { + method: 'POST', + body: { sessionId, ...request }, + mode: 'rest', + }, + ); + return parseStandaloneSession( + response, + 'POST /standalone/sessions', + sessionId, + ); + } catch (error) { + if ( + error instanceof DaemonHttpError && + !isStandaloneCreationOutcomeUnknown(error) + ) { + throw error; + } + const recovery = await this.recoverStandaloneCreation(sessionId); + throw new DaemonStandaloneCreationOutcomeUnknownError( + sessionId, + recovery, + error, + ); + } + } + + async listStandaloneSessions( + options: DaemonStandaloneSessionListOptions = {}, + ): Promise { + return (await this.listStandaloneSessionsPage(options)).sessions; + } + + async listStandaloneSessionsPage( + options: DaemonStandaloneSessionListOptions = {}, + ): Promise { + const query = new URLSearchParams(); + if (options.cursor !== undefined) query.set('cursor', options.cursor); + if (options.pageSize !== undefined) { + query.set('size', String(options.pageSize)); + } + if (options.archiveState !== undefined) { + query.set('archiveState', options.archiveState); + } + const queryString = query.toString(); + const suffix = queryString ? `?${queryString}` : ''; + return await this.standaloneJsonRequest( + `/standalone/sessions${suffix}`, + 'GET /standalone/sessions', + parseStandaloneListPage, + ); + } + + async getStandaloneSession( + sessionId: string, + ): Promise { + await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY); + return await this.getStandaloneSessionUnchecked(sessionId.toLowerCase()); + } + + async loadStandaloneSession( + sessionId: string, + request: RestoreStandaloneSessionRequest = {}, + clientId?: string, + ): Promise { + return await this.restoreStandaloneSession( + 'load', + sessionId, + request, + clientId, + ); + } + + async resumeStandaloneSession( + sessionId: string, + request: RestoreStandaloneSessionRequest = {}, + clientId?: string, + ): Promise { + return await this.restoreStandaloneSession( + 'resume', + sessionId, + request, + clientId, + ); + } + + async repairStandaloneSessionDirectory( + sessionId: string, + ): Promise { + const normalized = sessionId.toLowerCase(); + const route = 'POST /standalone/sessions/:id/repair-directory'; + return await this.standaloneJsonRequest( + `/standalone/sessions/${urlEncode(normalized)}/repair-directory`, + route, + (response) => parseStandaloneDirectoryResult(response, route, normalized), + { method: 'POST', body: {} }, + ); + } + + async renameStandaloneSession( + sessionId: string, + displayName: string, + clientId?: string, + ): Promise { + const normalized = sessionId.toLowerCase(); + const route = 'PATCH /standalone/sessions/:id/metadata'; + return await this.standaloneJsonRequest( + `/standalone/sessions/${urlEncode(normalized)}/metadata`, + route, + (response) => parseStandaloneMetadataResult(response, route, normalized), + { + method: 'PATCH', + body: { displayName }, + clientId, + }, + ); + } + + async exportStandaloneSession( + sessionId: string, + options: { format?: DaemonSessionExportFormat } = {}, + ): Promise { + await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY); + return await this.sessionExportRequest( + `/standalone/sessions/${urlEncode(sessionId.toLowerCase())}/export`, + 'GET /standalone/sessions/:id/export', + options, + ); + } + + async archiveStandaloneSessions( + sessionIds: string[], + ): Promise { + return await this.standaloneBatchRequest( + 'archive', + sessionIds, + parseArchiveStandaloneSessionsResult, + ); + } + + async unarchiveStandaloneSessions( + sessionIds: string[], + ): Promise { + return await this.standaloneBatchRequest( + 'unarchive', + sessionIds, + parseUnarchiveStandaloneSessionsResult, + ); + } + + async deleteStandaloneSessions( + sessionIds: string[], + ): Promise { + return await this.standaloneBatchRequest( + 'delete', + sessionIds, + parseDeleteStandaloneSessionsResult, + ); + } + + private async standaloneJsonRequest( + path: string, + route: string, + parse: (value: unknown, route: string) => T, + options: { + method?: string; + body?: unknown; + clientId?: string; + timeoutMs?: number; + } = {}, + ): Promise { + await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY); + const response = await this.jsonRequest(path, route, { + ...options, + mode: 'rest', + }); + return parse(response, route); + } + + private async standaloneBatchRequest( + action: 'archive' | 'unarchive' | 'delete', + sessionIds: string[], + parse: (value: unknown, route: string) => T, + ): Promise { + const route = `POST /standalone/sessions/${action}`; + return await this.standaloneJsonRequest( + `/standalone/sessions/${action}`, + route, + parse, + { + method: 'POST', + body: { + sessionIds: sessionIds.map((sessionId) => sessionId.toLowerCase()), + }, + }, + ); + } + + private async restoreStandaloneSession( + action: 'load' | 'resume', + sessionId: string, + request: RestoreStandaloneSessionRequest, + clientId?: string, + ): Promise { + const normalized = sessionId.toLowerCase(); + const route = `POST /standalone/sessions/:id/${action}`; + const { timeoutMs, ...body } = request; + return await this.standaloneJsonRequest( + `/standalone/sessions/${urlEncode(normalized)}/${action}`, + route, + (response) => parseRestoredStandaloneSession(response, route, normalized), + { + method: 'POST', + body, + clientId, + timeoutMs: this.resolveRestoreTimeoutMs(timeoutMs), + }, + ); + } + + private async getStandaloneSessionUnchecked( + sessionId: string, + ): Promise { + const route = 'GET /standalone/sessions/:id'; + const response = await this.jsonRequest( + `/standalone/sessions/${urlEncode(sessionId)}`, + route, + { mode: 'rest' }, + ); + return parseStandaloneLookup(response, route, sessionId); + } + + private async recoverStandaloneCreation( + sessionId: string, + ): Promise { + try { + const lookup = await this.getStandaloneSessionUnchecked(sessionId); + return 'state' in lookup + ? { state: 'creating', sessionId } + : { state: 'existing', session: lookup }; + } catch (error) { + return isStandaloneSessionNotFoundError(error) + ? { state: 'absent', sessionId } + : { state: 'unknown', sessionId, error }; + } + } + async createOrAttachSession( req: CreateSessionRequest, clientId?: string, diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index b1efc77d66a..5d0bd77f21c 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -59,6 +59,11 @@ import type { SessionMetadataResult, DaemonSessionPrInfo, } from './types.js'; +import type { + CreateStandaloneSessionOptions, + DaemonRestoredStandaloneSession, + RestoreStandaloneSessionRequest, +} from './standalone-sessions.js'; /** Compacted replay snapshot returned by the daemon on session load. */ export interface DaemonReplaySnapshot { @@ -124,6 +129,10 @@ export interface DaemonSessionClientOptions { maxPendingPromptsPerSession?: number | null; } +export type DaemonSessionRestoreStrategy = + | { kind: 'workspace'; workspaceCwd: string } + | { kind: 'standalone' }; + export interface DaemonSessionSubscribeOptions extends Omit< SubscribeOptions, @@ -159,6 +168,50 @@ function isSessionAttachmentReference( const MAX_ATTACHMENT_CACHE_BYTES = 32 * 1024 * 1024; const MAX_ATTACHMENT_CACHE_ENTRIES = 128; +function createStandaloneRestoredClient( + client: DaemonClient, + restored: DaemonRestoredStandaloneSession, + includeReplay: boolean, +): DaemonSessionClient { + const { + state, + hasActivePrompt, + compactedReplay, + liveJournal, + historyHasMore, + historyAnchorRecordId, + replayDegraded, + partial, + replayError, + lastEventId, + eventEpoch, + ...session + } = restored; + return new DaemonSessionClient({ + client, + session, + hasActivePrompt, + state, + lastEventId: lastEventId ?? 0, + eventEpoch, + ...(includeReplay + ? { + replaySnapshot: { + compactedReplay: compactedReplay ?? [], + liveJournal: liveJournal ?? [], + }, + replaySnapshotComplete: + Array.isArray(compactedReplay) && Array.isArray(liveJournal), + replayPartial: partial === true, + replayError, + historyHasMore, + historyAnchorRecordId, + replayDegraded, + } + : {}), + }); +} + /** * Session-scoped wrapper around `DaemonClient`. * @@ -173,6 +226,7 @@ const MAX_ATTACHMENT_CACHE_ENTRIES = 128; export class DaemonSessionClient { readonly client: DaemonClient; readonly session: DaemonSession; + readonly restoreStrategy: DaemonSessionRestoreStrategy; readonly state: DaemonSessionState; /** * Not `readonly`: {@link consumeReplaySnapshot} swaps it for an empty @@ -230,6 +284,11 @@ export class DaemonSessionClient { constructor(opts: DaemonSessionClientOptions) { this.client = opts.client; this.session = { ...opts.session }; + const context = (opts.session as { context?: { kind?: unknown } }).context; + this.restoreStrategy = + opts.session.sourceType === 'standalone' && context?.kind === 'standalone' + ? { kind: 'standalone' } + : { kind: 'workspace', workspaceCwd: opts.session.workspaceCwd }; this.state = { ...(opts.state ?? {}) }; this.hasActivePrompt = opts.hasActivePrompt ?? false; this.historyHasMore = opts.historyHasMore ?? false; @@ -382,6 +441,50 @@ export class DaemonSessionClient { }); } + static async createStandalone( + client: DaemonClient, + options: CreateStandaloneSessionOptions = {}, + ): Promise { + const session = await client.createStandaloneSession(options); + return new DaemonSessionClient({ + client, + session, + hasActivePrompt: session.hasActivePrompt, + lastEventId: 0, + eventEpoch: session.eventEpoch, + }); + } + + static async loadStandalone( + client: DaemonClient, + sessionId: string, + request: RestoreStandaloneSessionRequest = {}, + clientId?: string, + ): Promise { + const restored = await client.loadStandaloneSession( + sessionId, + request, + clientId, + ); + const result = createStandaloneRestoredClient(client, restored, true); + await result.hydrateReplaySnapshot(); + return result; + } + + static async resumeStandalone( + client: DaemonClient, + sessionId: string, + request: RestoreStandaloneSessionRequest = {}, + clientId?: string, + ): Promise { + const restored = await client.resumeStandaloneSession( + sessionId, + request, + clientId, + ); + return createStandaloneRestoredClient(client, restored, false); + } + get sessionId(): string { return this.session.sessionId; } @@ -600,15 +703,19 @@ export class DaemonSessionClient { private async reattach(): Promise { if (this.reattaching) return this.reattaching; // Send no clientId so the bridge issues a fresh registration rather than - // validating the stale one. Pass workspaceCwd explicitly: the daemon's - // restore path resolves the workspace key before its existing-session fast - // path, and that resolution rejects a missing/relative path. - this.reattaching = this.client - .resumeSession(this.sessionId, { workspaceCwd: this.workspaceCwd }) - .then((session) => { - // Refresh only the clientId; leave the SSE cursor and ACP state intact. - this.session.clientId = session.clientId; - }); + // validating the stale one. Keep the original context explicit: workspace + // restore resolves by cwd, while standalone restore must use its dedicated + // route and never fall back to the primary runtime. + const resume = + this.restoreStrategy.kind === 'standalone' + ? this.client.resumeStandaloneSession(this.sessionId) + : this.client.resumeSession(this.sessionId, { + workspaceCwd: this.restoreStrategy.workspaceCwd, + }); + this.reattaching = resume.then((session) => { + // Refresh only the clientId; leave the SSE cursor and ACP state intact. + this.session.clientId = session.clientId; + }); try { await this.reattaching; } finally { diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 54ceadbf002..01143665a48 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -61,8 +61,34 @@ export { DaemonSessionClient, type DaemonReplaySnapshot, type DaemonSessionClientOptions, + type DaemonSessionRestoreStrategy, type DaemonSessionSubscribeOptions, } from './DaemonSessionClient.js'; +export { + DaemonStandaloneCreationOutcomeUnknownError, + DaemonStandaloneProtocolError, + STANDALONE_SESSIONS_CAPABILITY, + isStandaloneCreationOutcomeUnknown, + isStandaloneSessionNotFoundError, + type CreateStandaloneSessionOptions, + type DaemonArchiveStandaloneSessionsResult, + type DaemonDeleteStandaloneSessionsResult, + type DaemonRestoredStandaloneSession, + type DaemonStandaloneBatchError, + type DaemonStandaloneCreationRecovery, + type DaemonStandaloneDirectoryResult, + type DaemonStandaloneFields, + type DaemonStandaloneMetadataResult, + type DaemonStandaloneSession, + type DaemonStandaloneSessionCreating, + type DaemonStandaloneSessionListOptions, + type DaemonStandaloneSessionListPage, + type DaemonStandaloneSessionLookup, + type DaemonStandaloneSessionSummary, + type DaemonStandaloneWorkingDirectory, + type DaemonUnarchiveStandaloneSessionsResult, + type RestoreStandaloneSessionRequest, +} from './standalone-sessions.js'; export { asKnownDaemonEvent, DAEMON_KNOWN_EVENT_TYPE_VALUES, diff --git a/packages/sdk-typescript/src/daemon/standalone-sessions.ts b/packages/sdk-typescript/src/daemon/standalone-sessions.ts new file mode 100644 index 00000000000..3745c40f566 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/standalone-sessions.ts @@ -0,0 +1,413 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DaemonHttpError } from './DaemonHttpError.js'; +import type { + DaemonApprovalMode, + DaemonRestoredSession, + DaemonSession, + DaemonSessionArchiveState, + DaemonSessionSummary, +} from './types.js'; + +export const STANDALONE_SESSIONS_CAPABILITY = 'standalone_sessions_v1'; + +export interface CreateStandaloneSessionOptions { + sessionId?: string; + modelServiceId?: string; + approvalMode?: DaemonApprovalMode; +} + +export interface RestoreStandaloneSessionRequest { + approvalMode?: DaemonApprovalMode; + historyPageSize?: number; + liveReplayMode?: 'full' | 'summary'; + hideInheritedHistory?: boolean; + timeoutMs?: number; +} + +export interface DaemonStandaloneWorkingDirectory { + state: 'ready' | 'recreated'; + warnings?: string[]; +} + +export interface DaemonStandaloneFields { + sourceType: 'standalone'; + context: { kind: 'standalone' }; + projectlessOutputDirectory: string; + workingDirectory: DaemonStandaloneWorkingDirectory; +} + +export type DaemonStandaloneSession = DaemonSession & DaemonStandaloneFields; + +export type DaemonRestoredStandaloneSession = DaemonRestoredSession & + DaemonStandaloneFields; + +export interface DaemonStandaloneSessionSummary extends DaemonSessionSummary { + sourceType: 'standalone'; + context: { kind: 'standalone' }; +} + +export interface DaemonStandaloneSessionCreating { + sessionId: string; + state: 'creating'; +} + +export type DaemonStandaloneSessionLookup = + | DaemonStandaloneSessionSummary + | DaemonStandaloneSessionCreating; + +export interface DaemonStandaloneSessionListOptions { + pageSize?: number; + cursor?: string; + archiveState?: DaemonSessionArchiveState; +} + +export interface DaemonStandaloneSessionListPage { + sessions: DaemonStandaloneSessionSummary[]; + nextCursor?: string; + liveMergeFailed?: boolean; + truncated?: boolean; +} + +export interface DaemonStandaloneDirectoryResult { + sessionId: string; + projectlessOutputDirectory: string; + workingDirectory: DaemonStandaloneWorkingDirectory; +} + +export interface DaemonStandaloneMetadataResult { + sessionId: string; + displayName: string; +} + +export interface DaemonStandaloneBatchError { + sessionId: string; + code: string; + message: string; +} + +export interface DaemonArchiveStandaloneSessionsResult { + archived: string[]; + alreadyArchived: string[]; + notFound: string[]; + errors: DaemonStandaloneBatchError[]; +} + +export interface DaemonUnarchiveStandaloneSessionsResult { + unarchived: string[]; + alreadyActive: string[]; + notFound: string[]; + errors: DaemonStandaloneBatchError[]; +} + +export interface DaemonDeleteStandaloneSessionsResult { + removed: string[]; + notFound: string[]; + errors: DaemonStandaloneBatchError[]; + fileCleanupPending: string[]; +} + +export type DaemonStandaloneCreationRecovery = + | { state: 'creating'; sessionId: string } + | { state: 'existing'; session: DaemonStandaloneSessionSummary } + | { state: 'absent'; sessionId: string } + | { state: 'unknown'; sessionId: string; error: unknown }; + +export class DaemonStandaloneProtocolError extends Error { + constructor( + readonly route: string, + detail: string, + ) { + super(`${route}: malformed standalone-session response (${detail})`); + this.name = 'DaemonStandaloneProtocolError'; + } +} + +export class DaemonStandaloneCreationOutcomeUnknownError extends Error { + constructor( + readonly sessionId: string, + readonly recovery: DaemonStandaloneCreationRecovery, + readonly originalError: unknown, + ) { + super( + `Standalone session creation outcome is unknown for ${sessionId}; inspect recovery before retrying.`, + ); + this.name = 'DaemonStandaloneCreationOutcomeUnknownError'; + } +} + +type JsonRecord = Record; + +function asRecord( + value: unknown, + route: string, + field = 'response', +): JsonRecord { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new DaemonStandaloneProtocolError(route, `expected ${field} object`); + } + return value as JsonRecord; +} + +function requireString( + value: JsonRecord, + field: string, + route: string, + allowEmpty = false, +): string { + const result = value[field]; + if (typeof result !== 'string' || (!allowEmpty && result.length === 0)) { + throw new DaemonStandaloneProtocolError(route, `expected ${field} string`); + } + return result; +} + +function requireStringArray( + value: JsonRecord, + field: string, + route: string, +): void { + const result = value[field]; + if ( + !Array.isArray(result) || + !result.every((item) => typeof item === 'string') + ) { + throw new DaemonStandaloneProtocolError(route, `expected ${field}[]`); + } +} + +function requireSessionId( + value: JsonRecord, + route: string, + expected?: string, +): string { + const sessionId = requireString(value, 'sessionId', route); + if (expected !== undefined && sessionId !== expected) { + throw new DaemonStandaloneProtocolError( + route, + `expected sessionId ${expected}, received ${sessionId}`, + ); + } + return sessionId; +} + +function validateContext(value: JsonRecord, route: string): void { + if (asRecord(value['context'], route, 'context')['kind'] !== 'standalone') { + throw new DaemonStandaloneProtocolError( + route, + 'expected standalone context', + ); + } +} + +function validateWorkingDirectory(value: unknown, route: string): void { + const directory = asRecord(value, route, 'workingDirectory'); + if (directory['state'] !== 'ready' && directory['state'] !== 'recreated') { + throw new DaemonStandaloneProtocolError( + route, + 'invalid workingDirectory.state', + ); + } + if (directory['warnings'] !== undefined) { + requireStringArray(directory, 'warnings', route); + } +} + +function validateStandaloneFields(value: JsonRecord, route: string): void { + if (value['sourceType'] !== 'standalone') { + throw new DaemonStandaloneProtocolError( + route, + 'expected standalone sourceType', + ); + } + validateContext(value, route); + requireString(value, 'projectlessOutputDirectory', route); + validateWorkingDirectory(value['workingDirectory'], route); +} + +export function parseStandaloneSession( + value: unknown, + route: string, + expectedSessionId?: string, +): DaemonStandaloneSession { + const session = asRecord(value, route); + requireSessionId(session, route, expectedSessionId); + requireString(session, 'workspaceCwd', route); + if (typeof session['attached'] !== 'boolean') { + throw new DaemonStandaloneProtocolError(route, 'expected attached boolean'); + } + validateStandaloneFields(session, route); + return session as unknown as DaemonStandaloneSession; +} + +export function parseRestoredStandaloneSession( + value: unknown, + route: string, + expectedSessionId: string, +): DaemonRestoredStandaloneSession { + const raw = asRecord(value, route); + parseStandaloneSession(raw, route, expectedSessionId); + asRecord(raw['state'], route, 'state'); + return raw as unknown as DaemonRestoredStandaloneSession; +} + +export function parseStandaloneSummary( + value: unknown, + route: string, + expectedSessionId?: string, +): DaemonStandaloneSessionSummary { + const summary = asRecord(value, route); + requireSessionId(summary, route, expectedSessionId); + requireString(summary, 'workspaceCwd', route); + if (summary['sourceType'] !== 'standalone') { + throw new DaemonStandaloneProtocolError( + route, + 'expected standalone sourceType', + ); + } + validateContext(summary, route); + return summary as unknown as DaemonStandaloneSessionSummary; +} + +export function parseStandaloneLookup( + value: unknown, + route: string, + expectedSessionId: string, +): DaemonStandaloneSessionLookup { + const lookup = asRecord(value, route); + if (lookup['state'] === 'creating') { + const sessionId = requireSessionId(lookup, route, expectedSessionId); + return { sessionId, state: 'creating' }; + } + return parseStandaloneSummary(lookup, route, expectedSessionId); +} + +export function parseStandaloneListPage( + value: unknown, + route: string, +): DaemonStandaloneSessionListPage { + const page = asRecord(value, route); + if (!Array.isArray(page['sessions'])) { + throw new DaemonStandaloneProtocolError(route, 'expected sessions[]'); + } + if ( + page['nextCursor'] !== undefined && + typeof page['nextCursor'] !== 'string' + ) { + throw new DaemonStandaloneProtocolError( + route, + 'expected nextCursor string', + ); + } + for (const field of ['liveMergeFailed', 'truncated']) { + if (page[field] !== undefined && typeof page[field] !== 'boolean') { + throw new DaemonStandaloneProtocolError( + route, + `expected ${field} boolean`, + ); + } + } + for (const session of page['sessions']) + parseStandaloneSummary(session, route); + return page as unknown as DaemonStandaloneSessionListPage; +} + +export function parseStandaloneDirectoryResult( + value: unknown, + route: string, + expectedSessionId: string, +): DaemonStandaloneDirectoryResult { + const result = asRecord(value, route); + requireSessionId(result, route, expectedSessionId); + requireString(result, 'projectlessOutputDirectory', route); + validateWorkingDirectory(result['workingDirectory'], route); + return result as unknown as DaemonStandaloneDirectoryResult; +} + +export function parseStandaloneMetadataResult( + value: unknown, + route: string, + expectedSessionId: string, +): DaemonStandaloneMetadataResult { + const result = asRecord(value, route); + requireSessionId(result, route, expectedSessionId); + requireString(result, 'displayName', route, true); + return result as unknown as DaemonStandaloneMetadataResult; +} + +function validateBatch( + value: unknown, + route: string, + fields: string[], +): JsonRecord { + const result = asRecord(value, route); + for (const field of fields) requireStringArray(result, field, route); + if (!Array.isArray(result['errors'])) { + throw new DaemonStandaloneProtocolError(route, 'expected errors[]'); + } + for (const item of result['errors']) { + const error = asRecord(item, route, 'batch error'); + for (const field of ['sessionId', 'code', 'message']) { + requireString(error, field, route); + } + } + return result; +} + +export function parseArchiveStandaloneSessionsResult( + value: unknown, + route: string, +): DaemonArchiveStandaloneSessionsResult { + return validateBatch(value, route, [ + 'archived', + 'alreadyArchived', + 'notFound', + ]) as unknown as DaemonArchiveStandaloneSessionsResult; +} + +export function parseUnarchiveStandaloneSessionsResult( + value: unknown, + route: string, +): DaemonUnarchiveStandaloneSessionsResult { + return validateBatch(value, route, [ + 'unarchived', + 'alreadyActive', + 'notFound', + ]) as unknown as DaemonUnarchiveStandaloneSessionsResult; +} + +export function parseDeleteStandaloneSessionsResult( + value: unknown, + route: string, +): DaemonDeleteStandaloneSessionsResult { + return validateBatch(value, route, [ + 'removed', + 'notFound', + 'fileCleanupPending', + ]) as unknown as DaemonDeleteStandaloneSessionsResult; +} + +export function isStandaloneSessionNotFoundError(error: unknown): boolean { + return ( + error instanceof DaemonHttpError && + error.status === 404 && + recordCode(error.body) === 'standalone_session_not_found' + ); +} + +export function isStandaloneCreationOutcomeUnknown(error: unknown): boolean { + return ( + error instanceof DaemonHttpError && + recordCode(error.body) === 'standalone_creation_outcome_unknown' + ); +} + +function recordCode(value: unknown): unknown { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record)['code'] + : undefined; +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 406b7abfaa4..f3fddd28393 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -365,6 +365,33 @@ export { type SubscribeOptions, } from './daemon/index.js'; +export { + DaemonStandaloneCreationOutcomeUnknownError, + DaemonStandaloneProtocolError, + STANDALONE_SESSIONS_CAPABILITY, + isStandaloneCreationOutcomeUnknown, + isStandaloneSessionNotFoundError, + type CreateStandaloneSessionOptions, + type DaemonArchiveStandaloneSessionsResult, + type DaemonDeleteStandaloneSessionsResult, + type DaemonRestoredStandaloneSession, + type DaemonSessionRestoreStrategy, + type DaemonStandaloneBatchError, + type DaemonStandaloneCreationRecovery, + type DaemonStandaloneDirectoryResult, + type DaemonStandaloneFields, + type DaemonStandaloneMetadataResult, + type DaemonStandaloneSession, + type DaemonStandaloneSessionCreating, + type DaemonStandaloneSessionListOptions, + type DaemonStandaloneSessionListPage, + type DaemonStandaloneSessionLookup, + type DaemonStandaloneSessionSummary, + type DaemonStandaloneWorkingDirectory, + type DaemonUnarchiveStandaloneSessionsResult, + type RestoreStandaloneSessionRequest, +} from './daemon/index.js'; + // Auth // surface. These were re-exported from `./daemon/index.js` but the // public SDK entry (this file) never re-exported them, so an diff --git a/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts b/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts new file mode 100644 index 00000000000..c45bf29c624 --- /dev/null +++ b/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts @@ -0,0 +1,566 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { DaemonClient } from '../../src/daemon/DaemonClient.js'; +import { DaemonHttpError } from '../../src/daemon/DaemonHttpError.js'; +import { + DaemonStandaloneCreationOutcomeUnknownError, + DaemonStandaloneProtocolError, +} from '../../src/daemon/standalone-sessions.js'; + +const SESSION_ID = '550e8400-e29b-41d4-a716-446655440000'; +const UPPER_SESSION_ID = SESSION_ID.toUpperCase(); + +interface CapturedRequest { + url: string; + method: string; + headers: Record; + body: string | null; + signal?: AbortSignal | null; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function capabilityResponse(enabled = true): Response { + return jsonResponse(200, { + v: 1, + mode: 'serve', + features: enabled ? ['standalone_sessions_v1'] : [], + }); +} + +function standaloneSummary(sessionId = SESSION_ID) { + return { + sessionId, + workspaceCwd: '/conversations', + sourceType: 'standalone', + context: { kind: 'standalone' }, + displayName: 'Standalone', + }; +} + +function standaloneSession(sessionId = SESSION_ID) { + return { + ...standaloneSummary(sessionId), + attached: false, + clientId: 'client-1', + projectlessOutputDirectory: '/conversations/conversation-hash', + workingDirectory: { state: 'ready' }, + }; +} + +function restoredStandaloneSession(sessionId = SESSION_ID) { + return { + ...standaloneSession(sessionId), + attached: true, + state: {}, + lastEventId: 4, + }; +} + +function recordingFetch( + reply: (req: CapturedRequest) => Response | Promise, +): { fetch: typeof globalThis.fetch; calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const fetchImpl = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const headers: Record = {}; + new Headers(init?.headers).forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + const request = { + url, + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : null, + ...(init?.signal ? { signal: init.signal } : {}), + }; + calls.push(request); + return reply(request); + }, + ) as unknown as typeof globalThis.fetch; + return { fetch: fetchImpl, calls }; +} + +describe('DaemonClient standalone sessions', () => { + it('gates standalone operations before calling their routes', async () => { + const { fetch, calls } = recordingFetch(() => capabilityResponse(false)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect(client.listStandaloneSessions()).rejects.toMatchObject({ + name: 'DaemonCapabilityMissingError', + capability: 'standalone_sessions_v1', + }); + expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ + '/capabilities', + ]); + }); + + it('generates the UUID before create and sends only standalone fields', async () => { + const { fetch, calls } = recordingFetch((request) => { + if (request.url.endsWith('/capabilities')) return capabilityResponse(); + const body = JSON.parse(request.body ?? '{}') as { sessionId: string }; + return jsonResponse(200, standaloneSession(body.sessionId)); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const created = await client.createStandaloneSession({ + modelServiceId: 'qwen-prod', + approvalMode: 'default', + }); + + const request = calls[1]; + const body = JSON.parse(request?.body ?? '{}') as Record; + expect(request).toMatchObject({ + url: 'http://daemon/standalone/sessions', + method: 'POST', + }); + expect(body).toEqual({ + sessionId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ), + modelServiceId: 'qwen-prod', + approvalMode: 'default', + }); + expect(body).not.toHaveProperty('cwd'); + expect(body).not.toHaveProperty('workspaceCwd'); + expect(created.sessionId).toBe(body['sessionId']); + }); + + it('canonicalizes a caller UUID and exercises the complete route family', async () => { + const { fetch, calls } = recordingFetch((request) => { + const url = new URL(request.url); + if (url.pathname === '/capabilities') return capabilityResponse(); + if ( + url.pathname === '/standalone/sessions' && + request.method === 'POST' + ) { + return jsonResponse(200, standaloneSession()); + } + if (url.pathname === '/standalone/sessions' && request.method === 'GET') { + return jsonResponse(200, { + sessions: [standaloneSummary()], + nextCursor: 'next', + }); + } + if ( + url.pathname === `/standalone/sessions/${SESSION_ID}` && + request.method === 'GET' + ) { + return jsonResponse(200, standaloneSummary()); + } + if (url.pathname.endsWith('/load')) { + return jsonResponse(200, restoredStandaloneSession()); + } + if (url.pathname.endsWith('/resume')) { + return jsonResponse(200, restoredStandaloneSession()); + } + if (url.pathname.endsWith('/repair-directory')) { + return jsonResponse(200, { + sessionId: SESSION_ID, + projectlessOutputDirectory: '/conversations/conversation-hash', + workingDirectory: { + state: 'recreated', + warnings: ['Directory was recreated.'], + }, + }); + } + if (url.pathname.endsWith('/metadata')) { + return jsonResponse(200, { + sessionId: SESSION_ID, + displayName: 'Renamed', + }); + } + if (url.pathname.endsWith('/export')) { + return new Response('# transcript', { + status: 200, + headers: { + 'content-type': 'text/markdown', + 'content-disposition': 'attachment; filename="session.md"', + }, + }); + } + if (url.pathname.endsWith('/archive')) { + return jsonResponse(200, { + archived: [SESSION_ID], + alreadyArchived: [], + notFound: [], + errors: [], + }); + } + if (url.pathname.endsWith('/unarchive')) { + return jsonResponse(200, { + unarchived: [SESSION_ID], + alreadyActive: [], + notFound: [], + errors: [], + }); + } + if (url.pathname.endsWith('/delete')) { + return jsonResponse(200, { + removed: [SESSION_ID], + notFound: [], + errors: [], + fileCleanupPending: [SESSION_ID], + }); + } + return jsonResponse(500, { error: `Unexpected ${request.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await client.createStandaloneSession({ sessionId: UPPER_SESSION_ID }); + await expect( + client.listStandaloneSessionsPage({ + pageSize: 25, + cursor: 'cursor', + archiveState: 'archived', + }), + ).resolves.toMatchObject({ nextCursor: 'next' }); + await expect( + client.getStandaloneSession(SESSION_ID), + ).resolves.toMatchObject({ context: { kind: 'standalone' } }); + await client.loadStandaloneSession( + SESSION_ID, + { + historyPageSize: 40, + liveReplayMode: 'summary', + hideInheritedHistory: true, + approvalMode: 'default', + timeoutMs: 5_000, + }, + 'client-load', + ); + await client.resumeStandaloneSession( + SESSION_ID, + { approvalMode: 'default' }, + 'client-resume', + ); + await client.repairStandaloneSessionDirectory(SESSION_ID); + await client.renameStandaloneSession(SESSION_ID, 'Renamed', 'client-meta'); + await expect( + client.exportStandaloneSession(SESSION_ID, { format: 'md' }), + ).resolves.toEqual({ + content: '# transcript', + filename: 'session.md', + mimeType: 'text/markdown', + format: 'md', + }); + await client.archiveStandaloneSessions([UPPER_SESSION_ID]); + await client.unarchiveStandaloneSessions([SESSION_ID]); + await client.deleteStandaloneSessions([SESSION_ID]); + + const routeCalls = calls.filter( + (call) => new URL(call.url).pathname !== '/capabilities', + ); + expect( + routeCalls.map((call) => ({ + method: call.method, + path: new URL(call.url).pathname, + query: new URL(call.url).search, + })), + ).toEqual([ + { method: 'POST', path: '/standalone/sessions', query: '' }, + { + method: 'GET', + path: '/standalone/sessions', + query: '?cursor=cursor&size=25&archiveState=archived', + }, + { method: 'GET', path: `/standalone/sessions/${SESSION_ID}`, query: '' }, + { + method: 'POST', + path: `/standalone/sessions/${SESSION_ID}/load`, + query: '', + }, + { + method: 'POST', + path: `/standalone/sessions/${SESSION_ID}/resume`, + query: '', + }, + { + method: 'POST', + path: `/standalone/sessions/${SESSION_ID}/repair-directory`, + query: '', + }, + { + method: 'PATCH', + path: `/standalone/sessions/${SESSION_ID}/metadata`, + query: '', + }, + { + method: 'GET', + path: `/standalone/sessions/${SESSION_ID}/export`, + query: '?format=md', + }, + { method: 'POST', path: '/standalone/sessions/archive', query: '' }, + { method: 'POST', path: '/standalone/sessions/unarchive', query: '' }, + { method: 'POST', path: '/standalone/sessions/delete', query: '' }, + ]); + expect(JSON.parse(routeCalls[0]?.body ?? '{}')).toEqual({ + sessionId: SESSION_ID, + }); + expect(JSON.parse(routeCalls[3]?.body ?? '{}')).toEqual({ + historyPageSize: 40, + liveReplayMode: 'summary', + hideInheritedHistory: true, + approvalMode: 'default', + }); + expect(routeCalls[3]?.headers['x-qwen-client-id']).toBe('client-load'); + expect(JSON.parse(routeCalls[4]?.body ?? '{}')).toEqual({ + approvalMode: 'default', + }); + expect(routeCalls[4]?.headers['x-qwen-client-id']).toBe('client-resume'); + expect(JSON.parse(routeCalls[5]?.body ?? '{}')).toEqual({}); + expect(JSON.parse(routeCalls[6]?.body ?? '{}')).toEqual({ + displayName: 'Renamed', + }); + expect(routeCalls[6]?.headers['x-qwen-client-id']).toBe('client-meta'); + expect(JSON.parse(routeCalls[8]?.body ?? '{}')).toEqual({ + sessionIds: [SESSION_ID], + }); + }); + + it('preserves exact lookup 202 creating responses', async () => { + const { fetch } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? capabilityResponse() + : jsonResponse(202, { sessionId: SESSION_ID, state: 'creating' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect(client.getStandaloneSession(SESSION_ID)).resolves.toEqual({ + sessionId: SESSION_ID, + state: 'creating', + }); + }); + + it('performs one exact lookup after a transport-level unknown outcome', async () => { + let createAttempts = 0; + const { fetch, calls } = recordingFetch((request) => { + const url = new URL(request.url); + if (url.pathname === '/capabilities') return capabilityResponse(); + if (url.pathname === '/standalone/sessions') { + createAttempts += 1; + throw new TypeError('connection reset'); + } + return jsonResponse(200, standaloneSummary()); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const error = await client + .createStandaloneSession({ sessionId: SESSION_ID }) + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(DaemonStandaloneCreationOutcomeUnknownError); + expect(error).toMatchObject({ + sessionId: SESSION_ID, + recovery: { + state: 'existing', + session: { sessionId: SESSION_ID }, + }, + }); + expect(createAttempts).toBe(1); + expect( + calls.filter( + (call) => + new URL(call.url).pathname === `/standalone/sessions/${SESSION_ID}`, + ), + ).toHaveLength(1); + }); + + it('recovers the generated UUID after a create transport timeout', async () => { + let createAttempts = 0; + const { fetch } = recordingFetch((request) => { + const url = new URL(request.url); + if (url.pathname === '/capabilities') return capabilityResponse(); + if (url.pathname === '/standalone/sessions') { + createAttempts += 1; + return new Promise((_resolve, reject) => { + request.signal?.addEventListener('abort', () => { + reject(request.signal?.reason); + }); + }); + } + return jsonResponse(200, standaloneSummary()); + }); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + fetchTimeoutMs: 10, + }); + + await expect( + client.createStandaloneSession({ sessionId: SESSION_ID }), + ).rejects.toMatchObject({ + name: 'DaemonStandaloneCreationOutcomeUnknownError', + sessionId: SESSION_ID, + recovery: { + state: 'existing', + session: { sessionId: SESSION_ID }, + }, + }); + expect(createAttempts).toBe(1); + }); + + it('maps structured unknown outcome to an exact creating recovery', async () => { + const { fetch } = recordingFetch((request) => { + const url = new URL(request.url); + if (url.pathname === '/capabilities') return capabilityResponse(); + if (url.pathname === '/standalone/sessions') { + return jsonResponse(500, { + code: 'standalone_creation_outcome_unknown', + sessionId: SESSION_ID, + error: 'Creation outcome is unknown.', + }); + } + return jsonResponse(202, { sessionId: SESSION_ID, state: 'creating' }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.createStandaloneSession({ sessionId: SESSION_ID }), + ).rejects.toMatchObject({ + name: 'DaemonStandaloneCreationOutcomeUnknownError', + sessionId: SESSION_ID, + recovery: { state: 'creating', sessionId: SESSION_ID }, + originalError: { status: 500 }, + }); + }); + + it('treats a malformed successful create as unknown and records 404 recovery', async () => { + const { fetch } = recordingFetch((request) => { + const url = new URL(request.url); + if (url.pathname === '/capabilities') return capabilityResponse(); + if (url.pathname === '/standalone/sessions') { + return jsonResponse(200, { + ...standaloneSession(), + context: { kind: 'workspace' }, + }); + } + return jsonResponse(404, { + code: 'standalone_session_not_found', + sessionId: SESSION_ID, + error: 'Not found.', + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const error = await client + .createStandaloneSession({ sessionId: SESSION_ID }) + .catch((reason: unknown) => reason); + + expect(error).toMatchObject({ + name: 'DaemonStandaloneCreationOutcomeUnknownError', + sessionId: SESSION_ID, + recovery: { state: 'absent', sessionId: SESSION_ID }, + originalError: { name: 'DaemonStandaloneProtocolError' }, + }); + }); + + it('does not recover or wrap a definite create rejection', async () => { + const { fetch, calls } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? capabilityResponse() + : jsonResponse(409, { + code: 'standalone_session_conflict', + sessionId: SESSION_ID, + error: 'Conflict.', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const error = await client + .createStandaloneSession({ sessionId: SESSION_ID }) + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(DaemonHttpError); + expect(error).not.toBeInstanceOf( + DaemonStandaloneCreationOutcomeUnknownError, + ); + expect(calls).toHaveLength(2); + }); + + it('rejects malformed list responses at runtime', async () => { + const { fetch } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? capabilityResponse() + : jsonResponse(200, { + sessions: [ + { + ...standaloneSummary(), + sourceType: 'workspace', + }, + ], + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect(client.listStandaloneSessions()).rejects.toBeInstanceOf( + DaemonStandaloneProtocolError, + ); + }); + + it('rejects malformed restore and directory responses at runtime', async () => { + let response: unknown = { + ...restoredStandaloneSession(), + state: 'invalid', + }; + const { fetch } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? capabilityResponse() + : jsonResponse(200, response), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.loadStandaloneSession(SESSION_ID), + ).rejects.toBeInstanceOf(DaemonStandaloneProtocolError); + + response = { + sessionId: SESSION_ID, + projectlessOutputDirectory: '/conversations/conversation-hash', + workingDirectory: { state: 'missing' }, + }; + await expect( + client.repairStandaloneSessionDirectory(SESSION_ID), + ).rejects.toBeInstanceOf(DaemonStandaloneProtocolError); + }); + + it('rejects malformed metadata and batch responses at runtime', async () => { + let response: unknown = { sessionId: SESSION_ID, displayName: 42 }; + const { fetch } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? capabilityResponse() + : jsonResponse(200, response), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.renameStandaloneSession(SESSION_ID, 'Renamed'), + ).rejects.toBeInstanceOf(DaemonStandaloneProtocolError); + + response = { + archived: [SESSION_ID], + alreadyArchived: [], + notFound: [], + errors: [{ sessionId: SESSION_ID, code: 42, message: 'bad' }], + }; + await expect( + client.archiveStandaloneSessions([SESSION_ID]), + ).rejects.toBeInstanceOf(DaemonStandaloneProtocolError); + }); +}); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 41c3f1acd26..a89b43eabd8 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -222,6 +222,94 @@ describe('DaemonSessionClient', () => { }); }); + it('creates a standalone session with an explicit restore strategy', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const { fetch, calls } = recordingFetch((req) => { + if (req.url.endsWith('/capabilities')) { + return jsonResponse(200, { + features: ['standalone_sessions_v1'], + }); + } + return jsonResponse(200, { + sessionId, + workspaceCwd: '/conversations', + attached: false, + clientId: 'client-1', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/conversations/conversation-hash', + workingDirectory: { state: 'ready' }, + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const session = await DaemonSessionClient.createStandalone(client, { + sessionId, + }); + + expect(session.sessionId).toBe(sessionId); + expect(session.restoreStrategy).toEqual({ kind: 'standalone' }); + expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ + '/capabilities', + '/standalone/sessions', + ]); + }); + + it('loads and resumes standalone sessions without a workspace selector', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const { fetch, calls } = recordingFetch((req) => { + if (req.url.endsWith('/capabilities')) { + return jsonResponse(200, { + features: ['standalone_sessions_v1'], + }); + } + return jsonResponse(200, { + sessionId, + workspaceCwd: '/conversations', + attached: true, + clientId: 'client-1', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/conversations/conversation-hash', + workingDirectory: { state: 'ready' }, + state: {}, + compactedReplay: [], + liveJournal: [], + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const loaded = await DaemonSessionClient.loadStandalone( + client, + sessionId, + { historyPageSize: 20 }, + 'client-load', + ); + const resumed = await DaemonSessionClient.resumeStandalone( + client, + sessionId, + {}, + 'client-resume', + ); + + expect(loaded.restoreStrategy).toEqual({ kind: 'standalone' }); + expect(loaded.replaySnapshotComplete).toBe(true); + expect(resumed.restoreStrategy).toEqual({ kind: 'standalone' }); + const restores = calls.filter((call) => + /\/(load|resume)$/u.test(new URL(call.url).pathname), + ); + expect(restores.map((call) => new URL(call.url).pathname)).toEqual([ + `/standalone/sessions/${sessionId}/load`, + `/standalone/sessions/${sessionId}/resume`, + ]); + expect(JSON.parse(restores[0]?.body ?? '{}')).toEqual({ + historyPageSize: 20, + }); + expect(JSON.parse(restores[1]?.body ?? '{}')).toEqual({}); + expect(restores[0]?.headers['x-qwen-client-id']).toBe('client-load'); + expect(restores[1]?.headers['x-qwen-client-id']).toBe('client-resume'); + }); + it('preserves active prompt state from createOrAttach responses', async () => { const { fetch } = recordingFetch(() => jsonResponse(200, { @@ -2907,6 +2995,71 @@ describe('DaemonSessionClient clientId self-heal', () => { expect(resumeReq?.body).toBe(JSON.stringify({ cwd: '/work/a' })); }); + it('re-registers standalone sessions through the dedicated resume route', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + let promptCalls = 0; + const { fetch, calls } = recordingFetch((req) => { + const path = new URL(req.url).pathname; + if (path === '/capabilities') { + return jsonResponse(200, { features: ['standalone_sessions_v1'] }); + } + if (path === `/standalone/sessions/${sessionId}/resume`) { + return jsonResponse(200, { + sessionId, + workspaceCwd: '/conversations', + attached: true, + clientId: 'client-2', + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: '/conversations/conversation-hash', + workingDirectory: { state: 'ready' }, + state: {}, + }); + } + if (path === `/session/${sessionId}/prompt`) { + promptCalls += 1; + if (promptCalls === 1) { + return jsonResponse(400, { + code: 'invalid_client_id', + error: 'unknown client', + sessionId, + clientId: 'client-1', + }); + } + return jsonResponse(200, { stopReason: 'end_turn' }); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId, + workspaceCwd: '/conversations', + attached: true, + clientId: 'client-1', + sourceType: 'standalone', + context: { kind: 'standalone' }, + }, + }); + + await expect( + session.prompt({ prompt: [{ type: 'text', text: 'hi' }] }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + + const resume = calls.find((call) => + new URL(call.url).pathname.endsWith('/resume'), + ); + expect(resume?.body).toBe('{}'); + expect(resume?.headers['x-qwen-client-id']).toBeUndefined(); + expect( + calls.some((call) => + new URL(call.url).pathname.endsWith(`/session/${sessionId}/resume`), + ), + ).toBe(false); + expect(session.clientId).toBe('client-2'); + }); + it('re-registers and retries attachment upload, removal, and hydration', async () => { let resumeCalls = 0; const attempts = new Map(); diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index 750a98b27d5..46714973c48 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -20,6 +20,20 @@ import { // gap that two-layer SDK re-exports are easy to drift on. import type { DaemonClient, + CreateStandaloneSessionOptions, + DaemonArchiveStandaloneSessionsResult, + DaemonDeleteStandaloneSessionsResult, + DaemonRestoredStandaloneSession, + DaemonSessionRestoreStrategy, + DaemonStandaloneCreationRecovery, + DaemonStandaloneDirectoryResult, + DaemonStandaloneMetadataResult, + DaemonStandaloneSession, + DaemonStandaloneSessionListOptions, + DaemonStandaloneSessionListPage, + DaemonStandaloneSessionLookup, + DaemonStandaloneSessionSummary, + RestoreStandaloneSessionRequest, WorkspaceDaemonClient, DaemonClientEvictedData, DaemonClientEvictedEvent, @@ -180,6 +194,32 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => { // failure mode caught for PR-21 auth surface). expect(typeof Public.isWorkspaceScopedBudgetEvent).toBe('function'); expect('projectChatRecordsToDaemonTranscript' in Public).toBe(false); + expect(Public.STANDALONE_SESSIONS_CAPABILITY).toBe( + 'standalone_sessions_v1', + ); + expect(typeof Public.isStandaloneSessionNotFoundError).toBe('function'); + expect(typeof Public.isStandaloneCreationOutcomeUnknown).toBe('function'); + expect(typeof Public.DaemonStandaloneProtocolError).toBe('function'); + expect(typeof Public.DaemonStandaloneCreationOutcomeUnknownError).toBe( + 'function', + ); + }); + + it('exports standalone session SDK types from the package entry', () => { + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); }); it('round-trips a raw DaemonEvent through the public narrow helper', () => { From f9860e54572dfb548ef685d53c1973b9c288525e Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 28 Aug 2026 00:35:11 +0800 Subject: [PATCH 2/2] fix(sdk): address PR review feedback (#10294) Co-authored-by: Qwen-Coder --- .../src/daemon/standalone-sessions.ts | 5 +- .../test/unit/DaemonClientStandalone.test.ts | 172 ++++++++++++++++-- .../test/unit/DaemonSessionClient.test.ts | 104 ++++++++++- .../test/unit/daemon-public-surface.test.ts | 10 + 4 files changed, 271 insertions(+), 20 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/standalone-sessions.ts b/packages/sdk-typescript/src/daemon/standalone-sessions.ts index 3745c40f566..71687fe2f3d 100644 --- a/packages/sdk-typescript/src/daemon/standalone-sessions.ts +++ b/packages/sdk-typescript/src/daemon/standalone-sessions.ts @@ -401,8 +401,9 @@ export function isStandaloneSessionNotFoundError(error: unknown): boolean { export function isStandaloneCreationOutcomeUnknown(error: unknown): boolean { return ( - error instanceof DaemonHttpError && - recordCode(error.body) === 'standalone_creation_outcome_unknown' + error instanceof DaemonStandaloneCreationOutcomeUnknownError || + (error instanceof DaemonHttpError && + recordCode(error.body) === 'standalone_creation_outcome_unknown') ); } diff --git a/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts b/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts index c45bf29c624..c02b32abbfb 100644 --- a/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts @@ -10,6 +10,7 @@ import { DaemonHttpError } from '../../src/daemon/DaemonHttpError.js'; import { DaemonStandaloneCreationOutcomeUnknownError, DaemonStandaloneProtocolError, + isStandaloneCreationOutcomeUnknown, } from '../../src/daemon/standalone-sessions.js'; const SESSION_ID = '550e8400-e29b-41d4-a716-446655440000'; @@ -111,6 +112,41 @@ describe('DaemonClient standalone sessions', () => { ]); }); + it.each([ + ['get', (client: DaemonClient) => client.getStandaloneSession(SESSION_ID)], + [ + 'export', + (client: DaemonClient) => client.exportStandaloneSession(SESSION_ID), + ], + ])( + 'gates standalone %s before calling its route', + async (_name, operation) => { + const { fetch, calls } = recordingFetch(() => capabilityResponse(false)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect(operation(client)).rejects.toMatchObject({ + name: 'DaemonCapabilityMissingError', + capability: 'standalone_sessions_v1', + }); + expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ + '/capabilities', + ]); + }, + ); + + it('omits optional list query parameters when no options are provided', async () => { + const { fetch, calls } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? capabilityResponse() + : jsonResponse(200, { sessions: [] }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await client.listStandaloneSessionsPage(); + + expect(calls[1]?.url).toBe('http://daemon/standalone/sessions'); + }); + it('generates the UUID before create and sends only standalone fields', async () => { const { fetch, calls } = recordingFetch((request) => { if (request.url.endsWith('/capabilities')) return capabilityResponse(); @@ -232,10 +268,10 @@ describe('DaemonClient standalone sessions', () => { }), ).resolves.toMatchObject({ nextCursor: 'next' }); await expect( - client.getStandaloneSession(SESSION_ID), + client.getStandaloneSession(UPPER_SESSION_ID), ).resolves.toMatchObject({ context: { kind: 'standalone' } }); await client.loadStandaloneSession( - SESSION_ID, + UPPER_SESSION_ID, { historyPageSize: 40, liveReplayMode: 'summary', @@ -246,14 +282,18 @@ describe('DaemonClient standalone sessions', () => { 'client-load', ); await client.resumeStandaloneSession( - SESSION_ID, + UPPER_SESSION_ID, { approvalMode: 'default' }, 'client-resume', ); - await client.repairStandaloneSessionDirectory(SESSION_ID); - await client.renameStandaloneSession(SESSION_ID, 'Renamed', 'client-meta'); + await client.repairStandaloneSessionDirectory(UPPER_SESSION_ID); + await client.renameStandaloneSession( + UPPER_SESSION_ID, + 'Renamed', + 'client-meta', + ); await expect( - client.exportStandaloneSession(SESSION_ID, { format: 'md' }), + client.exportStandaloneSession(UPPER_SESSION_ID, { format: 'md' }), ).resolves.toEqual({ content: '# transcript', filename: 'session.md', @@ -261,8 +301,8 @@ describe('DaemonClient standalone sessions', () => { format: 'md', }); await client.archiveStandaloneSessions([UPPER_SESSION_ID]); - await client.unarchiveStandaloneSessions([SESSION_ID]); - await client.deleteStandaloneSessions([SESSION_ID]); + await client.unarchiveStandaloneSessions([UPPER_SESSION_ID]); + await client.deleteStandaloneSessions([UPPER_SESSION_ID]); const routeCalls = calls.filter( (call) => new URL(call.url).pathname !== '/capabilities', @@ -348,6 +388,49 @@ describe('DaemonClient standalone sessions', () => { }); }); + it('rejects an exact lookup response for a different session id', async () => { + const { fetch } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? capabilityResponse() + : jsonResponse( + 200, + standaloneSummary('550e8400-e29b-41d4-a716-446655440001'), + ), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.getStandaloneSession(SESSION_ID), + ).rejects.toBeInstanceOf(DaemonStandaloneProtocolError); + }); + + it('honors a standalone restore timeout override', async () => { + vi.useFakeTimers(); + try { + let signal: AbortSignal | undefined; + const { fetch } = recordingFetch((request) => { + if (request.url.endsWith('/capabilities')) return capabilityResponse(); + signal = request.signal ?? undefined; + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal?.reason), { + once: true, + }); + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const outcome = client + .loadStandaloneSession(SESSION_ID, { timeoutMs: 25 }) + .catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(24); + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(await outcome).toMatchObject({ name: 'TimeoutError' }); + } finally { + vi.useRealTimers(); + } + }); + it('performs one exact lookup after a transport-level unknown outcome', async () => { let createAttempts = 0; const { fetch, calls } = recordingFetch((request) => { @@ -366,6 +449,7 @@ describe('DaemonClient standalone sessions', () => { .catch((reason: unknown) => reason); expect(error).toBeInstanceOf(DaemonStandaloneCreationOutcomeUnknownError); + expect(isStandaloneCreationOutcomeUnknown(error)).toBe(true); expect(error).toMatchObject({ sessionId: SESSION_ID, recovery: { @@ -382,20 +466,53 @@ describe('DaemonClient standalone sessions', () => { ).toHaveLength(1); }); + it('reports unknown recovery when the exact lookup also fails', async () => { + const { fetch } = recordingFetch((request) => { + const url = new URL(request.url); + if (url.pathname === '/capabilities') return capabilityResponse(); + if (url.pathname === '/standalone/sessions') { + throw new TypeError('connection reset'); + } + return jsonResponse(500, { + code: 'lookup_failed', + error: 'Unavailable.', + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const error = await client + .createStandaloneSession({ sessionId: SESSION_ID }) + .catch((reason: unknown) => reason); + + expect(error).toMatchObject({ + name: 'DaemonStandaloneCreationOutcomeUnknownError', + sessionId: SESSION_ID, + recovery: { + state: 'unknown', + sessionId: SESSION_ID, + error: { name: 'DaemonHttpError', status: 500 }, + }, + }); + }); + it('recovers the generated UUID after a create transport timeout', async () => { let createAttempts = 0; + let generatedSessionId: string | undefined; const { fetch } = recordingFetch((request) => { const url = new URL(request.url); if (url.pathname === '/capabilities') return capabilityResponse(); if (url.pathname === '/standalone/sessions') { createAttempts += 1; + generatedSessionId = ( + JSON.parse(request.body ?? '{}') as { sessionId: string } + ).sessionId; return new Promise((_resolve, reject) => { request.signal?.addEventListener('abort', () => { reject(request.signal?.reason); }); }); } - return jsonResponse(200, standaloneSummary()); + return jsonResponse(200, standaloneSummary(generatedSessionId)); }); const client = new DaemonClient({ baseUrl: 'http://daemon', @@ -403,16 +520,25 @@ describe('DaemonClient standalone sessions', () => { fetchTimeoutMs: 10, }); - await expect( - client.createStandaloneSession({ sessionId: SESSION_ID }), - ).rejects.toMatchObject({ + const error = await client + .createStandaloneSession() + .catch((reason: unknown) => reason); + + expect(error).toMatchObject({ name: 'DaemonStandaloneCreationOutcomeUnknownError', - sessionId: SESSION_ID, + sessionId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ), recovery: { state: 'existing', - session: { sessionId: SESSION_ID }, + session: { sessionId: expect.any(String) }, }, }); + expect(error).toHaveProperty('sessionId', generatedSessionId); + expect(error).toHaveProperty( + 'recovery.session.sessionId', + generatedSessionId, + ); expect(createAttempts).toBe(1); }); @@ -562,5 +688,23 @@ describe('DaemonClient standalone sessions', () => { await expect( client.archiveStandaloneSessions([SESSION_ID]), ).rejects.toBeInstanceOf(DaemonStandaloneProtocolError); + + response = { + unarchived: [SESSION_ID], + alreadyActive: [], + notFound: [], + }; + await expect( + client.unarchiveStandaloneSessions([SESSION_ID]), + ).rejects.toBeInstanceOf(DaemonStandaloneProtocolError); + + response = { + removed: [SESSION_ID], + notFound: [], + errors: [], + }; + await expect( + client.deleteStandaloneSessions([SESSION_ID]), + ).rejects.toBeInstanceOf(DaemonStandaloneProtocolError); }); }); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index a89b43eabd8..8f0f9c04ee1 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -230,6 +230,9 @@ describe('DaemonSessionClient', () => { features: ['standalone_sessions_v1'], }); } + if (requestPathEndsWith(req, `/session/${sessionId}/events`)) { + return sseResponse(''); + } return jsonResponse(200, { sessionId, workspaceCwd: '/conversations', @@ -239,6 +242,7 @@ describe('DaemonSessionClient', () => { context: { kind: 'standalone' }, projectlessOutputDirectory: '/conversations/conversation-hash', workingDirectory: { state: 'ready' }, + eventEpoch: 'epoch-created', }); }); const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); @@ -249,10 +253,16 @@ describe('DaemonSessionClient', () => { expect(session.sessionId).toBe(sessionId); expect(session.restoreStrategy).toEqual({ kind: 'standalone' }); + expect(session.eventEpoch).toBe('epoch-created'); + for await (const _event of session.events()) { + /* empty */ + } expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ '/capabilities', '/standalone/sessions', + `/session/${sessionId}/events`, ]); + expect(calls[2]?.headers['last-event-id']).toBe('0'); }); it('loads and resumes standalone sessions without a workspace selector', async () => { @@ -263,6 +273,16 @@ describe('DaemonSessionClient', () => { features: ['standalone_sessions_v1'], }); } + if (req.url.endsWith(`/session/${sessionId}/attachments/media-1`)) { + return new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + } + if (requestPathEndsWith(req, `/session/${sessionId}/events`)) { + return sseResponse(''); + } + const loading = req.url.endsWith('/load'); return jsonResponse(200, { sessionId, workspaceCwd: '/conversations', @@ -272,9 +292,33 @@ describe('DaemonSessionClient', () => { context: { kind: 'standalone' }, projectlessOutputDirectory: '/conversations/conversation-hash', workingDirectory: { state: 'ready' }, - state: {}, - compactedReplay: [], - liveJournal: [], + state: loading ? { mode: 'loaded' } : { mode: 'resumed' }, + hasActivePrompt: true, + ...(loading + ? { + lastEventId: 42, + eventEpoch: 'epoch-loaded', + compactedReplay: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'image', + attachmentId: 'media-1', + mimeType: 'image/png', + size: 3, + }, + }, + }, + ], + replayDegraded: true, + partial: true, + replayError: 'journal read failed', + } + : { eventEpoch: 'epoch-resumed' }), }); }); const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); @@ -293,8 +337,24 @@ describe('DaemonSessionClient', () => { ); expect(loaded.restoreStrategy).toEqual({ kind: 'standalone' }); - expect(loaded.replaySnapshotComplete).toBe(true); + expect(loaded.state).toEqual({ mode: 'loaded' }); + expect(loaded.hasActivePrompt).toBe(true); + expect(loaded.lastEventId).toBe(42); + expect(loaded.eventEpoch).toBe('epoch-loaded'); + expect(loaded.replaySnapshotComplete).toBe(false); + expect(loaded.replayPartial).toBe(true); + expect(loaded.replayError).toBe('journal read failed'); + expect(loaded.replayDegraded).toBe(true); + expect(loaded.replaySnapshot.compactedReplay[0]?.data).toEqual({ + sessionUpdate: 'user_message_chunk', + content: { type: 'image', data: 'AQID', mimeType: 'image/png' }, + }); expect(resumed.restoreStrategy).toEqual({ kind: 'standalone' }); + expect(resumed.state).toEqual({ mode: 'resumed' }); + expect(resumed.hasActivePrompt).toBe(true); + expect(resumed.lastEventId).toBe(0); + expect(resumed.eventEpoch).toBe('epoch-resumed'); + expect(resumed.replaySnapshotComplete).toBe(false); const restores = calls.filter((call) => /\/(load|resume)$/u.test(new URL(call.url).pathname), ); @@ -308,6 +368,42 @@ describe('DaemonSessionClient', () => { expect(JSON.parse(restores[1]?.body ?? '{}')).toEqual({}); expect(restores[0]?.headers['x-qwen-client-id']).toBe('client-load'); expect(restores[1]?.headers['x-qwen-client-id']).toBe('client-resume'); + expect( + calls.filter((call) => call.url.endsWith('/attachments/media-1')), + ).toHaveLength(1); + + for await (const _event of loaded.events()) { + /* empty */ + } + for await (const _event of resumed.events()) { + /* empty */ + } + const eventCalls = calls.filter((call) => + requestPathEndsWith(call, `/session/${sessionId}/events`), + ); + expect(eventCalls.map((call) => call.headers['last-event-id'])).toEqual([ + '42', + '0', + ]); + }); + + it('uses workspace restore when standalone source metadata is incomplete', () => { + const { fetch } = recordingFetch(() => jsonResponse(500, {})); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + sourceType: 'standalone', + }, + }); + + expect(session.restoreStrategy).toEqual({ + kind: 'workspace', + workspaceCwd: '/work/a', + }); }); it('preserves active prompt state from createOrAttach responses', async () => { diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index 46714973c48..5827a5a597d 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -25,14 +25,19 @@ import type { DaemonDeleteStandaloneSessionsResult, DaemonRestoredStandaloneSession, DaemonSessionRestoreStrategy, + DaemonStandaloneBatchError, DaemonStandaloneCreationRecovery, DaemonStandaloneDirectoryResult, + DaemonStandaloneFields, DaemonStandaloneMetadataResult, DaemonStandaloneSession, + DaemonStandaloneSessionCreating, DaemonStandaloneSessionListOptions, DaemonStandaloneSessionListPage, DaemonStandaloneSessionLookup, DaemonStandaloneSessionSummary, + DaemonStandaloneWorkingDirectory, + DaemonUnarchiveStandaloneSessionsResult, RestoreStandaloneSessionRequest, WorkspaceDaemonClient, DaemonClientEvictedData, @@ -217,7 +222,12 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => { expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); });