diff --git a/CHANGELOG.md b/CHANGELOG.md index e970d16..5278bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Fixed + +- Upgraded the embedded Qoder Agent SDK so it stays compatible with qodercli + 1.1.81. + ## [1.0.17] - 2026-10-10 ### Changed diff --git a/package-lock.json b/package-lock.json index 0bdc774..ec001e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.38.6", "@modelcontextprotocol/sdk": "~1.32.1", - "@qoder-ai/qoder-agent-sdk": "^1.0.45", + "@qoder-ai/qoder-agent-sdk": "^1.0.58", "tslib": "^2.8.1" }, "devDependencies": { @@ -2954,9 +2954,9 @@ } }, "node_modules/@qoder-ai/qoder-agent-sdk": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.45.tgz", - "integrity": "sha512-SQFx702DxLiXpbWJw/CtzqG+QYWztRkxcdlS/Ai7Y1SJSesFEWfwMEE7xazoXU89pzMRzE0uvDvcBcB7w4fRCQ==", + "version": "1.0.58", + "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.58.tgz", + "integrity": "sha512-h726yBIXm08GQ9mZ9cLXJJWdVCiCt/ys6JlcJ2Ij7ipQZDS8yrQl0LxEGGJBv9k586oD9VjQMnbuUmK9HNnNVA==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -6832,37 +6832,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", diff --git a/package.json b/package.json index f93655f..ad7545b 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.38.6", "@modelcontextprotocol/sdk": "~1.32.1", - "@qoder-ai/qoder-agent-sdk": "^1.0.45", + "@qoder-ai/qoder-agent-sdk": "^1.0.58", "tslib": "^2.8.1" }, "overrides": { diff --git a/scripts/renderer-safe-unref.js b/scripts/renderer-safe-unref.js index 77c5588..422feac 100644 --- a/scripts/renderer-safe-unref.js +++ b/scripts/renderer-safe-unref.js @@ -82,6 +82,11 @@ const UNSAFE_TIMER_UNREF_PATTERNS = [ '\n closeTimeout.unref?.();' + '\n })', }, + { + name: 'qoder-sdk-daemon-heartbeat-watchdog', + pattern: /this\.heartbeatWatchdog\.unref\(\)/g, + replacement: 'this.heartbeatWatchdog.unref?.()', + }, ]; const TIMER_CALL_PREFIXES = ['setTimeout(', 'setInterval(']; @@ -143,7 +148,8 @@ function findUnsafeTimerUnrefSites(contents) { continue; } - const unrefMatch = contents.slice(callEnd + 1).match(/^\s*\.unref\(\)/); + const unrefMatch = contents.slice(callEnd + 1).match(/^\s*\.unref\(\)/) + ?? matchAssignedTimerUnref(contents, timerStart.startIndex, callEnd); if (unrefMatch) { const startIndex = timerStart.startIndex; const endIndex = callEnd + 1 + unrefMatch[0].length; @@ -162,6 +168,20 @@ function findUnsafeTimerUnrefSites(contents) { return matches; } +function matchAssignedTimerUnref(contents, timerStartIndex, callEnd) { + const assignment = contents + .slice(Math.max(0, timerStartIndex - 128), timerStartIndex) + .match(/([A-Za-z_$][A-Za-z0-9_$.]*)\s*=\s*$/); + if (!assignment) { + return null; + } + + const target = assignment[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return contents + .slice(callEnd + 1) + .match(new RegExp(`^\\s*,\\s*${target}\\.unref\\(\\)`)); +} + function findNextTimerCall(contents, startIndex) { let nextMatch = null; diff --git a/scripts/smoke-qoder-sdk.mjs b/scripts/smoke-qoder-sdk.mjs index 24909cf..025fa52 100644 --- a/scripts/smoke-qoder-sdk.mjs +++ b/scripts/smoke-qoder-sdk.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node import { + ProcessTransport, qodercliAuth, query, + shutdownDaemon, } from '@qoder-ai/qoder-agent-sdk'; /** Idle async input used for initialization-only SDK operations. */ @@ -38,6 +40,9 @@ async function main() { cwd: process.cwd(), model: 'auto', persistSession: false, + // The plugin pins the process carrier so the user's installed CLI runs; + // without this the SDK starts its bundled worker runtime instead. + transport: ProcessTransport.default, ...(cliPath ? { pathToQoderCLIExecutable: cliPath } : {}), }, }); @@ -59,6 +64,8 @@ async function main() { } finally { input.end(); await catalogQuery.close(); + // The daemon is process-wide and keeps Node alive until it is stopped. + await shutdownDaemon(); } } diff --git a/src/features/settings/ui/qoder-settings-tab.ts b/src/features/settings/ui/qoder-settings-tab.ts index 3526650..7545d9e 100644 --- a/src/features/settings/ui/qoder-settings-tab.ts +++ b/src/features/settings/ui/qoder-settings-tab.ts @@ -136,6 +136,7 @@ export function renderQoderCliPathControl( await view?.getTabManager()?.broadcastToAllTabs( (service) => Promise.resolve(service.cleanup()) ); + await qoderWorkspace.shutdownRuntime(); void qoderWorkspace.agentCatalog.refresh(); return true; }; @@ -196,6 +197,7 @@ export function renderQoderCliEditionControl( qoderWorkspace.cliResolver.reset(); await qoderWorkspace.pluginManager.loadPlugins(); await context.plugin.reloadConversationIndex(); + await qoderWorkspace.shutdownRuntime(); void qoderWorkspace.agentCatalog.refresh(); }); }); diff --git a/src/qoder/commands/probe-runtime-commands.ts b/src/qoder/commands/probe-runtime-commands.ts index e34597c..4f5f84d 100644 --- a/src/qoder/commands/probe-runtime-commands.ts +++ b/src/qoder/commands/probe-runtime-commands.ts @@ -18,6 +18,7 @@ import { import { sortThinkingEfforts } from '../models/model-catalog'; import type { QoderHostContext } from '../qoder-host-context'; import { createCustomSpawnFunction } from '../runtime/custom-spawn'; +import { buildDaemonLaunchOptions } from '../runtime/qoder-daemon-launch'; const STDERR_TAIL_LIMIT = 4_000; @@ -190,8 +191,7 @@ export function buildProbeOptions( cwd: vaultPath, abortController, auth: qodercliAuth(), - pathToQoderCLIExecutable: cliPath, - env: { ...process.env, PATH: enhancedPath }, + ...buildDaemonLaunchOptions(cliPath, enhancedPath), permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: resolveQoderSettingSources(qoderSettings.loadUserSettings), diff --git a/src/qoder/qoder-services.ts b/src/qoder/qoder-services.ts index 1bd6c1a..fb960fd 100644 --- a/src/qoder/qoder-services.ts +++ b/src/qoder/qoder-services.ts @@ -1,3 +1,5 @@ +import { shutdownDaemon } from '@qoder-ai/qoder-agent-sdk'; + import { getVaultPath } from '../core/fs/path'; import type { ChatRuntime } from '../core/runtime/chat-runtime'; import type { VaultFileAdapter } from '../core/storage/vault-file-adapter'; @@ -47,6 +49,12 @@ export interface QoderServices { historyService: QoderConversationHistoryService; taskResultInterpreter: QoderTaskResultInterpreter; loginService: QoderLoginService; + /** + * Stops the SDK's process-wide daemon. Required after CLI path/edition + * changes and login, because a running daemon keeps its original launch + * configuration and credentials. + */ + shutdownRuntime(): Promise; dispose(): void; createRuntime(): ChatRuntime; createTitleGenerationService(): QoderTitleGenerationService; @@ -111,12 +119,16 @@ export async function createQoderServices( const historyService = new QoderConversationHistoryService(); const taskResultInterpreter = new QoderTaskResultInterpreter(); + const shutdownRuntime = async (): Promise => { + await shutdownDaemon().catch(() => { + // Best-effort: a failed shutdown must not block settings/login flows. + }); + }; const loginService = new QoderLoginService(plugin, () => { if (disposed) return; - // Always return to idle once the post-login refresh settles; keeping - // 'succeeded' would render a stale "checking status" panel if the runtime - // later becomes authRequired again in this session. - void agentCatalog.refresh().then(() => { + // Credentials are written by the CLI after the daemon may already have + // started, so restart the daemon before re-probing the runtime. + void shutdownRuntime().then(() => agentCatalog.refresh()).then(() => { if (!disposed) loginService.reset(); }); }); @@ -134,8 +146,10 @@ export async function createQoderServices( historyService, taskResultInterpreter, loginService, + shutdownRuntime, dispose: () => { disposed = true; + void shutdownRuntime(); loginService.dispose(); if (startupRetryTimer !== null) { window.clearTimeout(startupRetryTimer); diff --git a/src/qoder/runtime/custom-spawn.ts b/src/qoder/runtime/custom-spawn.ts index 74f053a..3e9e8ce 100644 --- a/src/qoder/runtime/custom-spawn.ts +++ b/src/qoder/runtime/custom-spawn.ts @@ -2,28 +2,16 @@ import type { SpawnedProcess, SpawnOptions } from '@qoder-ai/qoder-agent-sdk'; import { type ChildProcess, spawn } from 'child_process'; import { cliPathRequiresNode, findNodeExecutable } from '../../core/env/environment'; +import { + CUSTOM_PROVIDER_CLI_ENV, + INTEGRATION_CLI_ENV, +} from './qoder-daemon-launch'; import { resolveWindowsCmdShimSpawnSpec, terminateSpawnedProcess, type WindowsCmdShimSpawnSpec, } from './windows-cmd-shim'; -/** - * SDK-spawned sessions run qodercli in its "sdk" runtime mode, where - * CLI-configured custom providers (self-defined base URLs) stay disabled - * unless this opt-in is present — without it the CLI hides their models. - */ -const CUSTOM_PROVIDER_CLI_ENV = { QODER_SDK_CUSTOM_BASE_URL_BYOK: '1' } as const; - -/** - * Identifies this host integration to the CLI, which reports it as the - * `integration_id` telemetry attribute. Only a UUIDv4 is accepted; any other - * value is silently dropped. - */ -const INTEGRATION_CLI_ENV = { - QODER_INTEGRATION_ID: '70457212-a449-4ae3-af1e-5fb9e00fafcf', -} as const; - /** * Spawn qodercli in Obsidian's Electron process. * diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index 7e32ed5..74864d3 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -198,7 +198,6 @@ export class QoderChatRuntime implements ChatRuntime { this.sessionManager.clearHistoryRebuild(); this.pendingForkSession = false; } - this.messageChannel?.setSessionId(event.sessionId); if (event.agents) { try { this.getAgentCatalog().applySessionAgents(event.agents); } catch { /* non-critical */ } } @@ -441,7 +440,6 @@ export class QoderChatRuntime implements ChatRuntime { this.messageChannel = new QoderMessageChannel(); if (resumeSessionId) { - this.messageChannel.setSessionId(resumeSessionId); this.sessionManager.setSessionId(resumeSessionId, this.getScopedSettings().model); } @@ -874,10 +872,14 @@ export class QoderChatRuntime implements ChatRuntime { if (shouldUsePersistent) { // Start persistent query if not running if (!this.persistentQuery && !this.shuttingDown) { + // Seed the launch config with this turn's external context so the + // first applyDynamicUpdates() does not restart a query whose daemon + // session is still opening. await this.startPersistentQuery( vaultPath, resolvedQoderPath, - this.sessionManager.getSessionId() ?? undefined + this.sessionManager.getSessionId() ?? undefined, + effectiveQueryOptions?.externalContextPaths ?? this.currentExternalContextPaths ); } @@ -1034,11 +1036,9 @@ export class QoderChatRuntime implements ChatRuntime { } private buildSDKUserMessage(prompt: string, images?: ImageAttachment[]): SDKUserMessage { - return buildQoderSDKUserMessage( - prompt, - this.sessionManager.getSessionId() || '', - images, - ); + // The daemon owns the session id and stamps it onto each message; sending + // a stale one is rejected with a session_id mismatch. + return buildQoderSDKUserMessage(prompt, images); } /** diff --git a/src/qoder/runtime/qoder-cold-start-query.ts b/src/qoder/runtime/qoder-cold-start-query.ts index e471481..d25884f 100644 --- a/src/qoder/runtime/qoder-cold-start-query.ts +++ b/src/qoder/runtime/qoder-cold-start-query.ts @@ -14,6 +14,7 @@ import { import type { QoderHostContext } from '../qoder-host-context'; import { extractAssistantText } from '../services/extract-assistant-text'; import { createCustomSpawnFunction } from './custom-spawn'; +import { buildDaemonLaunchOptions } from './qoder-daemon-launch'; export interface ColdStartQueryConfig { plugin: QoderHostContext; @@ -77,11 +78,7 @@ export async function runColdStartQuery( model: selectedModel, abortController: config.abortController, auth: qodercliAuth(), - pathToQoderCLIExecutable: resolvedQoderPath, - env: { - ...process.env, - PATH: enhancedPath, - }, + ...buildDaemonLaunchOptions(resolvedQoderPath, enhancedPath), permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: resolveQoderSettingSources(qoderSettings.loadUserSettings), diff --git a/src/qoder/runtime/qoder-daemon-launch.ts b/src/qoder/runtime/qoder-daemon-launch.ts new file mode 100644 index 0000000..e8c5cb1 --- /dev/null +++ b/src/qoder/runtime/qoder-daemon-launch.ts @@ -0,0 +1,51 @@ +import type { Options } from '@qoder-ai/qoder-agent-sdk'; +import { ProcessTransport } from '@qoder-ai/qoder-agent-sdk'; + +/** + * SDK-spawned sessions run qodercli in its "sdk" runtime mode, where + * CLI-configured custom providers (self-defined base URLs) stay disabled + * unless this opt-in is present — without it the CLI hides their models. + */ +export const CUSTOM_PROVIDER_CLI_ENV = { QODER_SDK_CUSTOM_BASE_URL_BYOK: '1' } as const; + +/** + * Identifies this host integration to the CLI, which reports it as the + * `integration_id` telemetry attribute. Only a UUIDv4 is accepted; any other + * value is silently dropped. + */ +export const INTEGRATION_CLI_ENV = { + QODER_INTEGRATION_ID: '70457212-a449-4ae3-af1e-5fb9e00fafcf', +} as const; + +export interface DaemonLaunchOptions { + pathToQoderCLIExecutable: string; + transport: Options['transport']; + env: Record; +} + +/** + * Builds the daemon-bound options shared by every Query in this host. + * + * The SDK owns the daemon process and ignores `spawnQoderCLIProcess` when it + * launches that process, so launch-critical settings (the CLI entry and its + * environment) must live in these options instead. The daemon is process-wide + * and rejects a Query whose launch options differ, so every query site must + * build them through this helper. + */ +export function buildDaemonLaunchOptions( + cliPath: string, + enhancedPath: string, + extraEnv?: Record, +): DaemonLaunchOptions { + return { + pathToQoderCLIExecutable: cliPath, + transport: ProcessTransport.default, + env: { + ...process.env, + ...extraEnv, + ...CUSTOM_PROVIDER_CLI_ENV, + ...INTEGRATION_CLI_ENV, + PATH: enhancedPath, + }, + }; +} diff --git a/src/qoder/runtime/qoder-message-channel.ts b/src/qoder/runtime/qoder-message-channel.ts index fd7e753..9cbdfda 100644 --- a/src/qoder/runtime/qoder-message-channel.ts +++ b/src/qoder/runtime/qoder-message-channel.ts @@ -27,7 +27,6 @@ export class QoderMessageChannel implements AsyncIterable { private turnActive = false; private closed = false; private resolveNext: ((value: IteratorResult) => void) | null = null; - private currentSessionId: string | null = null; private pendingSteerAborts = 0; private onWarning: (message: string) => void; @@ -35,10 +34,6 @@ export class QoderMessageChannel implements AsyncIterable { this.onWarning = onWarning; } - setSessionId(sessionId: string): void { - this.currentSessionId = sessionId; - } - isTurnActive(): boolean { return this.turnActive; } @@ -232,7 +227,6 @@ export class QoderMessageChannel implements AsyncIterable { content: pending.content, }, parent_tool_use_id: null, - session_id: this.currentSessionId || '', }; } } diff --git a/src/qoder/runtime/qoder-query-options-builder.ts b/src/qoder/runtime/qoder-query-options-builder.ts index c4cdf1f..c32819e 100644 --- a/src/qoder/runtime/qoder-query-options-builder.ts +++ b/src/qoder/runtime/qoder-query-options-builder.ts @@ -21,6 +21,7 @@ import { type SystemPromptSettings, } from '../prompt/main-agent'; import { createCustomSpawnFunction } from './custom-spawn'; +import { buildDaemonLaunchOptions } from './qoder-daemon-launch'; import { DISABLED_BUILTIN_SUBAGENTS, type PersistentQueryConfig, @@ -270,13 +271,8 @@ export class QueryOptionsBuilder { model, abortController, auth: qodercliAuth(), - pathToQoderCLIExecutable: ctx.cliPath, + ...buildDaemonLaunchOptions(ctx.cliPath, ctx.enhancedPath, ctx.customEnv), settingSources: resolveQoderSettingSources(qoderSettings.loadUserSettings), - env: { - ...process.env, - ...ctx.customEnv, - PATH: ctx.enhancedPath, - }, includePartialMessages: true, }; diff --git a/src/qoder/runtime/qoder-user-message-factory.ts b/src/qoder/runtime/qoder-user-message-factory.ts index fcf895a..338a43b 100644 --- a/src/qoder/runtime/qoder-user-message-factory.ts +++ b/src/qoder/runtime/qoder-user-message-factory.ts @@ -30,7 +30,6 @@ function buildUserContentBlocks(prompt: string, images?: ImageAttachment[]): Use export function buildQoderSDKUserMessage( prompt: string, - sessionId: string, images?: ImageAttachment[], ): SDKUserMessage { if (!images || images.length === 0) { @@ -41,7 +40,6 @@ export function buildQoderSDKUserMessage( content: prompt, }, parent_tool_use_id: null, - session_id: sessionId, uuid: randomUUID(), }; } @@ -53,7 +51,6 @@ export function buildQoderSDKUserMessage( content: buildUserContentBlocks(prompt, images), }, parent_tool_use_id: null, - session_id: sessionId, uuid: randomUUID(), }; } diff --git a/tests/__mocks__/qoder-agent-sdk.ts b/tests/__mocks__/qoder-agent-sdk.ts index 23dc8a0..e79ed58 100644 --- a/tests/__mocks__/qoder-agent-sdk.ts +++ b/tests/__mocks__/qoder-agent-sdk.ts @@ -60,6 +60,14 @@ export function qodercliAuth() { return { type: 'qodercli' } as const; } +export const ProcessTransport = { + default: { kind: 'process-transport' as const }, +}; + +export async function shutdownDaemon(): Promise { + // No daemon exists in unit tests. +} + export interface Settings { effortLevel?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; } diff --git a/tests/unit/features/settings/ui/qoder-settings-tab.test.ts b/tests/unit/features/settings/ui/qoder-settings-tab.test.ts index d226574..e4ab257 100644 --- a/tests/unit/features/settings/ui/qoder-settings-tab.test.ts +++ b/tests/unit/features/settings/ui/qoder-settings-tab.test.ts @@ -323,6 +323,7 @@ function createPlugin(overrides: Record = {}): any { pluginManager: { loadPlugins: jest.fn().mockResolvedValue(undefined), }, + shutdownRuntime: jest.fn().mockResolvedValue(undefined), }, saveSettings: mockSaveSettings, normalizeModelVariantSettings: jest.fn(() => false), diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index 3878654..4315ba7 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -309,6 +309,23 @@ describe('QoderChatRuntime', () => { expect(service.isPersistentQueryActive()).toBe(false); }); + it('seeds a newly started persistent query with the turn external context', async () => { + service.setSessionId('test-session'); + const startPersistentQuerySpy = jest + .spyOn(service as any, 'startPersistentQuery') + .mockResolvedValue(undefined); + + await collectChunks(queryText( + 'hello', + undefined, + undefined, + { externalContextPaths: ['/external/a'] }, + )); + + expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); + expect(startPersistentQuerySpy.mock.calls[0][3]).toEqual(['/external/a']); + }); + it('should close persistent query', () => { service.setSessionId('test-session'); service.closePersistentQuery('test reason'); @@ -916,16 +933,15 @@ describe('QoderChatRuntime', () => { type: 'user', message: { role: 'user', content: 'Hello Qoder' }, parent_tool_use_id: null, - session_id: '', uuid: expect.any(String), }); }); - it('should include session ID when available', () => { + it('omits the session ID so the daemon can stamp its own', () => { service.setSessionId('session-abc'); const message = (service as any).buildSDKUserMessage('Test'); - expect(message.session_id).toBe('session-abc'); + expect(message.session_id).toBeUndefined(); }); it('should build message with images', () => { diff --git a/tests/unit/qoder/runtime/qoder-daemon-launch.test.ts b/tests/unit/qoder/runtime/qoder-daemon-launch.test.ts new file mode 100644 index 0000000..28e048f --- /dev/null +++ b/tests/unit/qoder/runtime/qoder-daemon-launch.test.ts @@ -0,0 +1,36 @@ +import { ProcessTransport } from '@qoder-ai/qoder-agent-sdk'; + +import { + buildDaemonLaunchOptions, + CUSTOM_PROVIDER_CLI_ENV, + INTEGRATION_CLI_ENV, +} from '@/qoder/runtime/qoder-daemon-launch'; + +describe('buildDaemonLaunchOptions', () => { + it('pins the process carrier so the installed CLI is used', () => { + const options = buildDaemonLaunchOptions('/opt/qodercli', '/opt/bin'); + + expect(options.transport).toBe(ProcessTransport.default); + expect(options.pathToQoderCLIExecutable).toBe('/opt/qodercli'); + }); + + it('injects the SDK runtime opt-ins into the daemon environment', () => { + const options = buildDaemonLaunchOptions('/opt/qodercli', '/opt/bin'); + + expect(options.env).toMatchObject({ + ...CUSTOM_PROVIDER_CLI_ENV, + ...INTEGRATION_CLI_ENV, + PATH: '/opt/bin', + }); + }); + + it('merges caller env without letting it override PATH', () => { + const options = buildDaemonLaunchOptions('/opt/qodercli', '/opt/bin', { + CUSTOM_FLAG: '1', + PATH: '/ignored', + }); + + expect(options.env.CUSTOM_FLAG).toBe('1'); + expect(options.env.PATH).toBe('/opt/bin'); + }); +}); diff --git a/tests/unit/qoder/runtime/qoder-message-channel.test.ts b/tests/unit/qoder/runtime/qoder-message-channel.test.ts index 3d4f8c0..3b56403 100644 --- a/tests/unit/qoder/runtime/qoder-message-channel.test.ts +++ b/tests/unit/qoder/runtime/qoder-message-channel.test.ts @@ -65,7 +65,7 @@ describe('QoderMessageChannel', () => { }); describe('enqueue and iteration', () => { - it('merges queued text messages and stamps the session ID', async () => { + it('merges queued text messages without stamping a session ID', async () => { const iterator = channel[Symbol.asyncIterator](); const firstPromise = iterator.next(); @@ -76,12 +76,11 @@ describe('QoderMessageChannel', () => { channel.enqueue(createTextUserMessage('second')); channel.enqueue(createTextUserMessage('third')); - channel.setSessionId('session-abc'); channel.onTurnComplete(); const merged = await iterator.next(); expect(merged.value.message.content).toBe('second\n\nthird'); - expect(merged.value.session_id).toBe('session-abc'); + expect(merged.value.session_id).toBeUndefined(); expect(warnings).toHaveLength(0); }); diff --git a/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts b/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts index 0b2a36b..0d546ab 100644 --- a/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts +++ b/tests/unit/qoder/runtime/qoder-query-options-builder.test.ts @@ -1,4 +1,5 @@ import type { ModelPolicyContext } from '@qoder-ai/qoder-agent-sdk'; +import { ProcessTransport } from '@qoder-ai/qoder-agent-sdk'; import type { AppPluginManager } from '@/core/types/services'; import type { PermissionMode, QoderianSettings } from '@/core/types/settings'; @@ -199,3 +200,24 @@ describe('QueryOptionsBuilder memory', () => { expect(QueryOptionsBuilder.needsRestart(off, off)).toBe(false); }); }); + +describe('QueryOptionsBuilder daemon launch options', () => { + it('pins the process carrier and SDK env for every query shape', () => { + const persistent = QueryOptionsBuilder.buildPersistentQueryOptions( + createContext('default'), + ); + const coldStart = QueryOptionsBuilder.buildColdStartQueryOptions( + createColdStartContext(), + ); + + for (const options of [persistent, coldStart]) { + expect(options.transport).toBe(ProcessTransport.default); + expect(options.pathToQoderCLIExecutable).toBe('/test/qodercli'); + expect(options.env).toMatchObject({ + QODER_SDK_CUSTOM_BASE_URL_BYOK: '1', + QODER_INTEGRATION_ID: '70457212-a449-4ae3-af1e-5fb9e00fafcf', + PATH: '/test/bin', + }); + } + }); +}); diff --git a/tests/unit/scripts/renderer-safe-unref.test.ts b/tests/unit/scripts/renderer-safe-unref.test.ts index f3453ed..b893a22 100644 --- a/tests/unit/scripts/renderer-safe-unref.test.ts +++ b/tests/unit/scripts/renderer-safe-unref.test.ts @@ -87,6 +87,29 @@ describe('rendererSafeUnref helpers', () => { expect(result.contents.split('\n')).toHaveLength(input.split('\n').length); }); + it('patches the qoder-sdk daemon heartbeat watchdog shape', () => { + const input = 'this.heartbeatWatchdog=setInterval(()=>{' + + 'if(this.failure||this.stopping||!this.initialized)return;' + + '},e),this.heartbeatWatchdog.unref()'; + + expect(findUnsafeTimerUnrefSites(input)).toEqual([ + { + line: 1, + snippet: 'setInterval(()=>{' + + 'if(this.failure||this.stopping||!this.initialized)return;' + + '},e),this.heartbeatWatchdog.unref()', + }, + ]); + + const result = patchRendererUnsafeUnrefSites(input); + + expect(result.appliedPatches).toEqual([ + { name: 'qoder-sdk-daemon-heartbeat-watchdog', count: 1 }, + ]); + expect(result.contents).toContain('this.heartbeatWatchdog.unref?.()'); + expect(findUnsafeTimerUnrefSites(result.contents)).toEqual([]); + }); + it('reports remaining direct timer .unref() calls but ignores guarded usage', () => { const input = [ 'const timer = setTimeout(run, 1000);',