From 9aed926cda818cfb35dcb376212e14b8d3ffd41e Mon Sep 17 00:00:00 2001 From: mesalogo Date: Tue, 8 Sep 2026 01:09:14 +0800 Subject: [PATCH] fix: stabilize OpenCode reuse and session switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check cached embedded servers before reuse, isolate concurrent cancellation, and serialize request MCP registration and cleanup. Preserve native controls across session switches and recover usable partial inventories. Share the bundled offline OpenCode configuration with desktop and Agent launch paths, including model bridge environment forwarding. Validation: focused tests and typecheck passed; lint passed before the final fixes. The full test run exceeded 120 seconds and reported a DS stdio MCP failure. Current-source Agent host and real suspend/resume validation remain pending. Release note: 修复切换会话后 OpenCode 原生控件消失及并发取消相互影响的问题,并完善内嵌服务复用检查和桌面、Agent 的离线依赖配置。 --- build/opencode-config.cjs | 149 + build/remote-runtime-bundle.cjs | 1017 +- build/runtime-hooks.cjs | 334 +- docs/features/assistant-workbar/README.md | 2 +- docs/features/assistant-workbar/progress.md | 18 + src/agent-daemon/model-bridge-helper.ts | 20 + src/agent-daemon/model-bridge.test.ts | 90 +- src/agent-daemon/opencode-runtime-profile.ts | 240 +- src/main/agent/opencode-runtime.test.ts | 5658 ++++--- src/main/agent/opencode-runtime.ts | 3671 ++-- src/renderer/src/App.test.tsx | 14885 ++++++++--------- src/renderer/src/App.tsx | 13475 ++++++++------- 12 files changed, 19417 insertions(+), 20142 deletions(-) create mode 100644 build/opencode-config.cjs diff --git a/build/opencode-config.cjs b/build/opencode-config.cjs new file mode 100644 index 00000000..60445c91 --- /dev/null +++ b/build/opencode-config.cjs @@ -0,0 +1,149 @@ +const { existsSync } = require("node:fs"); +const { + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} = require("node:fs"); +const { join } = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const opencodeVersion = "1.18.29"; +const opencodePluginPackage = "@opencode-ai/plugin"; + +function npmInvocation() { + const npmCli = process.env.npm_execpath; + if (npmCli) { + return { command: process.execPath, prefixArgs: [npmCli] }; + } + if (process.platform === "win32") { + throw new Error("npm_execpath is required to prepare OpenCode config"); + } + return { command: "npm", prefixArgs: [] }; +} + +function pluginIntegrity(projectDir) { + const lock = JSON.parse( + readFileSync(join(projectDir, "package-lock.json"), "utf8"), + ); + const entry = lock.packages?.[`node_modules/${opencodePluginPackage}`]; + if ( + entry?.version !== opencodeVersion || + typeof entry.integrity !== "string" + ) { + throw new Error( + `Missing locked ${opencodePluginPackage}@${opencodeVersion} integrity`, + ); + } + return entry.integrity; +} + +function prepareBundledOpenCodeConfig(projectDir) { + const targetDirectory = join( + projectDir, + ".runtime-resources", + "opencode-config", + ); + const identity = { + packageName: opencodePluginPackage, + version: opencodeVersion, + integrity: pluginIntegrity(projectDir), + }; + const markerPath = join(targetDirectory, ".goodbuddy-ready.json"); + try { + const ready = JSON.parse(readFileSync(markerPath, "utf8")); + if ( + ready.packageName === identity.packageName && + ready.version === identity.version && + ready.integrity === identity.integrity && + statSync( + join( + targetDirectory, + "node_modules", + "@opencode-ai", + "plugin", + "package.json", + ), + ).isFile() + ) { + return targetDirectory; + } + } catch { + // Rebuild an incomplete or stale offline config template. + } + + const stagingDirectory = `${targetDirectory}.staging-${process.pid}`; + rmSync(stagingDirectory, { recursive: true, force: true }); + mkdirSync(stagingDirectory, { recursive: true }); + try { + writeFileSync( + join(stagingDirectory, "package.json"), + `${JSON.stringify( + { + private: true, + dependencies: { + [opencodePluginPackage]: opencodeVersion, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + writeFileSync(join(stagingDirectory, ".gitignore"), "node_modules\n"); + const npm = npmInvocation(); + const environment = { ...process.env }; + delete environment.NODE_TLS_REJECT_UNAUTHORIZED; + const result = spawnSync( + npm.command, + [ + ...npm.prefixArgs, + "install", + "--ignore-scripts", + "--omit=dev", + "--omit=optional", + "--no-audit", + "--no-fund", + "--save-exact", + ], + { + cwd: stagingDirectory, + encoding: "utf8", + env: environment, + maxBuffer: 16 * 1024 * 1024, + shell: false, + windowsHide: true, + }, + ); + if (result.status !== 0) { + throw new Error( + `Unable to prepare bundled OpenCode config: ${ + result.error?.message || + result.stderr || + result.stdout || + `npm exited with ${result.status}` + }`, + ); + } + writeFileSync( + join(stagingDirectory, ".goodbuddy-ready.json"), + `${JSON.stringify(identity)}\n`, + "utf8", + ); + rmSync(targetDirectory, { recursive: true, force: true }); + mkdirSync(join(targetDirectory, ".."), { recursive: true }); + renameSync(stagingDirectory, targetDirectory); + return targetDirectory; + } finally { + if (existsSync(stagingDirectory)) { + rmSync(stagingDirectory, { recursive: true, force: true }); + } + } +} + +module.exports = { + opencodeVersion, + prepareBundledOpenCodeConfig, +}; diff --git a/build/remote-runtime-bundle.cjs b/build/remote-runtime-bundle.cjs index e913a3eb..dc8fb5a1 100644 --- a/build/remote-runtime-bundle.cjs +++ b/build/remote-runtime-bundle.cjs @@ -1,12 +1,8 @@ -const { - createHash, - createPublicKey, - sign, - verify -} = require('node:crypto') +const { createHash, createPublicKey, sign, verify } = require("node:crypto"); const { chmodSync, closeSync, + cpSync, copyFileSync, existsSync, lstatSync, @@ -19,9 +15,9 @@ const { renameSync, rmSync, statSync, - writeFileSync -} = require('node:fs') -const { tmpdir } = require('node:os') + writeFileSync, +} = require("node:fs"); +const { tmpdir } = require("node:os"); const { basename, dirname, @@ -29,450 +25,423 @@ const { posix, relative, resolve, - sep -} = require('node:path') -const tar = require('tar') + sep, +} = require("node:path"); +const tar = require("tar"); +const { targetName, assertTargetBinary } = require("./agent-build-target.cjs"); const { - targetName, - assertTargetBinary -} = require('./agent-build-target.cjs') -const { - preflightRegisteredProductionKey -} = require('./signing-key-preflight.cjs') + preflightRegisteredProductionKey, +} = require("./signing-key-preflight.cjs"); +const { prepareBundledOpenCodeConfig } = require("./opencode-config.cjs"); -const root = join(__dirname, '..') -const supportedArchitectures = Object.freeze(['x64', 'arm64']) -const manifestFileName = 'manifest.json' -const signatureFileName = 'manifest.sig' -const signingPrivateKeyEnvironment = - 'GOODBUDDY_SIGNING_PRIVATE_KEY' -const signingKeyIdEnvironment = - 'GOODBUDDY_SIGNING_KEY_ID' +const root = join(__dirname, ".."); +const supportedArchitectures = Object.freeze(["x64", "arm64"]); +const manifestFileName = "manifest.json"; +const signatureFileName = "manifest.sig"; +const signingPrivateKeyEnvironment = "GOODBUDDY_SIGNING_PRIVATE_KEY"; +const signingKeyIdEnvironment = "GOODBUDDY_SIGNING_KEY_ID"; const signatureDomain = Buffer.from( - 'GoodBuddy Remote Runtime Bundle Manifest Signature v1\0', - 'utf8' -) + "GoodBuddy Remote Runtime Bundle Manifest Signature v1\0", + "utf8", +); const profileDigests = Object.freeze({ adapter: - 'sha256:ac7696abe504bbea444d8ad80dd8faa96437e6dbb27ed987ce46e1045f3dd365', - acp: - 'sha256:0e3764ab897258bc0234162357c2eefc3faeb9918080ae81b60e2084ed7996a6' -}) + "sha256:ac7696abe504bbea444d8ad80dd8faa96437e6dbb27ed987ce46e1045f3dd365", + acp: "sha256:0e3764ab897258bc0234162357c2eefc3faeb9918080ae81b60e2084ed7996a6", +}); const fixedLimits = Object.freeze({ maximumPromptRuntimeMilliseconds: 10 * 60 * 1000, maximumPromptInputBytes: 16 * 1024 * 1024, - maximumPromptOutputBytes: 8 * 1024 * 1024 -}) + maximumPromptOutputBytes: 8 * 1024 * 1024, +}); function readJson(filePath, description) { try { - return JSON.parse(readFileSync(filePath, 'utf8')) + return JSON.parse(readFileSync(filePath, "utf8")); } catch (error) { throw new Error( `${description} is missing or invalid: ${ error instanceof Error ? error.message : String(error) }`, - { cause: error } - ) + { cause: error }, + ); } } function readRemoteRuntimeLock(projectRoot = root) { const lock = readJson( - join(projectRoot, 'remote-runtime-lock.json'), - 'Remote Runtime lock' - ) - const runtime = lock?.runtimes?.opencode + join(projectRoot, "remote-runtime-lock.json"), + "Remote Runtime lock", + ); + const runtime = lock?.runtimes?.opencode; if ( lock?.formatVersion !== 1 || - runtime?.version !== '1.18.29' || - runtime.provider !== 'opencode' || - runtime.entrypoint !== 'bin/opencode' || - runtime.entrypointIdentity !== 'opencode-acp' || - JSON.stringify(runtime.argvPrefix) !== JSON.stringify(['acp']) || + runtime?.version !== "1.18.29" || + runtime.provider !== "opencode" || + runtime.entrypoint !== "bin/opencode" || + runtime.entrypointIdentity !== "opencode-acp" || + JSON.stringify(runtime.argvPrefix) !== JSON.stringify(["acp"]) || !Number.isSafeInteger(runtime.protocol?.major) || !Number.isSafeInteger(runtime.protocol?.minor) ) { - throw new Error('Remote Runtime lock contract is invalid') + throw new Error("Remote Runtime lock contract is invalid"); } for (const architecture of supportedArchitectures) { - const target = runtime.targets?.[architecture] + const target = runtime.targets?.[architecture]; if ( - typeof target?.package !== 'string' || - typeof target.integrity !== 'string' || + typeof target?.package !== "string" || + typeof target.integrity !== "string" || !/^sha512-[A-Za-z0-9+/]{86}==$/u.test(target.integrity) ) { - throw new Error( - `Remote Runtime lock target is invalid: ${architecture}` - ) + throw new Error(`Remote Runtime lock target is invalid: ${architecture}`); } } - return lock + return lock; } function readTrustedKeyRegistry(projectRoot = root) { const registry = readJson( - join(projectRoot, 'resources', 'agent-release-keys.json'), - 'Runtime trusted key registry' - ) + join(projectRoot, "resources", "agent-release-keys.json"), + "Runtime trusted key registry", + ); if ( registry?.formatVersion !== 1 || !Array.isArray(registry.keys) || !Array.isArray(registry.revocations) ) { - throw new Error('Runtime trusted key registry contract is invalid') + throw new Error("Runtime trusted key registry contract is invalid"); } - const keyIds = new Set() + const keyIds = new Set(); for (const key of registry.keys) { if ( - typeof key?.keyId !== 'string' || + typeof key?.keyId !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(key.keyId) || keyIds.has(key.keyId) || - typeof key.publicKeySpkiBase64 !== 'string' || - !['production', 'test'].includes(key.environment) + typeof key.publicKeySpkiBase64 !== "string" || + !["production", "test"].includes(key.environment) ) { - throw new Error('Runtime trusted key registry entry is invalid') + throw new Error("Runtime trusted key registry entry is invalid"); } const publicKey = createPublicKey({ - key: Buffer.from(key.publicKeySpkiBase64, 'base64'), - format: 'der', - type: 'spki' - }) - if (publicKey.asymmetricKeyType !== 'ed25519') { - throw new Error(`Runtime trusted key is not Ed25519: ${key.keyId}`) + key: Buffer.from(key.publicKeySpkiBase64, "base64"), + format: "der", + type: "spki", + }); + if (publicKey.asymmetricKeyType !== "ed25519") { + throw new Error(`Runtime trusted key is not Ed25519: ${key.keyId}`); } - keyIds.add(key.keyId) + keyIds.add(key.keyId); } - return registry + return registry; } function sha256(contents) { - return createHash('sha256').update(contents).digest('hex') + return createHash("sha256").update(contents).digest("hex"); } function sha512FileIntegrity(filePath) { - const hash = createHash('sha512') - const descriptor = openSync(filePath, 'r') - const chunk = Buffer.allocUnsafe(1024 * 1024) + const hash = createHash("sha512"); + const descriptor = openSync(filePath, "r"); + const chunk = Buffer.allocUnsafe(1024 * 1024); try { while (true) { - const bytesRead = readSync( - descriptor, - chunk, - 0, - chunk.byteLength, - null - ) + const bytesRead = readSync(descriptor, chunk, 0, chunk.byteLength, null); if (bytesRead === 0) { - break + break; } - hash.update(chunk.subarray(0, bytesRead)) + hash.update(chunk.subarray(0, bytesRead)); } } finally { - closeSync(descriptor) + closeSync(descriptor); } - return `sha512-${hash.digest('base64')}` + return `sha512-${hash.digest("base64")}`; } function sha256FileSync(filePath) { - return sha256(readFileSync(filePath)) + return sha256(readFileSync(filePath)); } function canonicalJson(value) { if ( value === null || - typeof value === 'boolean' || - typeof value === 'string' + typeof value === "boolean" || + typeof value === "string" ) { - return JSON.stringify(value) + return JSON.stringify(value); } - if (typeof value === 'number') { + if (typeof value === "number") { if (!Number.isFinite(value)) { - throw new Error('Canonical Runtime value is not finite') + throw new Error("Canonical Runtime value is not finite"); } - return JSON.stringify(Object.is(value, -0) ? 0 : value) + return JSON.stringify(Object.is(value, -0) ? 0 : value); } if (Array.isArray(value)) { - return `[${value.map(canonicalJson).join(',')}]` + return `[${value.map(canonicalJson).join(",")}]`; } if ( - typeof value !== 'object' || + typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype ) { - throw new Error('Canonical Runtime value is not plain JSON') + throw new Error("Canonical Runtime value is not plain JSON"); } return `{${Object.keys(value) .sort() - .map( - (key) => - `${JSON.stringify(key)}:${canonicalJson(value[key])}` - ) - .join(',')}}` + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; } function digestCanonicalOperation({ method, scope, payload }) { return `sha256:${sha256( - Buffer.from(canonicalJson({ method, payload, scope }), 'utf8') - )}` + Buffer.from(canonicalJson({ method, payload, scope }), "utf8"), + )}`; } function digestRuntimeBundleIdentity(manifest) { - const { bundleDigest: _bundleDigest, ...identity } = manifest - void _bundleDigest + const { bundleDigest: _bundleDigest, ...identity } = manifest; + void _bundleDigest; return digestCanonicalOperation({ - method: 'runtime/bundleIdentity', + method: "runtime/bundleIdentity", scope: { - kind: 'installation', - installationId: manifest.runtimeId + kind: "installation", + installationId: manifest.runtimeId, }, - payload: identity - }) + payload: identity, + }); } function digestRuntimeBundleManifest(manifest) { return digestCanonicalOperation({ - method: 'runtime/bundleManifest', + method: "runtime/bundleManifest", scope: { - kind: 'installation', - installationId: manifest.runtimeId + kind: "installation", + installationId: manifest.runtimeId, }, - payload: manifest - }) + payload: manifest, + }); } function canonicalManifestBytes(manifest) { - return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"); } function signaturePayload(manifestBytes) { - return Buffer.concat([signatureDomain, manifestBytes]) + return Buffer.concat([signatureDomain, manifestBytes]); } function publicKeySpkiBase64(key) { return createPublicKey(key) - .export({ format: 'der', type: 'spki' }) - .toString('base64') + .export({ format: "der", type: "spki" }) + .toString("base64"); } function productionSigningIdentity(environment = process.env) { - const keyId = environment[signingKeyIdEnvironment] - const privateKey = environment[signingPrivateKeyEnvironment] + const keyId = environment[signingKeyIdEnvironment]; + const privateKey = environment[signingPrivateKeyEnvironment]; if (!keyId || !privateKey) { throw new Error( - `${signingKeyIdEnvironment} and ${signingPrivateKeyEnvironment} are required to sign the bundled Runtime` - ) + `${signingKeyIdEnvironment} and ${signingPrivateKeyEnvironment} are required to sign the bundled Runtime`, + ); } - return { keyId, privateKey } + return { keyId, privateKey }; } function trustedKeyForSigning( signingIdentity, registry, - verificationEnvironment + verificationEnvironment, ) { const record = registry.keys.find( - (key) => key.keyId === signingIdentity.keyId - ) + (key) => key.keyId === signingIdentity.keyId, + ); if (!record) { throw new Error( - `Runtime signing key is not registered: ${signingIdentity.keyId}` - ) + `Runtime signing key is not registered: ${signingIdentity.keyId}`, + ); } if ( - verificationEnvironment === 'production' && - record.environment !== 'production' + verificationEnvironment === "production" && + record.environment !== "production" ) { throw new Error( - `Runtime signing key is not registered for production: ${record.keyId}` - ) + `Runtime signing key is not registered for production: ${record.keyId}`, + ); } if ( - registry.revocations.some( - (revocation) => revocation.keyId === record.keyId - ) + registry.revocations.some((revocation) => revocation.keyId === record.keyId) ) { - throw new Error('Runtime signing key is outside its trusted interval') + throw new Error("Runtime signing key is outside its trusted interval"); } if ( publicKeySpkiBase64(signingIdentity.privateKey) !== record.publicKeySpkiBase64 ) { throw new Error( - 'Runtime signing private key does not match the trusted registry' - ) + "Runtime signing private key does not match the trusted registry", + ); } - return record + return record; } function preflightProductionSigningKey(options = {}) { - const projectRoot = options.projectRoot ?? root - const environment = options.environment ?? process.env - const keyId = - options.keyId ?? environment[signingKeyIdEnvironment] - const registry = - options.registry ?? readTrustedKeyRegistry(projectRoot) + const projectRoot = options.projectRoot ?? root; + const environment = options.environment ?? process.env; + const keyId = options.keyId ?? environment[signingKeyIdEnvironment]; + const registry = options.registry ?? readTrustedKeyRegistry(projectRoot); return preflightRegisteredProductionKey({ - component: 'GoodBuddy', + component: "GoodBuddy", keyId, registry, - missingKeyIdMessage: - `${signingKeyIdEnvironment} is not configured; provision the production GoodBuddy signing key ID before building a release` - }) + missingKeyIdMessage: `${signingKeyIdEnvironment} is not configured; provision the production GoodBuddy signing key ID before building a release`, + }); } function assertSafeManifestPath(filePath) { if ( - typeof filePath !== 'string' || + typeof filePath !== "string" || filePath.length === 0 || filePath.length > 240 || - filePath.includes('\\') || - filePath.includes('\0') || + filePath.includes("\\") || + filePath.includes("\0") || posix.isAbsolute(filePath) || filePath - .split('/') - .some( - (part) => part.length === 0 || part === '.' || part === '..' - ) + .split("/") + .some((part) => part.length === 0 || part === "." || part === "..") ) { - throw new Error(`Unsafe Runtime manifest path: ${String(filePath)}`) + throw new Error(`Unsafe Runtime manifest path: ${String(filePath)}`); } } function compareUtf8(left, right) { - return Buffer.compare( - Buffer.from(left, 'utf8'), - Buffer.from(right, 'utf8') - ) + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); } function listFiles(directory) { - const files = [] - const pending = [directory] + const files = []; + const pending = [directory]; while (pending.length > 0) { - const current = pending.pop() + const current = pending.pop(); const entries = readdirSync(current, { - withFileTypes: true - }).sort((left, right) => compareUtf8(right.name, left.name)) + withFileTypes: true, + }).sort((left, right) => compareUtf8(right.name, left.name)); for (const entry of entries) { - const absolutePath = join(current, entry.name) + const absolutePath = join(current, entry.name); if (entry.isSymbolicLink()) { throw new Error( - `Runtime bundle cannot contain a symlink: ${absolutePath}` - ) + `Runtime bundle cannot contain a symlink: ${absolutePath}`, + ); } if (entry.isDirectory()) { - pending.push(absolutePath) + pending.push(absolutePath); } else if (entry.isFile()) { - files.push(absolutePath) + files.push(absolutePath); } else { throw new Error( - `Runtime bundle contains an unsupported file: ${absolutePath}` - ) + `Runtime bundle contains an unsupported file: ${absolutePath}`, + ); } } } - return files.sort(compareUtf8) + return files.sort(compareUtf8); } function posixRelative(directory, filePath) { - return relative(directory, filePath).split(sep).join('/') + return relative(directory, filePath).split(sep).join("/"); } function modeString(filePath) { - return (statSync(filePath).mode & 0o777) - .toString(8) - .padStart(4, '0') + return (statSync(filePath).mode & 0o777).toString(8).padStart(4, "0"); } -function assertElfArchitecture(filePath, expectedArchitecture, platform = 'linux') { - assertTargetBinary(filePath, expectedArchitecture, platform, 'Runtime') +function assertElfArchitecture( + filePath, + expectedArchitecture, + platform = "linux", +) { + assertTargetBinary(filePath, expectedArchitecture, platform, "Runtime"); } function validateManifestShape(manifest) { if ( manifest?.formatVersion !== 2 || - manifest.product !== 'GoodBuddy' || - manifest.runtimeId !== 'opencode' || - manifest.runtimeVersion !== '1.18.29' || - manifest.provider !== 'opencode' || + manifest.product !== "GoodBuddy" || + manifest.runtimeId !== "opencode" || + manifest.runtimeVersion !== "1.18.29" || + manifest.provider !== "opencode" || !( - manifest.platform === 'linux' || - (manifest.platform === 'darwin' && manifest.architecture === 'arm64') + manifest.platform === "linux" || + (manifest.platform === "darwin" && manifest.architecture === "arm64") ) || !supportedArchitectures.includes(manifest.architecture) || - typeof manifest.signingKeyId !== 'string' || + typeof manifest.signingKeyId !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(manifest.bundleDigest) || !/^sha256:[a-f0-9]{64}$/u.test(manifest.adapterDigest) || - !/^sha256:[a-f0-9]{64}$/u.test( - manifest.acpCapabilitiesDigest - ) || + !/^sha256:[a-f0-9]{64}$/u.test(manifest.acpCapabilitiesDigest) || !Array.isArray(manifest.files) || !Array.isArray(manifest.licenses) || !Array.isArray(manifest.allowedEnvironmentNames) ) { - throw new Error('Runtime manifest contract is invalid') + throw new Error("Runtime manifest contract is invalid"); } if ( - !Number.isSafeInteger( - manifest.limits?.maximumPromptRuntimeMilliseconds - ) || + !Number.isSafeInteger(manifest.limits?.maximumPromptRuntimeMilliseconds) || manifest.limits.maximumPromptRuntimeMilliseconds < 1 || !Number.isSafeInteger(manifest.limits.maximumPromptInputBytes) || manifest.limits.maximumPromptInputBytes < 1 || !Number.isSafeInteger(manifest.limits.maximumPromptOutputBytes) || manifest.limits.maximumPromptOutputBytes < 0 || Object.keys(manifest.limits).length !== 3 || - 'quotas' in manifest + "quotas" in manifest ) { - throw new Error('Runtime manifest limits contract is invalid') + throw new Error("Runtime manifest limits contract is invalid"); } if ( - manifest.entrypoint?.identity !== 'opencode-acp' || - manifest.entrypoint.path !== 'bin/opencode' || + manifest.entrypoint?.identity !== "opencode-acp" || + manifest.entrypoint.path !== "bin/opencode" || !/^[a-f0-9]{64}$/u.test(manifest.entrypoint.sha256) || - JSON.stringify(manifest.entrypoint.argvPrefix) !== - JSON.stringify(['acp']) + JSON.stringify(manifest.entrypoint.argvPrefix) !== JSON.stringify(["acp"]) ) { - throw new Error('Runtime manifest entrypoint is invalid') + throw new Error("Runtime manifest entrypoint is invalid"); } } -function trustedKeyForManifest( - manifest, - registry, - verificationEnvironment -) { +function trustedKeyForManifest(manifest, registry, verificationEnvironment) { const key = registry.keys.find( - (candidate) => candidate.keyId === manifest.signingKeyId - ) + (candidate) => candidate.keyId === manifest.signingKeyId, + ); if (!key) { throw new Error( - `Runtime manifest uses an unknown signing key: ${manifest.signingKeyId}` - ) + `Runtime manifest uses an unknown signing key: ${manifest.signingKeyId}`, + ); } if ( - verificationEnvironment === 'production' && - key.environment !== 'production' + verificationEnvironment === "production" && + key.environment !== "production" ) { throw new Error( - `Production Runtime verification rejects non-production key: ${key.keyId}` - ) + `Production Runtime verification rejects non-production key: ${key.keyId}`, + ); } if ( - registry.revocations.some( - (revocation) => revocation.keyId === key.keyId - ) + registry.revocations.some((revocation) => revocation.keyId === key.keyId) ) { - throw new Error('Runtime signing key is outside its trusted interval') + throw new Error("Runtime signing key is outside its trusted interval"); } - return key + return key; } -function assertManifestMatchesLock(manifest, lock, architecture, platform = 'linux') { - const expected = lock.runtimes.opencode - const target = expected.targets[platform === 'linux' ? architecture : targetName(architecture, platform)] +function assertManifestMatchesLock( + manifest, + lock, + architecture, + platform = "linux", +) { + const expected = lock.runtimes.opencode; + const target = + expected.targets[ + platform === "linux" ? architecture : targetName(architecture, platform) + ]; if ( - manifest.runtimeId !== 'opencode' || + manifest.runtimeId !== "opencode" || manifest.runtimeVersion !== expected.version || manifest.provider !== expected.provider || manifest.platform !== platform || @@ -491,296 +460,268 @@ function assertManifestMatchesLock(manifest, lock, architecture, platform = 'lin manifest.acpCapabilitiesDigest !== profileDigests.acp ) { throw new Error( - 'Runtime manifest does not match the locked OpenCode profile' - ) + "Runtime manifest does not match the locked OpenCode profile", + ); } } function verifyBundleDirectory(bundleDirectoryInput, options = {}) { - const bundleDirectory = resolve(bundleDirectoryInput) - const projectRoot = options.projectRoot ?? root - const lock = options.lock ?? readRemoteRuntimeLock(projectRoot) - const registry = - options.registry ?? readTrustedKeyRegistry(projectRoot) - const architecture = options.architecture - const platform = options.platform ?? 'linux' - targetName(architecture, platform) - const manifestBytes = readFileSync( - join(bundleDirectory, manifestFileName) - ) + const bundleDirectory = resolve(bundleDirectoryInput); + const projectRoot = options.projectRoot ?? root; + const lock = options.lock ?? readRemoteRuntimeLock(projectRoot); + const registry = options.registry ?? readTrustedKeyRegistry(projectRoot); + const architecture = options.architecture; + const platform = options.platform ?? "linux"; + targetName(architecture, platform); + const manifestBytes = readFileSync(join(bundleDirectory, manifestFileName)); const signatureText = readFileSync( join(bundleDirectory, signatureFileName), - 'utf8' - ) + "utf8", + ); if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(signatureText)) { - throw new Error('Runtime detached signature encoding is invalid') + throw new Error("Runtime detached signature encoding is invalid"); } - const manifest = JSON.parse(manifestBytes.toString('utf8')) - validateManifestShape(manifest) + const manifest = JSON.parse(manifestBytes.toString("utf8")); + validateManifestShape(manifest); if (!canonicalManifestBytes(manifest).equals(manifestBytes)) { - throw new Error( - 'Runtime manifest is not in canonical deterministic form' - ) + throw new Error("Runtime manifest is not in canonical deterministic form"); } - assertManifestMatchesLock(manifest, lock, architecture, platform) - const environment = - options.verificationEnvironment ?? 'production' - const key = trustedKeyForManifest( - manifest, - registry, - environment - ) + assertManifestMatchesLock(manifest, lock, architecture, platform); + const environment = options.verificationEnvironment ?? "production"; + const key = trustedKeyForManifest(manifest, registry, environment); const publicKey = createPublicKey({ - key: Buffer.from(key.publicKeySpkiBase64, 'base64'), - format: 'der', - type: 'spki' - }) - const signatureBytes = Buffer.from(signatureText.trim(), 'base64') + key: Buffer.from(key.publicKeySpkiBase64, "base64"), + format: "der", + type: "spki", + }); + const signatureBytes = Buffer.from(signatureText.trim(), "base64"); if ( signatureBytes.length !== 64 || - !verify( - null, - signaturePayload(manifestBytes), - publicKey, - signatureBytes - ) + !verify(null, signaturePayload(manifestBytes), publicKey, signatureBytes) ) { - throw new Error('Runtime manifest signature verification failed') + throw new Error("Runtime manifest signature verification failed"); } if (digestRuntimeBundleIdentity(manifest) !== manifest.bundleDigest) { - throw new Error('Runtime bundle identity digest does not match') + throw new Error("Runtime bundle identity digest does not match"); } if ( options.requireDigestDirectory !== false && - basename(bundleDirectory) !== - manifest.bundleDigest.slice('sha256:'.length) + basename(bundleDirectory) !== manifest.bundleDigest.slice("sha256:".length) ) { throw new Error( - 'Runtime bundle digest does not match its managed directory' - ) + "Runtime bundle digest does not match its managed directory", + ); } - const declared = new Set() + const declared = new Set(); for (const file of manifest.files) { - assertSafeManifestPath(file.path) + assertSafeManifestPath(file.path); if ( declared.has(file.path) || !Number.isSafeInteger(file.size) || file.size < 0 || !/^[a-f0-9]{64}$/u.test(file.sha256) || - !['0644', '0755'].includes(file.mode) + !["0644", "0755"].includes(file.mode) ) { - throw new Error(`Runtime manifest file entry is invalid: ${file.path}`) + throw new Error(`Runtime manifest file entry is invalid: ${file.path}`); } - declared.add(file.path) - const filePath = join(bundleDirectory, ...file.path.split('/')) - const stat = lstatSync(filePath) + declared.add(file.path); + const filePath = join(bundleDirectory, ...file.path.split("/")); + const stat = lstatSync(filePath); if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error(`Runtime payload is not a regular file: ${file.path}`) + throw new Error(`Runtime payload is not a regular file: ${file.path}`); } if (stat.size !== file.size) { - throw new Error(`Runtime payload size mismatch: ${file.path}`) + throw new Error(`Runtime payload size mismatch: ${file.path}`); } if (sha256FileSync(filePath) !== file.sha256) { - throw new Error(`Runtime payload hash mismatch: ${file.path}`) + throw new Error(`Runtime payload hash mismatch: ${file.path}`); } if ( options.enforceFilesystemMode !== false && - process.platform !== 'win32' && + process.platform !== "win32" && modeString(filePath) !== file.mode ) { - throw new Error(`Runtime payload mode mismatch: ${file.path}`) + throw new Error(`Runtime payload mode mismatch: ${file.path}`); } } const actual = new Set( listFiles(bundleDirectory).map((filePath) => - posixRelative(bundleDirectory, filePath) - ) - ) - const expected = new Set([ - ...declared, - manifestFileName, - signatureFileName - ]) + posixRelative(bundleDirectory, filePath), + ), + ); + const expected = new Set([...declared, manifestFileName, signatureFileName]); if ( actual.size !== expected.size || [...actual].some((filePath) => !expected.has(filePath)) ) { - throw new Error( - 'Runtime bundle contains undeclared or missing files' - ) + throw new Error("Runtime bundle contains undeclared or missing files"); } for (const license of manifest.licenses) { - assertSafeManifestPath(license.path) + assertSafeManifestPath(license.path); if (!declared.has(license.path)) { - throw new Error('Runtime manifest license is not declared') + throw new Error("Runtime manifest license is not declared"); } } if ( !declared.has(manifest.entrypoint.path) || manifest.entrypoint.sha256 !== - manifest.files.find( - (file) => file.path === manifest.entrypoint.path - )?.sha256 + manifest.files.find((file) => file.path === manifest.entrypoint.path) + ?.sha256 ) { - throw new Error('Runtime entrypoint payload is missing') + throw new Error("Runtime entrypoint payload is missing"); } assertElfArchitecture( - join(bundleDirectory, ...manifest.entrypoint.path.split('/')), + join(bundleDirectory, ...manifest.entrypoint.path.split("/")), architecture, - platform - ) + platform, + ); return { bundleDirectory, manifest, - manifestDigest: digestRuntimeBundleManifest(manifest) - } + manifestDigest: digestRuntimeBundleManifest(manifest), + }; } -function lockedArchive(lock, architecture, archivePath, platform = 'linux') { - const input = resolveLockedRuntimeInput(lock, architecture, platform) +function lockedArchive(lock, architecture, archivePath, platform = "linux") { + const input = resolveLockedRuntimeInput(lock, architecture, platform); if (!archivePath || !existsSync(archivePath)) { - throw new Error( - `Locked OpenCode archive is required: ${input.archive}` - ) + throw new Error(`Locked OpenCode archive is required: ${input.archive}`); } if (!statSync(archivePath).isFile()) { - throw new Error(`Runtime archive is not a file: ${archivePath}`) + throw new Error(`Runtime archive is not a file: ${archivePath}`); } if (basename(archivePath) !== input.archive) { throw new Error( - `Runtime archive filename mismatch: expected ${input.archive}` - ) + `Runtime archive filename mismatch: expected ${input.archive}`, + ); } - const actualIntegrity = sha512FileIntegrity(archivePath) + const actualIntegrity = sha512FileIntegrity(archivePath); if (actualIntegrity !== input.integrity) { throw new Error( - `Runtime archive integrity mismatch for ${targetName(architecture)}` - ) + `Runtime archive integrity mismatch for ${targetName(architecture)}`, + ); } - return lock.runtimes.opencode.targets[platform === 'linux' ? architecture : targetName(architecture, platform)] + return lock.runtimes.opencode.targets[ + platform === "linux" ? architecture : targetName(architecture, platform) + ]; } -function resolveLockedRuntimeInput(lock, architecture, platform = 'linux') { - targetName(architecture, platform) - const runtime = lock.runtimes.opencode - const target = runtime.targets[platform === 'linux' ? architecture : targetName(architecture, platform)] +function resolveLockedRuntimeInput(lock, architecture, platform = "linux") { + targetName(architecture, platform); + const runtime = lock.runtimes.opencode; + const target = + runtime.targets[ + platform === "linux" ? architecture : targetName(architecture, platform) + ]; return { packageName: target.package, version: runtime.version, integrity: target.integrity, - archive: `${target.package}-${runtime.version}.tgz` - } + archive: `${target.package}-${runtime.version}.tgz`, + }; } function createManifest(bundleDirectory, metadata) { const files = listFiles(bundleDirectory) .filter((filePath) => { - const name = posixRelative(bundleDirectory, filePath) - return name !== manifestFileName && name !== signatureFileName + const name = posixRelative(bundleDirectory, filePath); + return name !== manifestFileName && name !== signatureFileName; }) .map((filePath) => { - const path = posixRelative(bundleDirectory, filePath) - assertSafeManifestPath(path) + const path = posixRelative(bundleDirectory, filePath); + assertSafeManifestPath(path); return { path, size: statSync(filePath).size, sha256: sha256FileSync(filePath), - mode: path === 'bin/opencode' ? '0755' : '0644' - } - }) - const entrypoint = files.find( - (file) => file.path === 'bin/opencode' - ) + mode: path === "bin/opencode" ? "0755" : "0644", + }; + }); + const entrypoint = files.find((file) => file.path === "bin/opencode"); if (!entrypoint) { - throw new Error('Runtime entrypoint is missing') + throw new Error("Runtime entrypoint is missing"); } const initial = { formatVersion: 2, - product: 'GoodBuddy', - runtimeId: 'opencode', + product: "GoodBuddy", + runtimeId: "opencode", runtimeVersion: metadata.runtime.version, provider: metadata.runtime.provider, - platform: metadata.platform ?? 'linux', + platform: metadata.platform ?? "linux", architecture: metadata.architecture, signingKeyId: metadata.signingKeyId, - bundleDigest: `sha256:${'0'.repeat(64)}`, + bundleDigest: `sha256:${"0".repeat(64)}`, adapterDigest: profileDigests.adapter, sourcePackage: metadata.sourcePackage, entrypoint: { identity: metadata.runtime.entrypointIdentity, path: metadata.runtime.entrypoint, sha256: entrypoint.sha256, - argvPrefix: metadata.runtime.argvPrefix + argvPrefix: metadata.runtime.argvPrefix, }, files, licenses: [ { - package: 'opencode-ai', + package: "opencode-ai", version: metadata.runtime.version, - spdx: 'MIT', - path: 'licenses/opencode-MIT.txt' - } + spdx: "MIT", + path: "licenses/opencode-MIT.txt", + }, ], - allowedEnvironmentNames: - metadata.runtime.allowedEnvironmentNames, + allowedEnvironmentNames: metadata.runtime.allowedEnvironmentNames, protocol: metadata.runtime.protocol, acpCapabilitiesDigest: profileDigests.acp, - limits: fixedLimits - } + limits: fixedLimits, + }; return { ...initial, - bundleDigest: digestRuntimeBundleIdentity(initial) - } + bundleDigest: digestRuntimeBundleIdentity(initial), + }; } function defaultOutputRoot(projectRoot = root) { - return join(projectRoot, '.remote-runtime-resources') + return join(projectRoot, ".remote-runtime-resources"); } -function bundleDirectory(outputRoot, architecture, digest, platform = 'linux') { +function bundleDirectory(outputRoot, architecture, digest, platform = "linux") { return join( outputRoot, targetName(architecture, platform), - 'opencode', - digest.slice('sha256:'.length) - ) + "opencode", + digest.slice("sha256:".length), + ); } function buildRuntimeBundle(options) { - const projectRoot = options.projectRoot ?? root - const architecture = options.architecture - const platform = options.platform ?? 'linux' - const lock = options.lock ?? readRemoteRuntimeLock(projectRoot) - const runtime = lock.runtimes.opencode + const projectRoot = options.projectRoot ?? root; + const architecture = options.architecture; + const platform = options.platform ?? "linux"; + const lock = options.lock ?? readRemoteRuntimeLock(projectRoot); + const runtime = lock.runtimes.opencode; const sourcePackage = lockedArchive( lock, architecture, options.runtimeArchive, - platform - ) - const registry = - options.registry ?? readTrustedKeyRegistry(projectRoot) + platform, + ); + const registry = options.registry ?? readTrustedKeyRegistry(projectRoot); const signingIdentity = options.testSigningIdentity ?? options.signingIdentity ?? - productionSigningIdentity(options.environment ?? process.env) - const verificationEnvironment = - options.testSigningIdentity ? 'test' : 'production' - trustedKeyForSigning( - signingIdentity, - registry, - verificationEnvironment - ) - const outputRoot = - options.outputRoot ?? defaultOutputRoot(projectRoot) - mkdirSync(outputRoot, { recursive: true }) + productionSigningIdentity(options.environment ?? process.env); + const verificationEnvironment = options.testSigningIdentity + ? "test" + : "production"; + trustedKeyForSigning(signingIdentity, registry, verificationEnvironment); + const outputRoot = options.outputRoot ?? defaultOutputRoot(projectRoot); + mkdirSync(outputRoot, { recursive: true }); const staging = mkdtempSync( - join(outputRoot, `.stage-${targetName(architecture)}-`) - ) + join(outputRoot, `.stage-${targetName(architecture)}-`), + ); const extracted = mkdtempSync( - join(tmpdir(), `goodbuddy-opencode-${targetName(architecture)}-`) - ) + join(tmpdir(), `goodbuddy-opencode-${targetName(architecture)}-`), + ); try { tar.x({ file: options.runtimeArchive, @@ -789,87 +730,82 @@ function buildRuntimeBundle(options) { strict: true, filter(path) { return ( - path === 'package/package.json' || - path === 'package/bin/opencode' - ) - } - }) + path === "package/package.json" || path === "package/bin/opencode" + ); + }, + }); const sourceMetadata = readJson( - join(extracted, 'package', 'package.json'), - 'OpenCode source package metadata' - ) + join(extracted, "package", "package.json"), + "OpenCode source package metadata", + ); if ( sourceMetadata.name !== sourcePackage.package && sourceMetadata.name !== sourcePackage.name ) { // The lock target itself carries the canonical package name. - if ( - sourceMetadata.name !== - sourcePackage.package - ) { - throw new Error('OpenCode source package name does not match the lock') + if (sourceMetadata.name !== sourcePackage.package) { + throw new Error("OpenCode source package name does not match the lock"); } } if (sourceMetadata.version !== runtime.version) { throw new Error( - 'OpenCode source package version does not match the lock' - ) + "OpenCode source package version does not match the lock", + ); } - mkdirSync(join(staging, 'bin'), { mode: 0o700 }) + mkdirSync(join(staging, "bin"), { mode: 0o700 }); copyFileSync( - join(extracted, 'package', 'bin', 'opencode'), - join(staging, 'bin', 'opencode') - ) - chmodSync(join(staging, 'bin', 'opencode'), 0o755) + join(extracted, "package", "bin", "opencode"), + join(staging, "bin", "opencode"), + ); + chmodSync(join(staging, "bin", "opencode"), 0o755); assertElfArchitecture( - join(staging, 'bin', 'opencode'), + join(staging, "bin", "opencode"), architecture, - platform - ) - mkdirSync(join(staging, 'licenses'), { mode: 0o700 }) + platform, + ); + const configTemplate = prepareBundledOpenCodeConfig(projectRoot); + cpSync(configTemplate, join(staging, "config", "opencode"), { + recursive: true, + }); + mkdirSync(join(staging, "licenses"), { mode: 0o700 }); copyFileSync( - join(projectRoot, 'node_modules', 'opencode-ai', 'LICENSE'), - join(staging, 'licenses', 'opencode-MIT.txt') - ) - chmodSync( - join(staging, 'licenses', 'opencode-MIT.txt'), - 0o644 - ) + join(projectRoot, "node_modules", "opencode-ai", "LICENSE"), + join(staging, "licenses", "opencode-MIT.txt"), + ); + chmodSync(join(staging, "licenses", "opencode-MIT.txt"), 0o644); const manifest = createManifest(staging, { platform, architecture, runtime, sourcePackage: { name: sourcePackage.package, - integrity: sourcePackage.integrity + integrity: sourcePackage.integrity, }, - signingKeyId: signingIdentity.keyId - }) - const manifestBytes = canonicalManifestBytes(manifest) + signingKeyId: signingIdentity.keyId, + }); + const manifestBytes = canonicalManifestBytes(manifest); const signatureBytes = sign( null, signaturePayload(manifestBytes), - signingIdentity.privateKey - ) - writeFileSync( - join(staging, manifestFileName), - manifestBytes, - { mode: 0o644 } - ) + signingIdentity.privateKey, + ); + writeFileSync(join(staging, manifestFileName), manifestBytes, { + mode: 0o644, + }); writeFileSync( join(staging, signatureFileName), - `${signatureBytes.toString('base64')}\n`, - { mode: 0o644 } - ) - chmodSync(join(staging, manifestFileName), 0o644) - chmodSync(join(staging, signatureFileName), 0o644) + `${signatureBytes.toString("base64")}\n`, + { mode: 0o644 }, + ); + chmodSync(join(staging, manifestFileName), 0o644); + chmodSync(join(staging, signatureFileName), 0o644); const destination = bundleDirectory( outputRoot, architecture, manifest.bundleDigest, - platform - ) - mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }) + platform, + ); + mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }); if (existsSync(destination)) { const existing = verifyBundleDirectory(destination, { platform, @@ -878,19 +814,17 @@ function buildRuntimeBundle(options) { lock, registry, verificationEnvironment, - enforceFilesystemMode: options.enforceFilesystemMode - }) - if ( - canonicalJson(existing.manifest) !== canonicalJson(manifest) - ) { + enforceFilesystemMode: options.enforceFilesystemMode, + }); + if (canonicalJson(existing.manifest) !== canonicalJson(manifest)) { throw new Error( - 'Existing Runtime digest directory has different content' - ) + "Existing Runtime digest directory has different content", + ); } - return existing + return existing; } - chmodSync(staging, 0o700) - renameSync(staging, destination) + chmodSync(staging, 0o700); + renameSync(staging, destination); return verifyBundleDirectory(destination, { platform, projectRoot, @@ -898,21 +832,21 @@ function buildRuntimeBundle(options) { lock, registry, verificationEnvironment, - enforceFilesystemMode: options.enforceFilesystemMode - }) + enforceFilesystemMode: options.enforceFilesystemMode, + }); } finally { - rmSync(staging, { recursive: true, force: true }) - rmSync(extracted, { recursive: true, force: true }) + rmSync(staging, { recursive: true, force: true }); + rmSync(extracted, { recursive: true, force: true }); } } function createRuntimeArchive(bundleDirectoryInput, archivePath) { - const directory = resolve(bundleDirectoryInput) + const directory = resolve(bundleDirectoryInput); const entries = listFiles(directory) .map((filePath) => posixRelative(directory, filePath)) - .sort(compareUtf8) - mkdirSync(dirname(archivePath), { recursive: true }) - rmSync(archivePath, { force: true }) + .sort(compareUtf8); + mkdirSync(dirname(archivePath), { recursive: true }); + rmSync(archivePath, { force: true }); tar.c( { cwd: directory, @@ -920,183 +854,176 @@ function createRuntimeArchive(bundleDirectoryInput, archivePath) { sync: true, portable: true, noPax: true, - mtime: new Date(0) + mtime: new Date(0), }, - entries - ) + entries, + ); } function importRuntimeArchive(archivePath, options) { if (!existsSync(archivePath)) { - throw new Error(`Runtime artifact archive is missing: ${archivePath}`) + throw new Error(`Runtime artifact archive is missing: ${archivePath}`); } - const projectRoot = options.projectRoot ?? root - const architecture = options.architecture - const outputRoot = - options.outputRoot ?? defaultOutputRoot(projectRoot) - mkdirSync(outputRoot, { recursive: true }) + const projectRoot = options.projectRoot ?? root; + const architecture = options.architecture; + const outputRoot = options.outputRoot ?? defaultOutputRoot(projectRoot); + mkdirSync(outputRoot, { recursive: true }); const staging = mkdtempSync( - join(outputRoot, `.import-${targetName(architecture)}-`) - ) + join(outputRoot, `.import-${targetName(architecture)}-`), + ); try { tar.x({ cwd: staging, file: archivePath, sync: true, strict: true, - preservePaths: false - }) + preservePaths: false, + }); const verified = verifyBundleDirectory(staging, { ...options, projectRoot, architecture, - requireDigestDirectory: false - }) + requireDigestDirectory: false, + }); const destination = bundleDirectory( outputRoot, architecture, - verified.manifest.bundleDigest - ) - mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }) + verified.manifest.bundleDigest, + ); + mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }); if (existsSync(destination)) { return verifyBundleDirectory(destination, { ...options, projectRoot, - architecture - }) + architecture, + }); } - chmodSync(staging, 0o700) - renameSync(staging, destination) + chmodSync(staging, 0o700); + renameSync(staging, destination); return verifyBundleDirectory(destination, { ...options, projectRoot, - architecture - }) + architecture, + }); } finally { - rmSync(staging, { recursive: true, force: true }) + rmSync(staging, { recursive: true, force: true }); } } function findOnlyBundle(outputRoot, architecture) { - const runtimeRoot = join( - outputRoot, - targetName(architecture), - 'opencode' - ) + const runtimeRoot = join(outputRoot, targetName(architecture), "opencode"); const entries = existsSync(runtimeRoot) ? readdirSync(runtimeRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => join(runtimeRoot, entry.name)) - : [] + : []; if (entries.length !== 1) { throw new Error( - `Expected exactly one built Runtime bundle for ${targetName(architecture)}` - ) + `Expected exactly one built Runtime bundle for ${targetName(architecture)}`, + ); } - return entries[0] + return entries[0]; } function redactSecrets(value, environment = process.env) { - let output = String(value) + let output = String(value); for (const secret of [ environment[signingPrivateKeyEnvironment], - environment[signingKeyIdEnvironment] + environment[signingKeyIdEnvironment], ]) { if (secret && secret.length >= 4) { - output = output.split(secret).join('[redacted]') + output = output.split(secret).join("[redacted]"); } } return output.replace( /-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----/gu, - '[redacted private key]' - ) + "[redacted private key]", + ); } function parseArguments(argv) { - const command = argv[0] - const options = {} + const command = argv[0]; + const options = {}; const allowed = new Set([ - '--arch', - '--runtime-archive', - '--archive', - '--output-root', - '--bundle' - ]) + "--arch", + "--runtime-archive", + "--archive", + "--output-root", + "--bundle", + ]); for (let index = 1; index < argv.length; index += 2) { - const flag = argv[index] - const value = argv[index + 1] + const flag = argv[index]; + const value = argv[index + 1]; if (!allowed.has(flag) || !value) { - throw new Error('Invalid Remote Runtime bundle argument') + throw new Error("Invalid Remote Runtime bundle argument"); } options[ - flag.slice(2).replace(/-([a-z])/gu, (_match, value) => - value.toUpperCase() - ) - ] = value + flag + .slice(2) + .replace(/-([a-z])/gu, (_match, value) => value.toUpperCase()) + ] = value; } - if (!['build', 'import', 'verify', 'preflight'].includes(command)) { + if (!["build", "import", "verify", "preflight"].includes(command)) { throw new Error( - 'Usage: remote-runtime-bundle.cjs --arch ' - ) + "Usage: remote-runtime-bundle.cjs --arch ", + ); } - if (command === 'preflight') { - return { command } + if (command === "preflight") { + return { command }; } - targetName(options.arch) - return { command, ...options } + targetName(options.arch); + return { command, ...options }; } function main(argv = process.argv.slice(2)) { - const options = parseArguments(argv) - if (options.command === 'preflight') { - const record = preflightProductionSigningKey() + const options = parseArguments(argv); + if (options.command === "preflight") { + const record = preflightProductionSigningKey(); console.log( - `Production GoodBuddy signing preflight passed for bundled Runtime: ${record.keyId}` - ) - return + `Production GoodBuddy signing preflight passed for bundled Runtime: ${record.keyId}`, + ); + return; } const outputRoot = options.outputRoot ? resolve(options.outputRoot) - : defaultOutputRoot() - if (options.command === 'build') { + : defaultOutputRoot(); + if (options.command === "build") { const verified = buildRuntimeBundle({ architecture: options.arch, - runtimeArchive: resolve(options.runtimeArchive ?? ''), - outputRoot - }) + runtimeArchive: resolve(options.runtimeArchive ?? ""), + outputRoot, + }); const archivePath = options.archive ? resolve(options.archive) : join( root, - 'dist', - 'remote-runtime-bundles', - `goodbuddy-opencode-${targetName(options.arch)}.tar` - ) - createRuntimeArchive(verified.bundleDirectory, archivePath) + "dist", + "remote-runtime-bundles", + `goodbuddy-opencode-${targetName(options.arch)}.tar`, + ); + createRuntimeArchive(verified.bundleDirectory, archivePath); console.log( - `Remote Runtime bundle built and verified: ${targetName(options.arch)}` - ) - return + `Remote Runtime bundle built and verified: ${targetName(options.arch)}`, + ); + return; } - if (options.command === 'import') { - importRuntimeArchive(resolve(options.archive ?? ''), { + if (options.command === "import") { + importRuntimeArchive(resolve(options.archive ?? ""), { architecture: options.arch, - outputRoot - }) + outputRoot, + }); console.log( - `Remote Runtime bundle imported and verified: ${targetName(options.arch)}` - ) - return + `Remote Runtime bundle imported and verified: ${targetName(options.arch)}`, + ); + return; } verifyBundleDirectory( options.bundle ? resolve(options.bundle) : findOnlyBundle(outputRoot, options.arch), - { architecture: options.arch } - ) - console.log( - `Remote Runtime bundle verified: ${targetName(options.arch)}` - ) + { architecture: options.arch }, + ); + console.log(`Remote Runtime bundle verified: ${targetName(options.arch)}`); } module.exports = { @@ -1121,18 +1048,16 @@ module.exports = { signaturePayload, supportedArchitectures, targetName, - verifyBundleDirectory -} + verifyBundleDirectory, +}; if (require.main === module) { try { - main() + main(); } catch (error) { console.error( - redactSecrets( - error instanceof Error ? error.message : String(error) - ) - ) - process.exitCode = 1 + redactSecrets(error instanceof Error ? error.message : String(error)), + ); + process.exitCode = 1; } } diff --git a/build/runtime-hooks.cjs b/build/runtime-hooks.cjs index 47e16275..9996488d 100644 --- a/build/runtime-hooks.cjs +++ b/build/runtime-hooks.cjs @@ -1,4 +1,4 @@ -const { createHash } = require('node:crypto') +const { createHash } = require("node:crypto"); const { chmod, mkdir, @@ -6,102 +6,100 @@ const { rename, rm, stat, - writeFile -} = require('node:fs/promises') -const { existsSync } = require('node:fs') -const { join } = require('node:path') -const { spawnSync } = require('node:child_process') -const tar = require('tar') -const { sha256File } = require('./file-hash.cjs') + writeFile, +} = require("node:fs/promises"); +const { existsSync } = require("node:fs"); +const { join } = require("node:path"); +const { spawnSync } = require("node:child_process"); +const tar = require("tar"); +const { sha256File } = require("./file-hash.cjs"); +const { + opencodeVersion, + prepareBundledOpenCodeConfig, +} = require("./opencode-config.cjs"); -const opencodeVersion = '1.18.29' -const opencodePluginPackage = '@opencode-ai/plugin' const architectureNames = { - 1: 'x64', - 3: 'arm64' -} + 1: "x64", + 3: "arm64", +}; const platformNames = { - darwin: 'darwin', - linux: 'linux', - win32: 'windows' -} + darwin: "darwin", + linux: "linux", + win32: "windows", +}; function sha512Integrity(contents) { - return `sha512-${createHash('sha512').update(contents).digest('base64')}` + return `sha512-${createHash("sha512").update(contents).digest("base64")}`; } async function lockedIntegrity(projectDir, packageName) { const lock = JSON.parse( - await readFile(join(projectDir, 'package-lock.json'), 'utf8') - ) - const entry = lock.packages?.[`node_modules/${packageName}`] + await readFile(join(projectDir, "package-lock.json"), "utf8"), + ); + const entry = lock.packages?.[`node_modules/${packageName}`]; if ( entry?.version !== opencodeVersion || - typeof entry.integrity !== 'string' + typeof entry.integrity !== "string" ) { throw new Error( - `Missing locked ${packageName}@${opencodeVersion} integrity` - ) + `Missing locked ${packageName}@${opencodeVersion} integrity`, + ); } - return entry.integrity + return entry.integrity; } function npmInvocation() { - const npmCli = process.env.npm_execpath + const npmCli = process.env.npm_execpath; if (npmCli) { return { command: process.execPath, - prefixArgs: [npmCli] - } + prefixArgs: [npmCli], + }; } - if (process.platform === 'win32') { - throw new Error('npm_execpath is required to prepare bundled runtimes') + if (process.platform === "win32") { + throw new Error("npm_execpath is required to prepare bundled runtimes"); } return { - command: 'npm', - prefixArgs: [] - } + command: "npm", + prefixArgs: [], + }; } async function downloadPackage(projectDir, packageName, integrity) { - const cacheDirectory = join( - projectDir, - '.runtime-resources', - 'cache' - ) - await mkdir(cacheDirectory, { recursive: true }) + const cacheDirectory = join(projectDir, ".runtime-resources", "cache"); + await mkdir(cacheDirectory, { recursive: true }); const archivePath = join( cacheDirectory, - `${packageName}-${opencodeVersion}.tgz` - ) + `${packageName}-${opencodeVersion}.tgz`, + ); if (existsSync(archivePath)) { - const cached = await readFile(archivePath) + const cached = await readFile(archivePath); if (sha512Integrity(cached) === integrity) { - return archivePath + return archivePath; } - await rm(archivePath, { force: true }) + await rm(archivePath, { force: true }); } - const npm = npmInvocation() + const npm = npmInvocation(); const result = spawnSync( npm.command, [ ...npm.prefixArgs, - 'pack', + "pack", `${packageName}@${opencodeVersion}`, - '--ignore-scripts', - '--json', - '--pack-destination', - cacheDirectory + "--ignore-scripts", + "--json", + "--pack-destination", + cacheDirectory, ], { cwd: projectDir, - encoding: 'utf8', + encoding: "utf8", maxBuffer: 16 * 1024 * 1024, shell: false, - windowsHide: true - } - ) + windowsHide: true, + }, + ); if (result.status !== 0) { throw new Error( `Unable to fetch ${packageName}: ${ @@ -109,219 +107,107 @@ async function downloadPackage(projectDir, packageName, integrity) { result.stderr || result.stdout || `npm exited with ${result.status}` - }` - ) + }`, + ); } - const output = JSON.parse(result.stdout) - const downloadedPath = join(cacheDirectory, output[0].filename) - const contents = await readFile(downloadedPath) + const output = JSON.parse(result.stdout); + const downloadedPath = join(cacheDirectory, output[0].filename); + const contents = await readFile(downloadedPath); if (sha512Integrity(contents) !== integrity) { - await rm(downloadedPath, { force: true }) - throw new Error(`Integrity verification failed for ${packageName}`) + await rm(downloadedPath, { force: true }); + throw new Error(`Integrity verification failed for ${packageName}`); } if (downloadedPath !== archivePath) { - await rm(archivePath, { force: true }) - await rename(downloadedPath, archivePath) - } - return archivePath -} - -async function prepareBundledOpenCodeConfig(projectDir) { - const targetDirectory = join( - projectDir, - '.runtime-resources', - 'opencode-config' - ) - const pluginIntegrity = await lockedIntegrity( - projectDir, - opencodePluginPackage - ) - const identity = { - packageName: opencodePluginPackage, - version: opencodeVersion, - integrity: pluginIntegrity - } - try { - const ready = JSON.parse( - await readFile(join(targetDirectory, '.goodbuddy-ready.json'), 'utf8') - ) - if ( - ready.packageName === identity.packageName && - ready.version === identity.version && - ready.integrity === identity.integrity && - (await stat( - join( - targetDirectory, - 'node_modules', - '@opencode-ai', - 'plugin', - 'package.json' - ) - )).isFile() - ) { - return - } - } catch { - // Rebuild an incomplete or stale offline config template. - } - - const stagingDirectory = `${targetDirectory}.staging-${process.pid}` - await rm(stagingDirectory, { recursive: true, force: true }) - await mkdir(stagingDirectory, { recursive: true }) - try { - await writeFile( - join(stagingDirectory, 'package.json'), - `${JSON.stringify( - { - private: true, - dependencies: { - [opencodePluginPackage]: opencodeVersion - } - }, - null, - 2 - )}\n`, - 'utf8' - ) - const npm = npmInvocation() - const environment = { ...process.env } - delete environment.NODE_TLS_REJECT_UNAUTHORIZED - const result = spawnSync( - npm.command, - [ - ...npm.prefixArgs, - 'install', - '--ignore-scripts', - '--omit=dev', - '--omit=optional', - '--no-audit', - '--no-fund', - '--save-exact' - ], - { - cwd: stagingDirectory, - encoding: 'utf8', - env: environment, - maxBuffer: 16 * 1024 * 1024, - shell: false, - windowsHide: true - } - ) - if (result.status !== 0) { - throw new Error( - `Unable to prepare bundled OpenCode config: ${ - result.error?.message || - result.stderr || - result.stdout || - `npm exited with ${result.status}` - }` - ) - } - await writeFile( - join(stagingDirectory, '.goodbuddy-ready.json'), - `${JSON.stringify(identity)}\n`, - 'utf8' - ) - await rm(targetDirectory, { recursive: true, force: true }) - await rename(stagingDirectory, targetDirectory) - } finally { - await rm(stagingDirectory, { recursive: true, force: true }) + await rm(archivePath, { force: true }); + await rename(downloadedPath, archivePath); } + return archivePath; } module.exports = async function prepareBundledRuntimes(context) { - const platform = context.electronPlatformName - const architecture = architectureNames[context.arch] - const packagePlatform = platformNames[platform] + const platform = context.electronPlatformName; + const architecture = architectureNames[context.arch]; + const packagePlatform = platformNames[platform]; if (!architecture || !packagePlatform) { throw new Error( - `Bundled OpenCode does not support ${platform}/${context.arch}` - ) + `Bundled OpenCode does not support ${platform}/${context.arch}`, + ); } const suffix = - architecture === 'x64' ? `${architecture}-baseline` : architecture - const packageName = `opencode-${packagePlatform}-${suffix}` - const projectDir = context.packager.projectDir - await prepareBundledOpenCodeConfig(projectDir) + architecture === "x64" ? `${architecture}-baseline` : architecture; + const packageName = `opencode-${packagePlatform}-${suffix}`; + const projectDir = context.packager.projectDir; + await prepareBundledOpenCodeConfig(projectDir); const projectPackage = JSON.parse( - await readFile(join(projectDir, 'package.json'), 'utf8') - ) + await readFile(join(projectDir, "package.json"), "utf8"), + ); await writeFile( - join(projectDir, 'out', 'main', 'package.json'), + join(projectDir, "out", "main", "package.json"), `${JSON.stringify( { - name: '@deepseek-ai/dsh-llm', - version: - projectPackage.dependencies['@deepseek-ai/dsh-llm'], + name: "@deepseek-ai/dsh-llm", + version: projectPackage.dependencies["@deepseek-ai/dsh-llm"], private: true, - type: 'module' + type: "module", }, null, - 2 + 2, )}\n`, - 'utf8' - ) - const integrity = await lockedIntegrity(projectDir, packageName) - const targetDirectory = join( - projectDir, - '.runtime-resources', - architecture - ) - const readyPath = join(targetDirectory, '.ready.json') - const executable = platform === 'win32' ? 'opencode.exe' : 'opencode' - const preparedPath = join(targetDirectory, executable) + "utf8", + ); + const integrity = await lockedIntegrity(projectDir, packageName); + const targetDirectory = join(projectDir, ".runtime-resources", architecture); + const readyPath = join(targetDirectory, ".ready.json"); + const executable = platform === "win32" ? "opencode.exe" : "opencode"; + const preparedPath = join(targetDirectory, executable); const identity = { packageName, version: opencodeVersion, - integrity - } + integrity, + }; try { - const ready = JSON.parse(await readFile(readyPath, 'utf8')) + const ready = JSON.parse(await readFile(readyPath, "utf8")); if ( ready.packageName === identity.packageName && ready.version === identity.version && ready.integrity === identity.integrity && - typeof ready.executableSha256 === 'string' && - (platform === 'win32' || + typeof ready.executableSha256 === "string" && + (platform === "win32" || ((await stat(preparedPath)).mode & 0o111) !== 0) && (await sha256File(preparedPath)) === ready.executableSha256 ) { - return + return; } } catch { // Rebuild an incomplete or stale runtime cache. } - const archivePath = await downloadPackage( - projectDir, - packageName, - integrity - ) - const stagingDirectory = `${targetDirectory}.staging-${process.pid}` - await rm(stagingDirectory, { recursive: true, force: true }) - await mkdir(stagingDirectory, { recursive: true }) + const archivePath = await downloadPackage(projectDir, packageName, integrity); + const stagingDirectory = `${targetDirectory}.staging-${process.pid}`; + await rm(stagingDirectory, { recursive: true, force: true }); + await mkdir(stagingDirectory, { recursive: true }); try { await tar.x({ file: archivePath, cwd: stagingDirectory, - strip: 1 - }) - const sourcePath = join(stagingDirectory, 'bin', executable) - const stagingExecutable = join(stagingDirectory, executable) - await rename(sourcePath, stagingExecutable) - if (platform !== 'win32') { - await chmod(stagingExecutable, 0o755) + strip: 1, + }); + const sourcePath = join(stagingDirectory, "bin", executable); + const stagingExecutable = join(stagingDirectory, executable); + await rename(sourcePath, stagingExecutable); + if (platform !== "win32") { + await chmod(stagingExecutable, 0o755); } - const executableSha256 = await sha256File(stagingExecutable) + const executableSha256 = await sha256File(stagingExecutable); await writeFile( - join(stagingDirectory, '.ready.json'), + join(stagingDirectory, ".ready.json"), JSON.stringify({ ...identity, executableSha256 }), - 'utf8' - ) - await rm(targetDirectory, { recursive: true, force: true }) - await rename(stagingDirectory, targetDirectory) + "utf8", + ); + await rm(targetDirectory, { recursive: true, force: true }); + await rename(stagingDirectory, targetDirectory); } finally { - await rm(stagingDirectory, { recursive: true, force: true }) + await rm(stagingDirectory, { recursive: true, force: true }); } -} +}; diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index 2bb2d9bd..3f253f44 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -8,7 +8,7 @@ | --- | --- | | [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 | | [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 | -| [实现与验证进度](./progress.md) | 工作区改动的验证证据与剩余阻塞 | +| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 | 当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在 两份现有 PRD 中,后续实际维护该功能时按职责拆分。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index c23962e9..1eb13e29 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,23 @@ # 工作栏实现与验证进度 +## 2026-09-08 OpenCode 复用与原生控件恢复 + +- `OpenCodeRuntime` 复用缓存的 embedded server 前执行有超时的健康检查;健康则保留 + client,检查失败、抛错或超时则清除 client、会话映射、会话初始化及待答问题,关闭旧 + server 后重建。并发调用共享检查与重建,调用方取消只结束自身等待。 +- 请求级 MCP 注册与断开通过实例内 `mutateMcp` 队列串行执行。仅实际发出 `mcp.add` + 的名称进入清理列表;注册尚在排队时取消不追加断开。断开的 1 秒超时从出队后开始。 +- `App` 按本机/远程、连接方式、项目和 Runtime 选择组成 scope 缓存原生清单,用请求 + 代次阻止旧 scope 结果回写;同 scope 会话刷新不清空重取,切换会话重置消息级选择, + Runtime 切换状态也按 generation 隔离。清单请求失败或返回 `unavailable`/`partial` + 时保留已有可用缓存并有界重试;没有可用缓存时保留新返回的部分数据,因此 + `unavailable` 重试为可用 `partial` 后可显示 Agent 和 Command。 + +验证记录:首轮定向测试 273 项通过、5 项跳过;补充修复后的 Runtime 与生命周期测试 +77 项通过,界面定向测试 6 项通过,类型检查通过。修复前的 lint 检查通过。 +全量测试超过 120 秒被终止,期间出现一项 DS 本地 stdio MCP 测试失败,尚待定位。 +真实系统休眠恢复和当前源码的 GoodBuddy Agent 实机路径尚未完成验证。 + ## 2026-09-06 项目工作区 范围见 [PRD 工作区](./prd.md#76-工作区)。 diff --git a/src/agent-daemon/model-bridge-helper.ts b/src/agent-daemon/model-bridge-helper.ts index 27fe8c20..bde1ee5e 100644 --- a/src/agent-daemon/model-bridge-helper.ts +++ b/src/agent-daemon/model-bridge-helper.ts @@ -890,6 +890,26 @@ function credentialFreeHelperEnvironment( } environment[name] = value } + for (const name of [ + 'OPENCODE_CONFIG_DIR', + 'OPENCODE_DISABLE_AUTOUPDATE', + 'OPENCODE_DISABLE_CLAUDE_CODE_SKILLS', + 'OPENCODE_DISABLE_EMBEDDED_WEB_UI', + 'OPENCODE_DISABLE_EXTERNAL_SKILLS', + 'OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER', + 'OPENCODE_DISABLE_LSP_DOWNLOAD', + 'OPENCODE_DISABLE_MODELS_FETCH', + 'OPENCODE_DISABLE_PROJECT_CONFIG', + 'OPENCODE_DISABLE_SHARE' + ]) { + const value = source[name] + if (value !== undefined) { + if (value.includes('\0')) { + throw new Error(`Invalid helper environment value for ${name}`) + } + environment[name] = value + } + } return environment } diff --git a/src/agent-daemon/model-bridge.test.ts b/src/agent-daemon/model-bridge.test.ts index 20fa4efc..0f380e8a 100644 --- a/src/agent-daemon/model-bridge.test.ts +++ b/src/agent-daemon/model-bridge.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { chmodSync, existsSync, @@ -21,8 +22,12 @@ import { import { createOpenCodeModelBridgeProviderConfig, MODEL_BRIDGE_SDK_AUTH_SENTINEL, - ModelBridgeLoopbackProxy + ModelBridgeLoopbackProxy, + runOpenCodeModelBridgeHelper, + type ModelBridgeHelperSpawn } from './model-bridge-helper' +import { createOpenCodeLaunchProfile } from './opencode-runtime-profile' +import { createRuntimeBundleTestFixture } from './runtime-bundle-test-fixture' import { createUnixModelBridgeExchange, MODEL_BRIDGE_BROKER_SOCKET_NAME, @@ -51,6 +56,89 @@ afterEach(() => { }) describe('model bridge loopback helper', () => { + it.each(['ask', 'execute'] as const)( + 'preserves the %s launch profile offline resources in the final OpenCode spawn', + async (workMode) => { + const fixture = await createRuntimeBundleTestFixture() + temporaryPaths.push(fixture.root) + const socketPath = join(fixture.root, 'bridge', 'model.sock') + const profile = createOpenCodeLaunchProfile({ + ...fixture, + workspaceDirectory: fixture.root, + workMode, + modelBridge: { + agentExecutablePath: join(fixture.root, 'agent', 'goodbuddy-agent'), + bridgeDirectory: join(fixture.root, 'bridge'), + socketPath, + policy: { + protocol: 'anthropic-messages', + model: 'private-model', + modelProfileDigest: `sha256:${'9'.repeat(64)}`, + supportsImageInput: false + } + } + }) + const spawn = vi.fn(() => { + const child = new EventEmitter() + queueMicrotask(() => child.emit('close', 0, null)) + return child + }) + const resources = { + HOME: fixture.root, + PATH: join(fixture.root, 'tools'), + TMPDIR: join(fixture.root, 'tmp'), + XDG_CACHE_HOME: join(fixture.root, 'cache'), + XDG_CONFIG_HOME: join(fixture.root, 'config'), + XDG_DATA_HOME: join(fixture.root, 'data'), + XDG_STATE_HOME: join(fixture.root, 'state') + } + await expect(runOpenCodeModelBridgeHelper({ + socketPath, + protocol: 'anthropic-messages', + model: 'private-model', + supportsImageInput: false, + workMode, + opencodeEntrypoint: join(fixture.bundleDirectory, 'bin', 'opencode'), + environment: { + ...profile.env, + ...resources, + OPENCODE_CONFIG_CONTENT: '{"model":"must-not-be-used"}', + OPENCODE_CONFIG: '/unrelated/config.json', + ANTHROPIC_API_KEY: 'must-not-be-forwarded', + NODE_OPTIONS: '--inspect' + }, + spawn + })).resolves.toBe(0) + expect(spawn).toHaveBeenCalledOnce() + const [executable, args, options] = spawn.mock.calls[0]! + expect(executable).toBe(join(fixture.bundleDirectory, 'bin', 'opencode')) + expect(args).toEqual(['acp']) + expect(options).toMatchObject({ shell: false, stdio: 'inherit' }) + expect(options.env).toMatchObject({ + ...resources, + LANG: profile.env.LANG, + LC_ALL: profile.env.LC_ALL, + OPENCODE_CONFIG_DIR: join(fixture.bundleDirectory, 'config', 'opencode'), + OPENCODE_DISABLE_AUTOUPDATE: '1', + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', + OPENCODE_DISABLE_EMBEDDED_WEB_UI: '1', + OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: '1', + OPENCODE_DISABLE_LSP_DOWNLOAD: '1', + OPENCODE_DISABLE_MODELS_FETCH: '1', + OPENCODE_DISABLE_PROJECT_CONFIG: '1', + OPENCODE_DISABLE_SHARE: '1' + }) + expect(options.env.OPENCODE_CONFIG).toBeUndefined() + expect(options.env.ANTHROPIC_API_KEY).toBeUndefined() + expect(options.env.NODE_OPTIONS).toBeUndefined() + const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!) + expect(config.model).toBe('goodbuddy-anthropic/private-model') + expect(config.permission).toBe(workMode === 'ask' ? 'ask' : undefined) + expect(config.plugin).toHaveLength(1) + } + ) + it('forwards only the bounded request contract and returns the response', async () => { const exchange = vi.fn( async () => validResponse diff --git a/src/agent-daemon/opencode-runtime-profile.ts b/src/agent-daemon/opencode-runtime-profile.ts index e4baa4fd..66f85c8a 100644 --- a/src/agent-daemon/opencode-runtime-profile.ts +++ b/src/agent-daemon/opencode-runtime-profile.ts @@ -1,230 +1,216 @@ -import { - dirname, - isAbsolute, - join, - relative, - resolve, - sep -} from 'node:path' +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES, remoteRuntimeBundleManifestSchema, - type RemoteRuntimeBundleManifest -} from '../shared/remote-runtime-launch-contracts' + type RemoteRuntimeBundleManifest, +} from "../shared/remote-runtime-launch-contracts"; import { modelBridgePolicySchema, - type ModelBridgePolicy -} from '../shared/model-bridge-contracts' -import { - assertAbsoluteManagedPath -} from './managed-paths' + type ModelBridgePolicy, +} from "../shared/model-bridge-contracts"; +import { assertAbsoluteManagedPath } from "./managed-paths"; const OPEN_CODE_ASK_CONFIG = JSON.stringify({ - permission: 'ask', + permission: "ask", agent: { build: { - permission: 'ask' - } - } -}) + permission: "ask", + }, + }, +}); + +const OPEN_CODE_RUNTIME_ENVIRONMENT = Object.freeze({ + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1", + OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1", + OPENCODE_DISABLE_LSP_DOWNLOAD: "1", + OPENCODE_DISABLE_MODELS_FETCH: "1", + OPENCODE_DISABLE_PROJECT_CONFIG: "1", + OPENCODE_DISABLE_SHARE: "1", +}); export type OpenCodeLaunchProfile = { - executable: string - processExecutable: string - args: readonly string[] - cwd: string - env: Readonly - workMode: 'ask' | 'execute' -} + executable: string; + processExecutable: string; + args: readonly string[]; + cwd: string; + env: Readonly; + workMode: "ask" | "execute"; +}; export function createOpenCodeLaunchProfile(input: { - manifest: RemoteRuntimeBundleManifest - bundleDirectory: string - workspaceDirectory: string - workMode: 'ask' | 'execute' + manifest: RemoteRuntimeBundleManifest; + bundleDirectory: string; + workspaceDirectory: string; + workMode: "ask" | "execute"; modelBridge?: { - agentExecutablePath: string - bridgeDirectory: string - socketPath: string - policy: ModelBridgePolicy - } + agentExecutablePath: string; + bridgeDirectory: string; + socketPath: string; + policy: ModelBridgePolicy; + }; }): OpenCodeLaunchProfile { - const manifest = remoteRuntimeBundleManifestSchema.parse( - input.manifest - ) + const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest); if ( - manifest.runtimeId !== 'opencode' || - manifest.provider !== 'opencode' || - manifest.entrypoint.identity !== 'opencode-acp' || + manifest.runtimeId !== "opencode" || + manifest.provider !== "opencode" || + manifest.entrypoint.identity !== "opencode-acp" || manifest.entrypoint.argvPrefix.length !== 1 || - manifest.entrypoint.argvPrefix[0] !== 'acp' + manifest.entrypoint.argvPrefix[0] !== "acp" ) { - throw new Error('Runtime manifest is not the fixed OpenCode ACP profile') + throw new Error("Runtime manifest is not the fixed OpenCode ACP profile"); } if ( - [...manifest.allowedEnvironmentNames].sort().join('\0') !== - [...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES] - .sort() - .join('\0') + [...manifest.allowedEnvironmentNames].sort().join("\0") !== + [...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES].sort().join("\0") ) { throw new Error( - 'OpenCode Runtime environment allowlist does not match the fixed profile' - ) + "OpenCode Runtime environment allowlist does not match the fixed profile", + ); } const bundleDirectory = assertAbsoluteManagedPath( - resolve(input.bundleDirectory) - ) + resolve(input.bundleDirectory), + ); const workspaceDirectory = normalizedAbsolutePath( input.workspaceDirectory, - 'Workspace' - ) + "Workspace", + ); const modelBridge = input.modelBridge === undefined ? undefined : { agentExecutablePath: normalizedAbsolutePath( input.modelBridge.agentExecutablePath, - 'Agent executable' + "Agent executable", ), agentInstallationDirectory: normalizedAbsolutePath( dirname(input.modelBridge.agentExecutablePath), - 'Agent installation' + "Agent installation", ), bridgeDirectory: normalizedAbsolutePath( input.modelBridge.bridgeDirectory, - 'Model bridge' + "Model bridge", ), socketPath: normalizedAbsolutePath( input.modelBridge.socketPath, - 'Model bridge socket' + "Model bridge socket", ), - policy: modelBridgePolicySchema.parse( - input.modelBridge.policy - ) - } + policy: modelBridgePolicySchema.parse(input.modelBridge.policy), + }; if ( modelBridge !== undefined && - (!isStrictDescendant( - modelBridge.bridgeDirectory, - modelBridge.socketPath - ) || + (!isStrictDescendant(modelBridge.bridgeDirectory, modelBridge.socketPath) || !isStrictDescendant( modelBridge.agentInstallationDirectory, - modelBridge.agentExecutablePath + modelBridge.agentExecutablePath, )) ) { throw new Error( - 'Model bridge socket or Agent executable escapes its managed directory' - ) + "Model bridge socket or Agent executable escapes its managed directory", + ); } const executablePath = resolve( bundleDirectory, - ...manifest.entrypoint.path.split('/') - ) + ...manifest.entrypoint.path.split("/"), + ); if (!isStrictDescendant(bundleDirectory, executablePath)) { - throw new Error('OpenCode entrypoint escapes its bundle directory') + throw new Error("OpenCode entrypoint escapes its bundle directory"); } const runtimeCommand = modelBridge === undefined ? { executable: executablePath, - args: [...manifest.entrypoint.argvPrefix] + args: [...manifest.entrypoint.argvPrefix], } : { - executable: join( - modelBridge.agentInstallationDirectory, - 'node' - ), + executable: join(modelBridge.agentInstallationDirectory, "node"), args: [ - join( - modelBridge.agentInstallationDirectory, - 'lib', - 'agent.cjs' - ), - 'model-bridge-helper', - '--socket-path', + join(modelBridge.agentInstallationDirectory, "lib", "agent.cjs"), + "model-bridge-helper", + "--socket-path", modelBridge.socketPath, - '--protocol', + "--protocol", modelBridge.policy.protocol, - '--model', + "--model", modelBridge.policy.model, - '--supports-image-input', - modelBridge.policy.supportsImageInput ? 'true' : 'false', - '--work-mode', + "--supports-image-input", + modelBridge.policy.supportsImageInput ? "true" : "false", + "--work-mode", input.workMode, - '--opencode-entrypoint', - executablePath - ] - } + "--opencode-entrypoint", + executablePath, + ], + }; + const environmentBase = { + ...executeEnvironment(process.env, workspaceDirectory), + ...OPEN_CODE_RUNTIME_ENVIRONMENT, + OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode"), + }; const environment = - input.workMode === 'ask' + input.workMode === "ask" ? { - ...executeEnvironment(process.env, workspaceDirectory), - OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG + ...environmentBase, + OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG, } - : executeEnvironment(process.env, workspaceDirectory) + : environmentBase; return { ...runtimeCommand, processExecutable: runtimeCommand.executable, cwd: workspaceDirectory, env: environment, - workMode: input.workMode - } + workMode: input.workMode, + }; } function normalizedAbsolutePath(value: string, label: string): string { - if ( - value.includes('\0') || - !isAbsolute(value) || - resolve(value) !== value - ) { - throw new Error(`${label} path must be a normalized absolute path`) + if (value.includes("\0") || !isAbsolute(value) || resolve(value) !== value) { + throw new Error(`${label} path must be a normalized absolute path`); } - return value + return value; } function executeEnvironment( source: Readonly, - workspaceDirectory: string + workspaceDirectory: string, ): Readonly { const home = usableEnvironmentValue(source.HOME) ? source.HOME! - : workspaceDirectory + : workspaceDirectory; const defaults = { HOME: home, - LANG: 'C.UTF-8', - LC_ALL: 'C.UTF-8', - PATH: '/usr/bin:/bin', - TMPDIR: '/tmp', - XDG_CACHE_HOME: join(home, '.cache'), - XDG_CONFIG_HOME: join(home, '.config'), - XDG_DATA_HOME: join(home, '.local', 'share'), - XDG_STATE_HOME: join(home, '.local', 'state') - } as const - const environment: NodeJS.ProcessEnv = { ...source } + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8", + PATH: "/usr/bin:/bin", + TMPDIR: "/tmp", + XDG_CACHE_HOME: join(home, ".cache"), + XDG_CONFIG_HOME: join(home, ".config"), + XDG_DATA_HOME: join(home, ".local", "share"), + XDG_STATE_HOME: join(home, ".local", "state"), + } as const; + const environment: NodeJS.ProcessEnv = { ...source }; for (const [name, fallback] of Object.entries(defaults)) { if (!usableEnvironmentValue(environment[name])) { - environment[name] = fallback + environment[name] = fallback; } } - return environment + return environment; } -function usableEnvironmentValue( - value: string | undefined -): value is string { - return value !== undefined && value.length > 0 && !value.includes('\0') +function usableEnvironmentValue(value: string | undefined): value is string { + return value !== undefined && value.length > 0 && !value.includes("\0"); } function isStrictDescendant(parent: string, candidate: string): boolean { - const child = relative(parent, candidate) + const child = relative(parent, candidate); return ( child.length > 0 && - child !== '..' && + child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child) - ) + ); } diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index a5e1cb2c..6c98d887 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -1,106 +1,106 @@ -import { EventEmitter } from 'node:events' -import { createServer } from 'node:http' +import { EventEmitter } from "node:events"; +import { createServer } from "node:http"; import { mkdir, mkdtemp, readFile, rm, stat, - writeFile -} from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { PassThrough } from 'node:stream' -import type { createOpencodeClient } from '@opencode-ai/sdk/v2' -import type spawn from 'cross-spawn' -import { describe, expect, it, vi } from 'vitest' -import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway' -import type { RuntimeEvent } from './runtime' + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { PassThrough } from "node:stream"; +import type { createOpencodeClient } from "@opencode-ai/sdk/v2"; +import type spawn from "cross-spawn"; +import { describe, expect, it, vi } from "vitest"; +import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway"; +import type { RuntimeEvent } from "./runtime"; import { OpenCodeRuntime, - type OpenCodeRuntimeDependencies -} from './opencode-runtime' + type OpenCodeRuntimeDependencies, +} from "./opencode-runtime"; -type SpawnedProcess = ReturnType +type SpawnedProcess = ReturnType; function fakeChild(pid = 42): SpawnedProcess { const child = new EventEmitter() as EventEmitter & { - stdout: PassThrough - stderr: PassThrough - exitCode: number | null - killed: boolean - pid: number - kill: ReturnType - unref: ReturnType - } - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.exitCode = null - child.killed = false - child.pid = pid + stdout: PassThrough; + stderr: PassThrough; + exitCode: number | null; + killed: boolean; + pid: number; + kill: ReturnType; + unref: ReturnType; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.exitCode = null; + child.killed = false; + child.pid = pid; child.kill = vi.fn(() => { - child.killed = true + child.killed = true; queueMicrotask(() => { - child.exitCode = 0 - child.emit('close', 0, null) - }) - return true - }) - child.unref = vi.fn(() => child) - return child as unknown as SpawnedProcess + child.exitCode = 0; + child.emit("close", 0, null); + }); + return true; + }); + child.unref = vi.fn(() => child); + return child as unknown as SpawnedProcess; } function fakeClient() { return { session: { - list: vi.fn().mockResolvedValue({ data: [], error: undefined }) - } - } as unknown as ReturnType + list: vi.fn().mockResolvedValue({ data: [], error: undefined }), + }, + } as unknown as ReturnType; } function stderrOf(child: SpawnedProcess): PassThrough { - return child.stderr as PassThrough + return child.stderr as PassThrough; } function closeChild(child: SpawnedProcess, code: number): void { - ;(child as unknown as { exitCode: number | null }).exitCode = code - child.emit('close', code, null) + (child as unknown as { exitCode: number | null }).exitCode = code; + child.emit("close", code, null); } function options( - overrides: Partial[0]> = {} + overrides: Partial[0]> = {}, ): ConstructorParameters[0] { return { embedded: true, - binaryPath: '', - configPath: '', + binaryPath: "", + configPath: "", defaultWorkspace: process.cwd(), - ...overrides - } + ...overrides, + }; } function dependencies( child: SpawnedProcess, - overrides: Partial = {} + overrides: Partial = {}, ): { - deps: Partial - spawnMock: ReturnType - detectBinary: ReturnType - validateBinary: ReturnType - createClient: ReturnType - checkServerHealth: ReturnType + deps: Partial; + spawnMock: ReturnType; + detectBinary: ReturnType; + validateBinary: ReturnType; + createClient: ReturnType; + checkServerHealth: ReturnType; } { - const spawnMock = vi.fn(() => child) + const spawnMock = vi.fn(() => child); const detectBinary = vi.fn().mockResolvedValue({ - path: 'opencode', - detail: 'OpenCode CLI 已就绪' - }) + path: "opencode", + detail: "OpenCode CLI 已就绪", + }); const validateBinary = vi.fn().mockResolvedValue({ - path: 'opencode', - detail: 'OpenCode CLI 已就绪' - }) - const createClient = vi.fn(() => fakeClient()) - const checkServerHealth = vi.fn().mockResolvedValue(true) + path: "opencode", + detail: "OpenCode CLI 已就绪", + }); + const createClient = vi.fn(() => fakeClient()); + const checkServerHealth = vi.fn().mockResolvedValue(true); return { deps: { spawn: spawnMock as unknown as typeof spawn, @@ -108,146 +108,140 @@ function dependencies( validateBinary, createClient: createClient as unknown as typeof createOpencodeClient, checkServerHealth, - platform: 'linux', + platform: "linux", startupTimeoutMs: 100, - ...overrides + ...overrides, }, spawnMock, detectBinary, validateBinary, createClient, - checkServerHealth - } + checkServerHealth, + }; } function permissionEvent( - overrides: Record = {} + overrides: Record = {}, ): Record { return { - id: 'event-1', - type: 'permission.asked', + id: "event-1", + type: "permission.asked", properties: { - id: 'permission-1', - sessionID: 'session-1', - permission: 'bash', - patterns: ['npm test'], - metadata: { command: 'npm test' }, - always: ['npm test'], + id: "permission-1", + sessionID: "session-1", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + always: ["npm test"], tool: { - messageID: 'message-1', - callID: 'call-1' + messageID: "message-1", + callID: "call-1", }, - ...overrides - } - } + ...overrides, + }, + }; } function completedToolEvent( - callId = 'call-1', - tool = 'bash' + callId = "call-1", + tool = "bash", ): Record { return { id: `event-tool-${callId}`, - type: 'message.part.updated', + type: "message.part.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", part: { id: `part-${callId}`, callID: callId, - type: 'tool', + type: "tool", tool, state: { - status: 'completed', + status: "completed", input: { - command: 'npm test', - token: 'visible-token' + command: "npm test", + token: "visible-token", }, - output: 'Tests passed\nAuthorization: Bearer secret-token' - } - } - } - } + output: "Tests passed\nAuthorization: Bearer secret-token", + }, + }, + }, + }; } function runClient(events: Record[]) { - const callOrder: string[] = [] + const callOrder: string[] = []; const permissionReply = vi.fn().mockResolvedValue({ data: true, - error: undefined - }) + error: undefined, + }); const questionReply = vi.fn().mockResolvedValue({ data: true, - error: undefined - }) + error: undefined, + }); const questionReject = vi.fn().mockResolvedValue({ data: true, - error: undefined - }) + error: undefined, + }); const client = { session: { list: vi.fn().mockResolvedValue({ data: [], error: undefined }), create: vi.fn().mockResolvedValue({ - data: { id: 'session-1' }, - error: undefined + data: { id: "session-1" }, + error: undefined, }), update: vi.fn().mockResolvedValue({ - data: { id: 'session-1' }, - error: undefined + data: { id: "session-1" }, + error: undefined, }), promptAsync: vi.fn().mockImplementation(async () => { - callOrder.push('prompt') - return { data: true, error: undefined } + callOrder.push("prompt"); + return { data: true, error: undefined }; }), abort: vi.fn().mockResolvedValue({ data: true, - error: undefined + error: undefined, }), delete: vi.fn().mockResolvedValue({ data: true, - error: undefined - }) + error: undefined, + }), }, event: { subscribe: vi.fn().mockImplementation(async () => { - callOrder.push('subscribe') + callOrder.push("subscribe"); return { stream: (async function* () { for (const event of events) { - yield event + yield event; } - })() - } - }) + })(), + }; + }), }, permission: { - reply: permissionReply + reply: permissionReply, }, question: { reply: questionReply, - reject: questionReject + reject: questionReject, }, mcp: { - add: vi - .fn() - .mockImplementation( - async (input: { name: string }) => ({ - data: { - [input.name]: { status: 'connected' } - }, - error: undefined - }) - ), - disconnect: vi - .fn() - .mockResolvedValue({ data: true, error: undefined }) + add: vi.fn().mockImplementation(async (input: { name: string }) => ({ + data: { + [input.name]: { status: "connected" }, + }, + error: undefined, + })), + disconnect: vi.fn().mockResolvedValue({ data: true, error: undefined }), }, tool: { ids: vi.fn().mockResolvedValue({ - data: ['read', 'write', 'bash', 'task'], - error: undefined - }) - } - } as unknown as ReturnType + data: ["read", "write", "bash", "task"], + error: undefined, + }), + }, + } as unknown as ReturnType; return { client, callOrder, @@ -256,2259 +250,2276 @@ function runClient(events: Record[]) { questionReject, session: client.session, event: client.event, - tool: client.tool - } + tool: client.tool, + }; } function embeddedRuntime( client: ReturnType, - overrides: Partial< - ConstructorParameters[0] - > = {}, - dependencyOverrides: Partial = {} + overrides: Partial[0]> = {}, + dependencyOverrides: Partial = {}, ): OpenCodeRuntime { - const child = fakeChild() + const child = fakeChild(); const { deps } = dependencies(child, { ...dependencyOverrides, - createClient: vi.fn( - () => client - ) as unknown as typeof createOpencodeClient - }) - return new OpenCodeRuntime(options(overrides), deps) + createClient: vi.fn(() => client) as unknown as typeof createOpencodeClient, + }); + return new OpenCodeRuntime(options(overrides), deps); } async function collectRun( runtime: OpenCodeRuntime, - workMode: 'ask' | 'execute' = 'execute' + workMode: "ask" | "execute" = "execute", ) { - const events = [] + const events = []; for await (const event of runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'test', - workMode + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "test", + workMode, }, - new AbortController().signal + new AbortController().signal, )) { - events.push(event) + events.push(event); } - return events + return events; } -describe('OpenCodeRuntime embedded launcher', () => { - it('checks embedded availability without starting OpenCode', async () => { - const child = fakeChild() +describe("OpenCodeRuntime embedded launcher", () => { + it("checks embedded availability without starting OpenCode", async () => { + const child = fakeChild(); const { deps, spawnMock, detectBinary, validateBinary, createClient, - checkServerHealth - } = dependencies(child) - const runtime = new OpenCodeRuntime(options(), deps) + checkServerHealth, + } = dependencies(child); + const runtime = new OpenCodeRuntime(options(), deps); await expect(runtime.getStatus()).resolves.toMatchObject({ available: true, - detail: 'OpenCode 已配置,将在首次使用时启动' - }) - expect(detectBinary).toHaveBeenCalledWith( - 'opencode', - '', - undefined - ) - expect(validateBinary).toHaveBeenCalledWith( - 'opencode', - 'opencode' - ) - expect(spawnMock).not.toHaveBeenCalled() - expect(createClient).not.toHaveBeenCalled() - expect(checkServerHealth).not.toHaveBeenCalled() + detail: "OpenCode 已配置,将在首次使用时启动", + }); + expect(detectBinary).toHaveBeenCalledWith("opencode", "", undefined); + expect(validateBinary).toHaveBeenCalledWith("opencode", "opencode"); + expect(spawnMock).not.toHaveBeenCalled(); + expect(createClient).not.toHaveBeenCalled(); + expect(checkServerHealth).not.toHaveBeenCalled(); - await runtime.dispose() - }) + await runtime.dispose(); + }); - it('reports an unavailable embedded binary without starting OpenCode', async () => { - const child = fakeChild() + it("reports an unavailable embedded binary without starting OpenCode", async () => { + const child = fakeChild(); const { deps, spawnMock, detectBinary, validateBinary, createClient, - checkServerHealth - } = dependencies(child) + checkServerHealth, + } = dependencies(child); detectBinary.mockResolvedValueOnce({ - path: '/missing/opencode', - detail: 'OpenCode CLI 已就绪' - }) + path: "/missing/opencode", + detail: "OpenCode CLI 已就绪", + }); validateBinary.mockResolvedValueOnce({ - detail: 'OpenCode CLI 未找到' - }) + detail: "OpenCode CLI 未找到", + }); const runtime = new OpenCodeRuntime( options({ - binaryPath: '/missing/opencode', - bundledBinaryPath: '/bundled/opencode' + binaryPath: "/missing/opencode", + bundledBinaryPath: "/bundled/opencode", }), - deps - ) + deps, + ); await expect(runtime.getStatus()).resolves.toEqual({ - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: false, supportsToolExecution: true, - detail: 'OpenCode CLI 未找到' - }) - expect(spawnMock).not.toHaveBeenCalled() + detail: "OpenCode CLI 未找到", + }); + expect(spawnMock).not.toHaveBeenCalled(); expect(detectBinary).toHaveBeenCalledWith( - 'opencode', - '/missing/opencode', - '/bundled/opencode' - ) + "opencode", + "/missing/opencode", + "/bundled/opencode", + ); expect(validateBinary).toHaveBeenCalledWith( - 'opencode', - '/missing/opencode' - ) - expect(createClient).not.toHaveBeenCalled() - expect(checkServerHealth).not.toHaveBeenCalled() + "opencode", + "/missing/opencode", + ); + expect(createClient).not.toHaveBeenCalled(); + expect(checkServerHealth).not.toHaveBeenCalled(); - await runtime.dispose() - }) + await runtime.dispose(); + }); - it('reports a missing embedded model credential without starting OpenCode', async () => { - const child = fakeChild() + it("reports a missing embedded model credential without starting OpenCode", async () => { + const child = fakeChild(); const { deps, spawnMock, detectBinary, validateBinary, createClient, - checkServerHealth - } = dependencies(child) + checkServerHealth, + } = dependencies(child); const runtime = new OpenCodeRuntime( options({ modelProfile: { - id: '00000000-0000-4000-8000-000000000011', - name: 'Independent model', - baseUrl: 'https://model.example', - modelName: 'private-model', - protocol: 'anthropic-messages', - authentication: 'api-key' - } + id: "00000000-0000-4000-8000-000000000011", + name: "Independent model", + baseUrl: "https://model.example", + modelName: "private-model", + protocol: "anthropic-messages", + authentication: "api-key", + }, }), - deps - ) + deps, + ); await expect(runtime.getStatus()).resolves.toEqual({ - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: false, supportsToolExecution: true, - detail: 'OpenCode 独立模型连接尚未配置 API Key' - }) - expect(detectBinary).not.toHaveBeenCalled() - expect(validateBinary).not.toHaveBeenCalled() - expect(spawnMock).not.toHaveBeenCalled() - expect(createClient).not.toHaveBeenCalled() - expect(checkServerHealth).not.toHaveBeenCalled() + detail: "OpenCode 独立模型连接尚未配置 API Key", + }); + expect(detectBinary).not.toHaveBeenCalled(); + expect(validateBinary).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + expect(createClient).not.toHaveBeenCalled(); + expect(checkServerHealth).not.toHaveBeenCalled(); - await runtime.dispose() - }) + await runtime.dispose(); + }); - it('uses the detected binary and passes an absolute config path only through env', async () => { - const serverChild = fakeChild(314) - const killerChild = fakeChild(315) + it("uses the detected binary and passes an absolute config path only through env", async () => { + const serverChild = fakeChild(314); + const killerChild = fakeChild(315); const detectBinary = vi.fn().mockResolvedValue({ - path: 'C:\\Tools\\opencode.exe', - detail: 'OpenCode CLI 已就绪' - }) - const createClient = vi.fn(() => fakeClient()) - const checkServerHealth = vi.fn().mockResolvedValue(true) + path: "C:\\Tools\\opencode.exe", + detail: "OpenCode CLI 已就绪", + }); + const createClient = vi.fn(() => fakeClient()); + const checkServerHealth = vi.fn().mockResolvedValue(true); const spawnMock = vi.fn((command: string) => { - if (command === 'taskkill.exe') { + if (command === "taskkill.exe") { queueMicrotask(() => { - closeChild(serverChild, 0) - }) - return killerChild + closeChild(serverChild, 0); + }); + return killerChild; } - return serverChild - }) - const configPath = './private/opencode.json' + return serverChild; + }); + const configPath = "./private/opencode.json"; const runtime = new OpenCodeRuntime( options({ - binaryPath: 'C:\\Configured\\opencode.exe', + binaryPath: "C:\\Configured\\opencode.exe", configPath, launchEnvironmentProvider: () => Object.freeze({ - PATH: 'C:\\GoodBuddy\\tools;C:\\Windows', - OPENAI_API_KEY: 'must-not-leak', - ELECTRON_RUN_AS_NODE: '1' - }) + PATH: "C:\\GoodBuddy\\tools;C:\\Windows", + OPENAI_API_KEY: "must-not-leak", + ELECTRON_RUN_AS_NODE: "1", + }), }), { spawn: spawnMock as unknown as typeof spawn, detectBinary, createClient: createClient as unknown as typeof createOpencodeClient, checkServerHealth, - platform: 'win32' - } - ) + platform: "win32", + }, + ); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, - detail: - '由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程' - }) + detail: "由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程", + }); expect(detectBinary).toHaveBeenCalledWith( - 'opencode', - 'C:\\Configured\\opencode.exe', - undefined - ) + "opencode", + "C:\\Configured\\opencode.exe", + undefined, + ); expect(spawnMock).toHaveBeenNthCalledWith( 1, - 'C:\\Tools\\opencode.exe', - [ - 'serve', - '--hostname=127.0.0.1', - expect.stringMatching(/^--port=\d+$/u) - ], + "C:\\Tools\\opencode.exe", + ["serve", "--hostname=127.0.0.1", expect.stringMatching(/^--port=\d+$/u)], expect.objectContaining({ cwd: process.cwd(), shell: false, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: expect.objectContaining({ - OPENCODE_CONFIG: resolve(configPath) - }) - }) - ) + OPENCODE_CONFIG: resolve(configPath), + }), + }), + ); const firstSpawn = spawnMock.mock.calls[0] as unknown as - | [string, string[]] - | undefined + [string, string[]] | undefined; const serverPort = firstSpawn?.[1] - ?.find((argument) => argument.startsWith('--port=')) - ?.slice('--port='.length) - expect(serverPort).toMatch(/^\d+$/u) - const serverUrl = `http://127.0.0.1:${serverPort}` + ?.find((argument) => argument.startsWith("--port=")) + ?.slice("--port=".length); + expect(serverPort).toMatch(/^\d+$/u); + const serverUrl = `http://127.0.0.1:${serverPort}`; const clientOptions = ( createClient.mock.calls as unknown as Array< [ { - baseUrl?: string - directory?: string - headers?: Record - } + baseUrl?: string; + directory?: string; + headers?: Record; + }, ] > )[0]?.[0] as | { - baseUrl?: string - directory?: string - headers?: Record + baseUrl?: string; + directory?: string; + headers?: Record; } - | undefined + | undefined; expect(clientOptions).toMatchObject({ baseUrl: serverUrl, directory: process.cwd(), headers: { - Authorization: expect.stringMatching(/^Basic /u) - } - }) + Authorization: expect.stringMatching(/^Basic /u), + }, + }); expect(checkServerHealth).toHaveBeenCalledWith( serverUrl, clientOptions?.headers?.Authorization, - expect.any(AbortSignal) - ) + expect.any(AbortSignal), + ); const spawnOptions = ( spawnMock.mock.calls as unknown as Array< [string, string[], { env?: NodeJS.ProcessEnv }] > - )[0]?.[2] as - | { env?: NodeJS.ProcessEnv } - | undefined - expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe( - 'goodbuddy' - ) - expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).toBeTruthy() - expect(spawnOptions?.env?.PATH).toBe( - 'C:\\GoodBuddy\\tools;C:\\Windows' - ) - expect(spawnOptions?.env).not.toHaveProperty( - 'ELECTRON_RUN_AS_NODE' - ) + )[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe("goodbuddy"); + expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).toBeTruthy(); + expect(spawnOptions?.env?.PATH).toBe("C:\\GoodBuddy\\tools;C:\\Windows"); + expect(spawnOptions?.env).not.toHaveProperty("ELECTRON_RUN_AS_NODE"); expect( Buffer.from( - clientOptions?.headers?.Authorization?.slice(6) ?? '', - 'base64' - ).toString() - ).toBe( - `goodbuddy:${spawnOptions?.env?.OPENCODE_SERVER_PASSWORD}` - ) - expect(runtime.requiresToolApproval).toBe(false) + clientOptions?.headers?.Authorization?.slice(6) ?? "", + "base64", + ).toString(), + ).toBe(`goodbuddy:${spawnOptions?.env?.OPENCODE_SERVER_PASSWORD}`); + expect(runtime.requiresToolApproval).toBe(false); - await runtime.dispose() + await runtime.dispose(); expect(spawnMock).toHaveBeenNthCalledWith( 2, - 'taskkill.exe', - ['/PID', '314', '/T', '/F'], + "taskkill.exe", + ["/PID", "314", "/T", "/F"], { shell: false, - stdio: 'ignore', - windowsHide: true - } - ) - expect(killerChild.unref).toHaveBeenCalledOnce() - }) + stdio: "ignore", + windowsHide: true, + }, + ); + expect(killerChild.unref).toHaveBeenCalledOnce(); + }); - it('registers only assigned Skill packages in an isolated config directory', async () => { + it("registers only assigned Skill packages in an isolated config directory", async () => { const sourceRoot = await mkdtemp( - join(tmpdir(), 'goodbuddy-opencode-skill-source-') - ) - const skillDirectory = join(sourceRoot, 'longdoc-docx') - await mkdir(join(skillDirectory, 'templates'), { - recursive: true - }) + join(tmpdir(), "goodbuddy-opencode-skill-source-"), + ); + const skillDirectory = join(sourceRoot, "longdoc-docx"); + await mkdir(join(skillDirectory, "templates"), { + recursive: true, + }); await writeFile( - join(skillDirectory, 'SKILL.md'), + join(skillDirectory, "SKILL.md"), [ - '---', - 'id: longdoc-docx', - 'name: 长文档', - 'description: Build a DOCX', - '---', - '', - '# Long document' - ].join('\n'), - 'utf8' - ) + "---", + "id: longdoc-docx", + "name: 长文档", + "description: Build a DOCX", + "---", + "", + "# Long document", + ].join("\n"), + "utf8", + ); await writeFile( - join(skillDirectory, 'templates', 'document.txt'), - 'template', - 'utf8' - ) - const child = fakeChild() - const { deps, spawnMock } = dependencies(child) + join(skillDirectory, "templates", "document.txt"), + "template", + "utf8", + ); + const child = fakeChild(); + const { deps, spawnMock } = dependencies(child); const runtime = new OpenCodeRuntime( options({ skillPackages: [ { - id: 'longdoc-docx', - directory: skillDirectory - } - ] + id: "longdoc-docx", + directory: skillDirectory, + }, + ], }), - deps - ) + deps, + ); await expect(runtime.testConnection()).resolves.toMatchObject({ - available: true - }) + available: true, + }); const spawnOptions = spawnMock.mock.calls[0]?.[2] as - | { env?: NodeJS.ProcessEnv } - | undefined - const configDirectory = spawnOptions?.env?.OPENCODE_CONFIG_DIR - expect(configDirectory).toBeTruthy() - const registrationRoot = resolve(configDirectory!, '..') - const registeredSkill = join( - configDirectory!, - 'skills', - 'longdoc-docx' - ) + { env?: NodeJS.ProcessEnv } | undefined; + const configDirectory = spawnOptions?.env?.OPENCODE_CONFIG_DIR; + expect(configDirectory).toBeTruthy(); + const registrationRoot = resolve(configDirectory!, ".."); + const registeredSkill = join(configDirectory!, "skills", "longdoc-docx"); try { await expect( - readFile( - join(registeredSkill, 'templates', 'document.txt'), - 'utf8' - ) - ).resolves.toBe('template') + readFile(join(registeredSkill, "templates", "document.txt"), "utf8"), + ).resolves.toBe("template"); const registeredManifest = await readFile( - join(registeredSkill, 'SKILL.md'), - 'utf8' - ) - expect(registeredManifest).toContain('name: longdoc-docx') - expect(registeredManifest).not.toContain('id: longdoc-docx') + join(registeredSkill, "SKILL.md"), + "utf8", + ); + expect(registeredManifest).toContain("name: longdoc-docx"); + expect(registeredManifest).not.toContain("id: longdoc-docx"); const config = JSON.parse( - spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? '{}' - ) as Record + spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}", + ) as Record; expect(config).toEqual({ skills: { - paths: [join(configDirectory!, 'skills')], - urls: [] + paths: [join(configDirectory!, "skills")], + urls: [], }, permission: { skill: { - '*': 'deny', - 'longdoc-docx': 'allow' - } - } - }) + "*": "deny", + "longdoc-docx": "allow", + }, + }, + }); expect(spawnOptions?.env).toMatchObject({ - OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', - OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', - OPENCODE_DISABLE_PROJECT_CONFIG: '1', - XDG_CACHE_HOME: join(registrationRoot, 'xdg-cache'), - XDG_CONFIG_HOME: join(registrationRoot, 'xdg-config'), - XDG_DATA_HOME: join(registrationRoot, 'xdg-data'), - XDG_STATE_HOME: join(registrationRoot, 'xdg-state') - }) + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", + OPENCODE_DISABLE_PROJECT_CONFIG: "1", + XDG_CACHE_HOME: join(registrationRoot, "xdg-cache"), + XDG_CONFIG_HOME: join(registrationRoot, "xdg-config"), + XDG_DATA_HOME: join(registrationRoot, "xdg-data"), + XDG_STATE_HOME: join(registrationRoot, "xdg-state"), + }); } finally { - await runtime.dispose() - await rm(sourceRoot, { recursive: true, force: true }) + await runtime.dispose(); + await rm(sourceRoot, { recursive: true, force: true }); } - await expect(stat(registrationRoot)).rejects.toThrow() - }) + await expect(stat(registrationRoot)).rejects.toThrow(); + }); - it('reuses shared OpenCode config and content-addressed Skills across projects', async () => { + it("reuses shared OpenCode config and content-addressed Skills across projects", async () => { const sourceRoot = await mkdtemp( - join(tmpdir(), 'goodbuddy-opencode-shared-skill-source-') - ) + join(tmpdir(), "goodbuddy-opencode-shared-skill-source-"), + ); const sharedCacheRoot = await mkdtemp( - join(tmpdir(), 'goodbuddy-opencode-shared-cache-') - ) - const skillDirectory = join(sourceRoot, 'shared-skill') - const bundledConfigPath = join(sourceRoot, 'bundled-config') - await mkdir(skillDirectory, { recursive: true }) + join(tmpdir(), "goodbuddy-opencode-shared-cache-"), + ); + const skillDirectory = join(sourceRoot, "shared-skill"); + const bundledConfigPath = join(sourceRoot, "bundled-config"); + await mkdir(skillDirectory, { recursive: true }); await mkdir( - join( - bundledConfigPath, - 'node_modules', - '@opencode-ai', - 'plugin' - ), - { recursive: true } - ) + join(bundledConfigPath, "node_modules", "@opencode-ai", "plugin"), + { recursive: true }, + ); await writeFile( - join(bundledConfigPath, '.goodbuddy-ready.json'), + join(bundledConfigPath, ".goodbuddy-ready.json"), '{"version":"1.18.29"}\n', - 'utf8' - ) + "utf8", + ); await writeFile( join( bundledConfigPath, - 'node_modules', - '@opencode-ai', - 'plugin', - 'package.json' + "node_modules", + "@opencode-ai", + "plugin", + "package.json", ), '{"name":"@opencode-ai/plugin","version":"1.18.29"}\n', - 'utf8' - ) + "utf8", + ); await writeFile( - join(skillDirectory, 'SKILL.md'), + join(skillDirectory, "SKILL.md"), [ - '---', - 'name: Shared Skill', - 'description: Shared test Skill', - '---', - '', - '# Shared Skill' - ].join('\n'), - 'utf8' - ) + "---", + "name: Shared Skill", + "description: Shared test Skill", + "---", + "", + "# Shared Skill", + ].join("\n"), + "utf8", + ); const skillPackages = [ { - id: 'shared-skill', + id: "shared-skill", directory: skillDirectory, - digest: 'shared-skill-digest' - } - ] - const firstChild = fakeChild() - const first = dependencies(firstChild) + digest: "shared-skill-digest", + }, + ]; + const firstChild = fakeChild(); + const first = dependencies(firstChild); const firstRuntime = new OpenCodeRuntime( options({ sharedCacheRoot, skillPackages, - bundledConfigPath + bundledConfigPath, }), - first.deps - ) - const secondChild = fakeChild() - const second = dependencies(secondChild) + first.deps, + ); + const secondChild = fakeChild(); + const second = dependencies(secondChild); const secondRuntime = new OpenCodeRuntime( options({ sharedCacheRoot, skillPackages, - bundledConfigPath + bundledConfigPath, }), - second.deps - ) + second.deps, + ); try { await expect(firstRuntime.testConnection()).resolves.toMatchObject({ - available: true - }) - const firstEnvironment = first.spawnMock.mock.calls[0]?.[2]?.env - const firstConfigDirectory = - firstEnvironment?.OPENCODE_CONFIG_DIR ?? '' + available: true, + }); + const firstEnvironment = first.spawnMock.mock.calls[0]?.[2]?.env; + const firstConfigDirectory = firstEnvironment?.OPENCODE_CONFIG_DIR ?? ""; const firstConfig = JSON.parse( - firstEnvironment?.OPENCODE_CONFIG_CONTENT ?? '{}' - ) as { skills?: { paths?: string[] } } - const firstSkillsRoot = firstConfig.skills?.paths?.[0] ?? '' + firstEnvironment?.OPENCODE_CONFIG_CONTENT ?? "{}", + ) as { skills?: { paths?: string[] } }; + const firstSkillsRoot = firstConfig.skills?.paths?.[0] ?? ""; expect(firstConfigDirectory).toBe( - join(sharedCacheRoot, 'config', 'opencode') - ) + join(sharedCacheRoot, "config", "opencode"), + ); expect(firstEnvironment?.XDG_CONFIG_HOME).toBe( - join(sharedCacheRoot, 'config') - ) + join(sharedCacheRoot, "config"), + ); await expect( readFile( join( firstConfigDirectory, - 'node_modules', - '@opencode-ai', - 'plugin', - 'package.json' + "node_modules", + "@opencode-ai", + "plugin", + "package.json", ), - 'utf8' - ) - ).resolves.toContain('1.18.29') + "utf8", + ), + ).resolves.toContain("1.18.29"); expect(firstEnvironment?.XDG_CACHE_HOME).toBe( - join(sharedCacheRoot, 'cache') - ) + join(sharedCacheRoot, "cache"), + ); expect(firstEnvironment).toMatchObject({ - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: '1' - }) - await firstRuntime.dispose() + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1", + }); + await firstRuntime.dispose(); await expect( - readFile(join(firstSkillsRoot, 'shared-skill', 'SKILL.md'), 'utf8') - ).resolves.toContain('name: shared-skill') + readFile(join(firstSkillsRoot, "shared-skill", "SKILL.md"), "utf8"), + ).resolves.toContain("name: shared-skill"); await expect(secondRuntime.testConnection()).resolves.toMatchObject({ - available: true - }) - const secondEnvironment = second.spawnMock.mock.calls[0]?.[2]?.env + available: true, + }); + const secondEnvironment = second.spawnMock.mock.calls[0]?.[2]?.env; const secondConfig = JSON.parse( - secondEnvironment?.OPENCODE_CONFIG_CONTENT ?? '{}' - ) as { skills?: { paths?: string[] } } - expect(secondEnvironment?.OPENCODE_CONFIG_DIR).toBe( - firstConfigDirectory - ) - expect(secondConfig.skills?.paths?.[0]).toBe(firstSkillsRoot) + secondEnvironment?.OPENCODE_CONFIG_CONTENT ?? "{}", + ) as { skills?: { paths?: string[] } }; + expect(secondEnvironment?.OPENCODE_CONFIG_DIR).toBe(firstConfigDirectory); + expect(secondConfig.skills?.paths?.[0]).toBe(firstSkillsRoot); } finally { - await firstRuntime.dispose() - await secondRuntime.dispose() - await rm(sourceRoot, { recursive: true, force: true }) - await rm(sharedCacheRoot, { recursive: true, force: true }) + await firstRuntime.dispose(); + await secondRuntime.dispose(); + await rm(sourceRoot, { recursive: true, force: true }); + await rm(sharedCacheRoot, { recursive: true, force: true }); } - }) + }); - it('injects an independent model profile without persisting its key', async () => { - const child = fakeChild() - const { deps, spawnMock } = dependencies(child) + it("injects an independent model profile without persisting its key", async () => { + const child = fakeChild(); + const { deps, spawnMock } = dependencies(child); const runtime = new OpenCodeRuntime( options({ modelProfile: { - id: '00000000-0000-4000-8000-000000000011', - name: '独立模型', - baseUrl: 'https://model.example', - modelName: 'private-model', - apiKey: 'private-key', - protocol: 'anthropic-messages', - authentication: 'api-key', - supportsImageInput: true - } + id: "00000000-0000-4000-8000-000000000011", + name: "独立模型", + baseUrl: "https://model.example", + modelName: "private-model", + apiKey: "private-key", + protocol: "anthropic-messages", + authentication: "api-key", + supportsImageInput: true, + }, }), - deps - ) + deps, + ); await expect(runtime.testConnection()).resolves.toMatchObject({ - available: true - }) + available: true, + }); const spawnOptions = spawnMock.mock.calls[0]?.[2] as - | { env?: NodeJS.ProcessEnv } - | undefined + { env?: NodeJS.ProcessEnv } | undefined; const config = JSON.parse( - spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? '{}' - ) as Record + spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}", + ) as Record; expect(config).toMatchObject({ - model: 'goodbuddy-anthropic/private-model', + model: "goodbuddy-anthropic/private-model", provider: { - 'goodbuddy-anthropic': { - npm: '@ai-sdk/anthropic', + "goodbuddy-anthropic": { + npm: "@ai-sdk/anthropic", options: { - apiKey: 'private-key', - baseURL: 'https://model.example/v1' + apiKey: "private-key", + baseURL: "https://model.example/v1", }, models: { - 'private-model': { + "private-model": { attachment: true, modalities: { - input: ['text', 'image'], - output: ['text'] + input: ["text", "image"], + output: ["text"], }, provider: { - npm: '@ai-sdk/anthropic' - } - } - } - } - } - }) - await runtime.dispose() - }) + npm: "@ai-sdk/anthropic", + }, + }, + }, + }, + }, + }); + await runtime.dispose(); + }); - it('isolates an explicit profile from unrelated inherited credentials', async () => { - const child = fakeChild() - const { deps, spawnMock } = dependencies(child) + it("isolates an explicit profile from unrelated inherited credentials", async () => { + const child = fakeChild(); + const { deps, spawnMock } = dependencies(child); const inheritedCredentials = { - ANTHROPIC_API_KEY: 'inherited-anthropic', - OPENAI_API_KEY: 'inherited-openai', - GOOGLE_GENERATIVE_AI_API_KEY: 'inherited-google', - GEMINI_API_KEY: 'inherited-gemini', - AWS_ACCESS_KEY_ID: 'inherited-aws-access', - AWS_SECRET_ACCESS_KEY: 'inherited-aws-secret', - AWS_SESSION_TOKEN: 'inherited-aws-session', - AWS_PROFILE: 'inherited-aws-profile', - OPENROUTER_API_KEY: 'inherited-openrouter' - } + ANTHROPIC_API_KEY: "inherited-anthropic", + OPENAI_API_KEY: "inherited-openai", + GOOGLE_GENERATIVE_AI_API_KEY: "inherited-google", + GEMINI_API_KEY: "inherited-gemini", + AWS_ACCESS_KEY_ID: "inherited-aws-access", + AWS_SECRET_ACCESS_KEY: "inherited-aws-secret", + AWS_SESSION_TOKEN: "inherited-aws-session", + AWS_PROFILE: "inherited-aws-profile", + OPENROUTER_API_KEY: "inherited-openrouter", + }; const previousEnvironment = Object.fromEntries( Object.keys(inheritedCredentials).map((name) => [ name, - process.env[name] - ]) - ) - Object.assign(process.env, inheritedCredentials) + process.env[name], + ]), + ); + Object.assign(process.env, inheritedCredentials); const runtime = new OpenCodeRuntime( options({ modelProfile: { - id: '00000000-0000-4000-8000-000000000014', - name: 'Explicit OpenAI profile', - baseUrl: 'https://model.example/v1', - modelName: 'private-model', - protocol: 'openai-responses', - authentication: 'api-key', - apiKey: 'selected-openai-key' - } + id: "00000000-0000-4000-8000-000000000014", + name: "Explicit OpenAI profile", + baseUrl: "https://model.example/v1", + modelName: "private-model", + protocol: "openai-responses", + authentication: "api-key", + apiKey: "selected-openai-key", + }, }), - deps - ) + deps, + ); try { await expect(runtime.testConnection()).resolves.toMatchObject({ - available: true - }) + available: true, + }); const environment = ( - spawnMock.mock.calls[0]?.[2] as - | { env?: NodeJS.ProcessEnv } - | undefined - )?.env - expect(environment?.OPENAI_API_KEY).toBe('selected-openai-key') + spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined + )?.env; + expect(environment?.OPENAI_API_KEY).toBe("selected-openai-key"); for (const name of Object.keys(inheritedCredentials)) { - if (name !== 'OPENAI_API_KEY') { - expect(environment).not.toHaveProperty(name) + if (name !== "OPENAI_API_KEY") { + expect(environment).not.toHaveProperty(name); } } } finally { - await runtime.dispose() - for (const [name, value] of Object.entries( - previousEnvironment - )) { + await runtime.dispose(); + for (const [name, value] of Object.entries(previousEnvironment)) { if (value === undefined) { - delete process.env[name] + delete process.env[name]; } else { - process.env[name] = value + process.env[name] = value; } } } - }) + }); it.each([ { - label: 'Chat Completions', - protocol: 'openai-chat-completions' as const, - expectedPath: '/v1/chat/completions', - unexpectedPath: '/v1/responses' + label: "Chat Completions", + protocol: "openai-chat-completions" as const, + expectedPath: "/v1/chat/completions", + unexpectedPath: "/v1/responses", }, { - label: 'Responses', - protocol: 'openai-responses' as const, - expectedPath: '/v1/responses', - unexpectedPath: '/v1/chat/completions' - } + label: "Responses", + protocol: "openai-responses" as const, + expectedPath: "/v1/responses", + unexpectedPath: "/v1/chat/completions", + }, ])( - 'routes a custom-base $label profile through the bundled OpenCode provider', - async ({ - protocol, - expectedPath, - unexpectedPath - }) => { - const root = await mkdtemp( - join(tmpdir(), 'goodbuddy-opencode-routing-') - ) - const requestPaths: string[] = [] - const tenantHeaders: Array = [] - const requestBodies: Array> = [] + "routes a custom-base $label profile through the bundled OpenCode provider", + async ({ protocol, expectedPath, unexpectedPath }) => { + const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-routing-")); + const requestPaths: string[] = []; + const tenantHeaders: Array = []; + const requestBodies: Array> = []; const server = createServer(async (request, response) => { - requestPaths.push(request.url ?? '') - tenantHeaders.push(request.headers['x-tenant-id']) - let body = '' + requestPaths.push(request.url ?? ""); + tenantHeaders.push(request.headers["x-tenant-id"]); + let body = ""; for await (const chunk of request) { - body += String(chunk) + body += String(chunk); } if (body) { - requestBodies.push( - JSON.parse(body) as Record - ) + requestBodies.push(JSON.parse(body) as Record); } response.writeHead(400, { - 'content-type': 'application/json' - }) + "content-type": "application/json", + }); response.end( JSON.stringify({ error: { - message: 'Intentional local routing probe' - } - }) - ) - }) + message: "Intentional local routing probe", + }, + }), + ); + }); await new Promise((resolveListen, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', () => resolveListen()) - }) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('Failed to bind local routing probe') + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolveListen()); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to bind local routing probe"); } const isolatedEnvironment = { - APPDATA: join(root, 'appdata'), + APPDATA: join(root, "appdata"), HOME: root, - LOCALAPPDATA: join(root, 'localappdata'), - USERPROFILE: root - } as const + LOCALAPPDATA: join(root, "localappdata"), + USERPROFILE: root, + } as const; const previousEnvironment = Object.fromEntries( Object.keys(isolatedEnvironment).map((name) => [ name, - process.env[name] - ]) - ) - Object.assign(process.env, isolatedEnvironment) + process.env[name], + ]), + ); + Object.assign(process.env, isolatedEnvironment); const runtime = new OpenCodeRuntime( options({ binaryPath: join( process.cwd(), - 'node_modules', - 'opencode-ai', - 'bin', - 'opencode.exe' + "node_modules", + "opencode-ai", + "bin", + "opencode.exe", ), defaultWorkspace: root, modelProfile: { - id: '00000000-0000-4000-8000-000000000013', - name: 'Local endpoint probe', + id: "00000000-0000-4000-8000-000000000013", + name: "Local endpoint probe", baseUrl: `http://127.0.0.1:${address.port}/v1`, - modelName: 'probe-model', + modelName: "probe-model", protocol, - authentication: 'api-key', - apiKey: 'local-probe-key', + authentication: "api-key", + apiKey: "local-probe-key", requestHeaders: { - 'x-tenant-id': 'opencode-native' + "x-tenant-id": "opencode-native", }, requestBody: { - goodbuddy_unsupported_probe: 'must-not-send' - } - } + goodbuddy_unsupported_probe: "must-not-send", + }, + }, }), - { startupTimeoutMs: 20_000 } - ) - const controller = new AbortController() + { startupTimeoutMs: 20_000 }, + ); + const controller = new AbortController(); const timeout = setTimeout( - () => controller.abort(new Error('Routing probe timed out')), - 20_000 - ) + () => controller.abort(new Error("Routing probe timed out")), + 20_000, + ); try { - let failure = '' + let failure = ""; await (async () => { for await (const _event of runtime.run( { - requestId: - '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'routing-probe', - prompt: 'Reply with OK', - workMode: 'execute' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "routing-probe", + prompt: "Reply with OK", + workMode: "execute", }, - controller.signal + controller.signal, )) { // The local probe intentionally returns an upstream error. - void _event + void _event; } })().catch((error) => { - failure = - error instanceof Error ? error.message : String(error) - }) + failure = error instanceof Error ? error.message : String(error); + }); if (requestPaths.length === 0) { - throw new Error(`OpenCode routing probe failed: ${failure}`) + throw new Error(`OpenCode routing probe failed: ${failure}`); } - expect(requestPaths).toContain(expectedPath) - expect(requestPaths).not.toContain(unexpectedPath) - expect(tenantHeaders).toContain('opencode-native') + expect(requestPaths).toContain(expectedPath); + expect(requestPaths).not.toContain(unexpectedPath); + expect(tenantHeaders).toContain("opencode-native"); expect(requestBodies).not.toContainEqual( expect.objectContaining({ - goodbuddy_unsupported_probe: 'must-not-send' - }) - ) + goodbuddy_unsupported_probe: "must-not-send", + }), + ); } finally { - clearTimeout(timeout) - await runtime.dispose() - for (const [name, value] of Object.entries( - previousEnvironment - )) { + clearTimeout(timeout); + await runtime.dispose(); + for (const [name, value] of Object.entries(previousEnvironment)) { if (value === undefined) { - delete process.env[name] + delete process.env[name]; } else { - process.env[name] = value + process.env[name] = value; } } await new Promise((resolveClose, reject) => { - server.close((error) => - error ? reject(error) : resolveClose() - ) - }) - await rm(root, { recursive: true, force: true }) + server.close((error) => (error ? reject(error) : resolveClose())); + }); + await rm(root, { recursive: true, force: true }); } }, - 30_000 - ) + 30_000, + ); it.each([ { - protocol: 'openai-chat-completions' as const, - authentication: 'none' as const, - providerId: 'goodbuddy-openai-chat', - providerPackage: '@ai-sdk/openai-compatible' + protocol: "openai-chat-completions" as const, + authentication: "none" as const, + providerId: "goodbuddy-openai-chat", + providerPackage: "@ai-sdk/openai-compatible", }, { - protocol: 'openai-responses' as const, - authentication: 'api-key' as const, - providerId: 'goodbuddy-openai-responses', - providerPackage: '@ai-sdk/openai' - } + protocol: "openai-responses" as const, + authentication: "api-key" as const, + providerId: "goodbuddy-openai-responses", + providerPackage: "@ai-sdk/openai", + }, ])( - 'generates an explicit $protocol provider configuration', - async ({ - protocol, - authentication, - providerId, - providerPackage - }) => { - const child = fakeChild() - const { deps, spawnMock } = dependencies(child) + "generates an explicit $protocol provider configuration", + async ({ protocol, authentication, providerId, providerPackage }) => { + const child = fakeChild(); + const { deps, spawnMock } = dependencies(child); const runtime = new OpenCodeRuntime( options({ modelProfile: { - id: '00000000-0000-4000-8000-000000000012', - name: 'OpenAI 独立模型', - baseUrl: 'https://model.example/v1', - modelName: 'custom-model', + id: "00000000-0000-4000-8000-000000000012", + name: "OpenAI 独立模型", + baseUrl: "https://model.example/v1", + modelName: "custom-model", protocol, authentication, requestHeaders: { - 'x-tenant-id': 'opencode-tenant' + "x-tenant-id": "opencode-tenant", }, requestBody: { - temperature: 0.25 + temperature: 0.25, }, - ...(authentication === 'api-key' - ? { apiKey: 'private-key' } - : {}) - } + ...(authentication === "api-key" ? { apiKey: "private-key" } : {}), + }, }), - deps - ) + deps, + ); await expect(runtime.testConnection()).resolves.toMatchObject({ - available: true - }) + available: true, + }); const spawnOptions = spawnMock.mock.calls[0]?.[2] as - | { env?: NodeJS.ProcessEnv } - | undefined + { env?: NodeJS.ProcessEnv } | undefined; const config = JSON.parse( - spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? '{}' + spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}", ) as { - model?: string + model?: string; provider?: Record< string, { - npm?: string - options?: Record - models?: Record< - string, - { provider?: { npm?: string } } - > + npm?: string; + options?: Record; + models?: Record; } - > - } - expect(config.model).toBe(`${providerId}/custom-model`) + >; + }; + expect(config.model).toBe(`${providerId}/custom-model`); expect(config.provider?.[providerId]).toMatchObject({ npm: providerPackage, options: { - baseURL: 'https://model.example/v1', + baseURL: "https://model.example/v1", headers: { - 'x-tenant-id': 'opencode-tenant' - } + "x-tenant-id": "opencode-tenant", + }, }, models: { - 'custom-model': { + "custom-model": { provider: { - npm: providerPackage - } - } - } - }) - if (authentication === 'api-key') { - expect( - config.provider?.[providerId]?.options?.apiKey - ).toBe('private-key') + npm: providerPackage, + }, + }, + }, + }); + if (authentication === "api-key") { + expect(config.provider?.[providerId]?.options?.apiKey).toBe( + "private-key", + ); } else { - expect( - config.provider?.[providerId]?.options - ).not.toHaveProperty('apiKey') + expect(config.provider?.[providerId]?.options).not.toHaveProperty( + "apiKey", + ); } - expect(JSON.stringify(config)).not.toContain('temperature') - await runtime.dispose() - } - ) + expect(JSON.stringify(config)).not.toContain("temperature"); + await runtime.dispose(); + }, + ); - it('isolates embedded server configuration from inherited env', async () => { - const child = fakeChild() - const { deps, spawnMock } = dependencies(child) + it("isolates embedded server configuration from inherited env", async () => { + const child = fakeChild(); + const { deps, spawnMock } = dependencies(child); const isolatedNames = [ - 'OPENCODE_CONFIG', - 'OPENCODE_CONFIG_CONTENT', - 'OPENCODE_CONFIG_DIR', - 'OPENCODE_SERVER_PASSWORD', - 'OPENCODE_SERVER_USERNAME' - ] as const + "OPENCODE_CONFIG", + "OPENCODE_CONFIG_CONTENT", + "OPENCODE_CONFIG_DIR", + "OPENCODE_SERVER_PASSWORD", + "OPENCODE_SERVER_USERNAME", + ] as const; const inherited = Object.fromEntries( - isolatedNames.map((name) => [name, process.env[name]]) - ) - const inheritedOtel = process.env.OTEL_EXPORTER_OTLP_ENDPOINT + isolatedNames.map((name) => [name, process.env[name]]), + ); + const inheritedOtel = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; for (const name of isolatedNames) { - process.env[name] = 'must-not-be-inherited' + process.env[name] = "must-not-be-inherited"; } - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = - 'https://telemetry.invalid' + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://telemetry.invalid"; try { - const runtime = new OpenCodeRuntime(options(), deps) + const runtime = new OpenCodeRuntime(options(), deps); await expect(runtime.testConnection()).resolves.toMatchObject({ - available: true - }) + available: true, + }); const spawnOptions = spawnMock.mock.calls[0]?.[2] as - | { env?: NodeJS.ProcessEnv } - | undefined - expect(spawnOptions?.env?.OPENCODE_CONFIG).toBeUndefined() - expect( - spawnOptions?.env?.OPENCODE_CONFIG_CONTENT - ).not.toBe('must-not-be-inherited') + { env?: NodeJS.ProcessEnv } | undefined; + expect(spawnOptions?.env?.OPENCODE_CONFIG).toBeUndefined(); + expect(spawnOptions?.env?.OPENCODE_CONFIG_CONTENT).not.toBe( + "must-not-be-inherited", + ); expect(spawnOptions?.env?.OPENCODE_CONFIG_DIR).not.toBe( - 'must-not-be-inherited' - ) - expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe( - 'goodbuddy' - ) - expect( - spawnOptions?.env?.OPENCODE_SERVER_PASSWORD - ).not.toBe('must-not-be-inherited') + "must-not-be-inherited", + ); + expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe("goodbuddy"); + expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).not.toBe( + "must-not-be-inherited", + ); expect(spawnOptions?.env).toMatchObject({ - DO_NOT_TRACK: '1', - OPENCODE_DISABLE_AUTOUPDATE: '1', - OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', - OPENCODE_DISABLE_EMBEDDED_WEB_UI: '1', - OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', - OPENCODE_DISABLE_LSP_DOWNLOAD: '1', - OPENCODE_DISABLE_MODELS_FETCH: '1', - OPENCODE_DISABLE_PROJECT_CONFIG: '1', - OPENCODE_DISABLE_SHARE: '1', - OTEL_EXPORTER_OTLP_ENDPOINT: '', - OTEL_EXPORTER_OTLP_HEADERS: '', - OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: '', - OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: '', - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: '', - OTEL_SDK_DISABLED: 'true' - }) - await runtime.dispose() + DO_NOT_TRACK: "1", + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1", + OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", + OPENCODE_DISABLE_LSP_DOWNLOAD: "1", + OPENCODE_DISABLE_MODELS_FETCH: "1", + OPENCODE_DISABLE_PROJECT_CONFIG: "1", + OPENCODE_DISABLE_SHARE: "1", + OTEL_EXPORTER_OTLP_ENDPOINT: "", + OTEL_EXPORTER_OTLP_HEADERS: "", + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "", + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "", + OTEL_SDK_DISABLED: "true", + }); + await runtime.dispose(); } finally { for (const name of isolatedNames) { - const value = inherited[name] + const value = inherited[name]; if (value === undefined) { - delete process.env[name] + delete process.env[name]; } else { - process.env[name] = value + process.env[name] = value; } } if (inheritedOtel === undefined) { - delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; } else { - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = inheritedOtel + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = inheritedOtel; } } - }) + }); - it('polls the known loopback URL without waiting for stdout', async () => { - const child = fakeChild() + it("polls the known loopback URL without waiting for stdout", async () => { + const child = fakeChild(); const { deps, checkServerHealth, createClient } = dependencies(child, { - startupTimeoutMs: 1_000 - }) - checkServerHealth - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true) - const runtime = new OpenCodeRuntime(options(), deps) + startupTimeoutMs: 1_000, + }); + checkServerHealth.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const runtime = new OpenCodeRuntime(options(), deps); await expect(runtime.testConnection()).resolves.toMatchObject({ - available: true - }) - expect(checkServerHealth).toHaveBeenCalledTimes(2) - const [url, authorization, signal] = - checkServerHealth.mock.calls[0] ?? [] - expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) - expect(authorization).toMatch(/^Basic /) - expect(signal).toBeInstanceOf(AbortSignal) + available: true, + }); + expect(checkServerHealth).toHaveBeenCalledTimes(2); + const [url, authorization, signal] = checkServerHealth.mock.calls[0] ?? []; + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(authorization).toMatch(/^Basic /); + expect(signal).toBeInstanceOf(AbortSignal); expect(createClient).toHaveBeenCalledWith({ baseUrl: url, directory: process.cwd(), - headers: { Authorization: authorization } - }) - }) + headers: { Authorization: authorization }, + }); + }); - it('allows ARM-class startup to become healthy after ten seconds', async () => { - vi.useFakeTimers() + it("keeps a healthy cached server and client after idle", async () => { + const child = fakeChild(); + const { deps, checkServerHealth, createClient, spawnMock } = dependencies(child); + const runtime = new OpenCodeRuntime(options(), deps); try { - const child = fakeChild() - let healthy = false - const checkServerHealth = vi.fn(async () => healthy) - const { deps } = dependencies(child, { - checkServerHealth - }) - const runtime = new OpenCodeRuntime(options(), deps) - let settled = false - const statusPromise = runtime.testConnection().finally(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(10_100) - expect(settled).toBe(false) - - healthy = true - await vi.advanceTimersByTimeAsync(100) - await expect(statusPromise).resolves.toMatchObject({ - available: true - }) - expect(checkServerHealth).toHaveBeenCalled() - - await runtime.dispose() + await runtime.testConnection(); + await expect(runtime.testConnection()).resolves.toMatchObject({ available: true }); + expect(checkServerHealth).toHaveBeenCalledTimes(2); + expect(createClient).toHaveBeenCalledOnce(); + expect(spawnMock).toHaveBeenCalledOnce(); + expect(child.kill).not.toHaveBeenCalled(); } finally { - vi.useRealTimers() + await runtime.dispose(); } - }) + }); - it('times out, terminates the process tree, and does not expose stderr', async () => { - const child = fakeChild() - const secret = 'private-config-token' + it.each(["unhealthy", "rejected", "hung"])( + "replaces a %s cached server once for concurrent callers", + async (failure) => { + const child = fakeChild(); + const replacement = fakeChild(43); + const { deps, checkServerHealth, createClient, spawnMock } = dependencies(child); + spawnMock.mockReturnValueOnce(child).mockReturnValue(replacement); + const runtime = new OpenCodeRuntime(options(), deps); + try { + await runtime.testConnection(); + if (failure === "hung") { + checkServerHealth.mockImplementationOnce(() => new Promise(() => {})); + } else if (failure === "rejected") { + checkServerHealth.mockRejectedValueOnce(new Error("fetch failed")); + } else { + checkServerHealth.mockResolvedValueOnce(false); + } + const statuses = await Promise.all([ + runtime.testConnection(), + runtime.testConnection(), + ]); + expect(statuses.every((status) => status.available)).toBe(true); + expect(child.kill).toHaveBeenCalledOnce(); + expect(replacement.kill).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(createClient).toHaveBeenCalledTimes(2); + expect(checkServerHealth).toHaveBeenCalledTimes(3); + } finally { + await runtime.dispose(); + } + }, + ); + + it("creates a new session after replacing a stale server without replaying prompts", async () => { + const harness = runClient([ + { id: "idle", type: "session.idle", properties: { sessionID: "session-1" } }, + ]); + const child = fakeChild(); + const { deps, checkServerHealth, spawnMock } = dependencies(child, { + createClient: () => harness.client, + }); + spawnMock.mockReturnValueOnce(child).mockReturnValue(fakeChild(43)); + const runtime = new OpenCodeRuntime(options(), deps); + try { + await collectRun(runtime, "execute"); + checkServerHealth.mockResolvedValueOnce(false); + await collectRun(runtime, "execute"); + expect(harness.session.create).toHaveBeenCalledTimes(2); + expect(harness.session.update).not.toHaveBeenCalled(); + expect(harness.session.promptAsync).toHaveBeenCalledTimes(2); + } finally { + await runtime.dispose(); + } + }); + + it("isolates cancellation while concurrent requests wait for a cached health check", async () => { + const setup = runClient([ + { id: "idle", type: "session.idle", properties: { sessionID: "session-1" } }, + ]); + const child = fakeChild(); + const { deps, checkServerHealth, spawnMock } = dependencies(child, { + createClient: () => setup.client, + }); + const runtime = new OpenCodeRuntime(options(), deps); + let resolveHealth!: (healthy: boolean) => void; + const health = new Promise((resolve) => { resolveHealth = resolve; }); + const runs: Promise[] = []; + try { + await runtime.testConnection(); + const controller = new AbortController(); + checkServerHealth.mockReturnValueOnce(health); + const stream = runtime.run( + { requestId: "cancelled", conversationId: "conversation-cancelled", prompt: "test" }, + controller.signal, + ); + const cancelled = stream.next().catch((error: unknown) => error); + runs.push(cancelled); + await vi.waitFor(() => expect(checkServerHealth).toHaveBeenCalledTimes(2)); + const waiting = collectRun(runtime); + runs.push(waiting); + // Let the second request acquire its conversation lock and join the probe. + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(new Error("cancelled")); + await expect(cancelled).resolves.toMatchObject({ message: "cancelled" }); + expect(checkServerHealth.mock.calls[1]![2].aborted).toBe(false); + // A third caller must still join the pending probe after A exits. + const third = runtime.testConnection(); + runs.push(third); + expect(checkServerHealth).toHaveBeenCalledTimes(2); + resolveHealth(true); + await expect(waiting).resolves.toContainEqual(expect.objectContaining({ type: "done" })); + await expect(third).resolves.toMatchObject({ available: true }); + expect(child.kill).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledOnce(); + expect(checkServerHealth).toHaveBeenCalledTimes(2); + } finally { + resolveHealth(true); + await Promise.allSettled(runs); + await runtime.dispose(); + } + }); + + it("allows ARM-class startup to become healthy after ten seconds", async () => { + vi.useFakeTimers(); + try { + const child = fakeChild(); + let healthy = false; + const checkServerHealth = vi.fn(async () => healthy); + const { deps } = dependencies(child, { + checkServerHealth, + }); + const runtime = new OpenCodeRuntime(options(), deps); + let settled = false; + const statusPromise = runtime.testConnection().finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(10_100); + expect(settled).toBe(false); + + healthy = true; + await vi.advanceTimersByTimeAsync(100); + await expect(statusPromise).resolves.toMatchObject({ + available: true, + }); + expect(checkServerHealth).toHaveBeenCalled(); + + await runtime.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it("times out, terminates the process tree, and does not expose stderr", async () => { + const child = fakeChild(); + const secret = "private-config-token"; const { deps } = dependencies(child, { startupTimeoutMs: 5, - checkServerHealth: vi.fn().mockResolvedValue(false) - }) - stderrOf(child).write(secret) - const runtime = new OpenCodeRuntime(options(), deps) + checkServerHealth: vi.fn().mockResolvedValue(false), + }); + stderrOf(child).write(secret); + const runtime = new OpenCodeRuntime(options(), deps); - const status = await runtime.testConnection() + const status = await runtime.testConnection(); expect(status).toMatchObject({ available: false, - detail: 'OpenCode Server 启动超时(30 秒)' - }) - expect(status.detail).not.toContain(secret) - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - }) + detail: "OpenCode Server 启动超时(30 秒)", + }); + expect(status.detail).not.toContain(secret); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); - it('reports early exit without leaking captured stderr', async () => { - const child = fakeChild() - const secret = 'OPENCODE_CONFIG=/secret/config.json' - let registrationRoot = '' + it("reports early exit without leaking captured stderr", async () => { + const child = fakeChild(); + const secret = "OPENCODE_CONFIG=/secret/config.json"; + let registrationRoot = ""; const { deps } = dependencies(child, { spawn: vi.fn( ( _command: string, _args: string[], - spawnOptions: { env?: NodeJS.ProcessEnv } + spawnOptions: { env?: NodeJS.ProcessEnv }, ) => { registrationRoot = resolve( - spawnOptions.env?.OPENCODE_CONFIG_DIR ?? '', - '..' - ) + spawnOptions.env?.OPENCODE_CONFIG_DIR ?? "", + "..", + ); queueMicrotask(() => { - stderrOf(child).write(secret) - closeChild(child, 9) - }) - return child - } - ) as unknown as typeof spawn - }) - const runtime = new OpenCodeRuntime(options(), deps) + stderrOf(child).write(secret); + closeChild(child, 9); + }); + return child; + }, + ) as unknown as typeof spawn, + }); + const runtime = new OpenCodeRuntime(options(), deps); - const status = await runtime.testConnection() + const status = await runtime.testConnection(); - expect(status.detail).toBe('OpenCode Server 启动前退出(code 9)') - expect(status.detail).not.toContain(secret) - expect(registrationRoot).toBeTruthy() - await expect(stat(registrationRoot)).rejects.toThrow() - }) + expect(status.detail).toBe("OpenCode Server 启动前退出(code 9)"); + expect(status.detail).not.toContain(secret); + expect(registrationRoot).toBeTruthy(); + await expect(stat(registrationRoot)).rejects.toThrow(); + }); - it('terminates startup when the request is aborted', async () => { - const child = fakeChild() + it("terminates startup when the request is aborted", async () => { + const child = fakeChild(); const { deps, spawnMock } = dependencies(child, { - checkServerHealth: vi.fn().mockResolvedValue(false) - }) - const runtime = new OpenCodeRuntime(options(), deps) - const controller = new AbortController() + checkServerHealth: vi.fn().mockResolvedValue(false), + }); + const runtime = new OpenCodeRuntime(options(), deps); + const controller = new AbortController(); const stream = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'test' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "test", }, - controller.signal - ) + controller.signal, + ); - const pending = stream.next() - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) - controller.abort(new Error('sensitive abort reason')) + const pending = stream.next(); + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()); + controller.abort(new Error("sensitive abort reason")); - await expect(pending).rejects.toThrow('OpenCode Server 启动已取消') - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - }) + await expect(pending).rejects.toThrow("OpenCode Server 启动已取消"); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); - it('bounds session setup without imposing a full-run deadline', async () => { - const harness = runClient([]) - ;( + it("bounds session setup without imposing a full-run deadline", async () => { + const harness = runClient([]); + ( harness.session.create as unknown as ReturnType - ).mockImplementation( - () => new Promise(() => undefined) - ) + ).mockImplementation(() => new Promise(() => undefined)); const runtime = embeddedRuntime( harness.client, {}, - { controlRequestTimeoutMs: 5 } - ) + { controlRequestTimeoutMs: 5 }, + ); - await expect(collectRun(runtime)).rejects.toThrow( - 'OpenCode 创建会话超时' - ) - await runtime.dispose() - }) + await expect(collectRun(runtime)).rejects.toThrow("OpenCode 创建会话超时"); + await runtime.dispose(); + }); - it('keeps external baseUrl mode free of binary detection and spawning', async () => { - const child = fakeChild() - const { deps, spawnMock, detectBinary, createClient } = dependencies(child) + it("keeps external baseUrl mode free of binary detection and spawning", async () => { + const child = fakeChild(); + const { deps, spawnMock, detectBinary, createClient } = dependencies(child); const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', - embedded: false + baseUrl: "http://127.0.0.1:4096", + embedded: false, }), - deps - ) + deps, + ); await expect(runtime.getStatus()).resolves.toMatchObject({ available: true, - detail: '已连接 http://127.0.0.1:4096' - }) - expect(detectBinary).not.toHaveBeenCalled() - expect(spawnMock).not.toHaveBeenCalled() + detail: "已连接 http://127.0.0.1:4096", + }); + expect(detectBinary).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); expect(createClient).toHaveBeenCalledWith({ - baseUrl: 'http://127.0.0.1:4096', - directory: process.cwd() - }) - expect(runtime.requiresToolApproval).toBe(false) - }) + baseUrl: "http://127.0.0.1:4096", + directory: process.cwd(), + }); + expect(runtime.requiresToolApproval).toBe(false); + }); - it.each(['completed', 'failed', 'cancelled', 'consumer-return'])( - 'closes the event subscription when a run is %s', + it.each(["completed", "failed", "cancelled", "consumer-return"])( + "closes the event subscription when a run is %s", async (outcome) => { - const setup = runClient([]) - const controller = new AbortController() - let subscriptionSignal: AbortSignal | undefined - const subscribe = setup.event.subscribe as unknown as ReturnType - subscribe.mockImplementation(async (_input, options: { signal: AbortSignal }) => { - subscriptionSignal = options.signal - return { - stream: (async function* () { - if (outcome === 'cancelled') { - controller.abort(new DOMException('Cancelled', 'AbortError')) - return - } - if (outcome === 'failed') return - yield { - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - })() - } - }) + const setup = runClient([]); + const controller = new AbortController(); + let subscriptionSignal: AbortSignal | undefined; + const subscribe = setup.event.subscribe as unknown as ReturnType< + typeof vi.fn + >; + subscribe.mockImplementation( + async (_input, options: { signal: AbortSignal }) => { + subscriptionSignal = options.signal; + return { + stream: (async function* () { + if (outcome === "cancelled") { + controller.abort(new DOMException("Cancelled", "AbortError")); + return; + } + if (outcome === "failed") return; + yield { + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + }; + }, + ); const runtime = new OpenCodeRuntime( - options({ embedded: false, baseUrl: 'http://127.0.0.1:4096' }), - { createClient: (() => setup.client) as typeof createOpencodeClient } - ) + options({ embedded: false, baseUrl: "http://127.0.0.1:4096" }), + { createClient: (() => setup.client) as typeof createOpencodeClient }, + ); const collect = async (): Promise => { - for await (const event of runtime.run({ - requestId: crypto.randomUUID(), - conversationId: crypto.randomUUID(), - prompt: 'test', - workMode: 'execute' - }, controller.signal)) { - if (outcome === 'consumer-return' && event.type === 'done') break + for await (const event of runtime.run( + { + requestId: crypto.randomUUID(), + conversationId: crypto.randomUUID(), + prompt: "test", + workMode: "execute", + }, + controller.signal, + )) { + if (outcome === "consumer-return" && event.type === "done") break; } - } + }; try { - if (outcome === 'failed') { - await expect(collect()).rejects.toThrow('事件流意外结束') - } else if (outcome === 'cancelled') { - await expect(collect()).rejects.toMatchObject({ name: 'AbortError' }) + if (outcome === "failed") { + await expect(collect()).rejects.toThrow("事件流意外结束"); + } else if (outcome === "cancelled") { + await expect(collect()).rejects.toMatchObject({ name: "AbortError" }); } else { - await collect() + await collect(); } - expect(subscriptionSignal?.aborted).toBe(true) - expect(controller.signal.aborted).toBe(outcome === 'cancelled') + expect(subscriptionSignal?.aborted).toBe(true); + expect(controller.signal.aborted).toBe(outcome === "cancelled"); } finally { - await runtime.dispose() + await runtime.dispose(); } - } - ) + }, + ); - it('serializes external runs that share one conversation session', async () => { - const child = fakeChild() - let releaseFirst!: () => void + it("serializes external runs that share one conversation session", async () => { + const child = fakeChild(); + let releaseFirst!: () => void; const firstGate = new Promise((resolve) => { - releaseFirst = resolve - }) - let subscriptionCount = 0 + releaseFirst = resolve; + }); + let subscriptionCount = 0; const promptAsync = vi.fn().mockResolvedValue({ data: true, - error: undefined - }) + error: undefined, + }); const client = { session: { create: vi.fn().mockResolvedValue({ - data: { id: 'session-1' }, - error: undefined + data: { id: "session-1" }, + error: undefined, }), update: vi.fn().mockResolvedValue({ - data: { id: 'session-1' }, - error: undefined + data: { id: "session-1" }, + error: undefined, }), promptAsync, abort: vi.fn().mockResolvedValue({ data: true, - error: undefined - }) + error: undefined, + }), }, event: { subscribe: vi.fn().mockImplementation(async () => { - subscriptionCount += 1 - const current = subscriptionCount + subscriptionCount += 1; + const current = subscriptionCount; return { stream: (async function* () { if (current === 1) { - await firstGate + await firstGate; } yield { - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - })() - } - }) + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + }; + }), }, tool: { ids: vi.fn().mockResolvedValue({ data: [], - error: undefined - }) - } - } as unknown as ReturnType + error: undefined, + }), + }, + } as unknown as ReturnType; const { deps } = dependencies(child, { createClient: vi.fn( - () => client - ) as unknown as typeof createOpencodeClient - }) + () => client, + ) as unknown as typeof createOpencodeClient, + }); const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', - embedded: false + baseUrl: "http://127.0.0.1:4096", + embedded: false, }), - deps - ) + deps, + ); const request = { - requestId: '00000000-0000-4000-8000-000000000101', - conversationId: 'shared-conversation', - prompt: 'first', - workMode: 'execute' as const - } + requestId: "00000000-0000-4000-8000-000000000101", + conversationId: "shared-conversation", + prompt: "first", + workMode: "execute" as const, + }; const collect = async ( - stream: AsyncGenerator + stream: AsyncGenerator, ): Promise => { - const events: RuntimeEvent[] = [] + const events: RuntimeEvent[] = []; for await (const event of stream) { - events.push(event) + events.push(event); } - return events - } - const first = collect(runtime.run( - request, - new AbortController().signal - )) - await vi.waitFor(() => expect(promptAsync).toHaveBeenCalledTimes(1)) + return events; + }; + const first = collect(runtime.run(request, new AbortController().signal)); + await vi.waitFor(() => expect(promptAsync).toHaveBeenCalledTimes(1)); const second = collect( runtime.run( { ...request, - requestId: '00000000-0000-4000-8000-000000000102', - prompt: 'second' + requestId: "00000000-0000-4000-8000-000000000102", + prompt: "second", }, - new AbortController().signal - ) - ) - await Promise.resolve() - expect(promptAsync).toHaveBeenCalledTimes(1) + new AbortController().signal, + ), + ); + await Promise.resolve(); + expect(promptAsync).toHaveBeenCalledTimes(1); - releaseFirst() - await first - await second - expect(promptAsync).toHaveBeenCalledTimes(2) - await runtime.dispose() - }) + releaseFirst(); + await first; + await second; + expect(promptAsync).toHaveBeenCalledTimes(2); + await runtime.dispose(); + }); - it('loads assigned Skills before prompting', async () => { - const child = fakeChild() - const promptAsync = vi.fn().mockResolvedValue({ error: undefined }) + it("loads assigned Skills before prompting", async () => { + const child = fakeChild(); + const promptAsync = vi.fn().mockResolvedValue({ error: undefined }); const client = { session: { - create: vi.fn().mockResolvedValue({ data: { id: 'session-1' } }), + create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }), promptAsync, - abort: vi.fn().mockResolvedValue(undefined) + abort: vi.fn().mockResolvedValue(undefined), }, event: { subscribe: vi.fn().mockResolvedValue({ stream: (async function* () { yield { - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - })() - }) + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + }), }, tool: { ids: vi.fn().mockResolvedValue({ - data: ['read', 'write', 'goodbuddy-mcp'], - error: undefined - }) - } - } as unknown as ReturnType + data: ["read", "write", "goodbuddy-mcp"], + error: undefined, + }), + }, + } as unknown as ReturnType; const { deps } = dependencies(child, { createClient: vi.fn( - () => client - ) as unknown as typeof createOpencodeClient - }) + () => client, + ) as unknown as typeof createOpencodeClient, + }); const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', + baseUrl: "http://127.0.0.1:4096", embedded: false, - skillInstructions: '# 文档写作' + skillInstructions: "# 文档写作", }), - deps - ) + deps, + ); - const events = [] + const events = []; for await (const event of runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'test', - workMode: 'execute', + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "test", + workMode: "execute", images: [ { - name: 'screenshot.png', - mediaType: 'image/png', - data: 'aW1hZ2U=' - } - ] + name: "screenshot.png", + mediaType: "image/png", + data: "aW1hZ2U=", + }, + ], }, - new AbortController().signal + new AbortController().signal, )) { - events.push(event) + events.push(event); } expect(promptAsync).toHaveBeenCalledWith( expect.objectContaining({ - system: '# 文档写作', + system: "# 文档写作", parts: [ - { type: 'text', text: 'test' }, + { type: "text", text: "test" }, { - type: 'file', - mime: 'image/png', - filename: 'screenshot.png', - url: 'data:image/png;base64,aW1hZ2U=' - } - ] + type: "file", + mime: "image/png", + filename: "screenshot.png", + url: "data:image/png;base64,aW1hZ2U=", + }, + ], }), expect.objectContaining({ - signal: expect.any(AbortSignal) - }) - ) - expect(events.at(-1)).toMatchObject({ type: 'done' }) - await runtime.dispose() - }) + signal: expect.any(AbortSignal), + }), + ); + expect(events.at(-1)).toMatchObject({ type: "done" }); + await runtime.dispose(); + }); - it('rejects images when the explicit model connection disables image input', async () => { - const child = fakeChild() - const { deps, createClient } = dependencies(child) + it("rejects images when the explicit model connection disables image input", async () => { + const child = fakeChild(); + const { deps, createClient } = dependencies(child); const runtime = new OpenCodeRuntime( options({ modelProfile: { - id: '00000000-0000-4000-8000-000000000011', - name: '文本模型', - baseUrl: 'https://model.example', - modelName: 'text-model', - protocol: 'anthropic-messages', - authentication: 'none', - supportsImageInput: false - } + id: "00000000-0000-4000-8000-000000000011", + name: "文本模型", + baseUrl: "https://model.example", + modelName: "text-model", + protocol: "anthropic-messages", + authentication: "none", + supportsImageInput: false, + }, }), - deps - ) + deps, + ); const stream = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'describe', + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "describe", images: [ { - name: 'screenshot.png', - mediaType: 'image/png', - data: 'aW1hZ2U=' - } - ] + name: "screenshot.png", + mediaType: "image/png", + data: "aW1hZ2U=", + }, + ], }, - new AbortController().signal - ) + new AbortController().signal, + ); - await expect(stream.next()).rejects.toThrow( - '当前模型连接未启用图像输入' - ) - expect(createClient).not.toHaveBeenCalled() - }) -}) + await expect(stream.next()).rejects.toThrow("当前模型连接未启用图像输入"); + expect(createClient).not.toHaveBeenCalled(); + }); +}); -describe('OpenCodeRuntime embedded permission mediation', () => { - it('shares assigned custom MCP only with embedded Execute through a scoped loopback token', async () => { +describe("OpenCodeRuntime embedded permission mediation", () => { + it("shares assigned custom MCP only with embedded Execute through a scoped loopback token", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const gateway = { - getEndpoint: vi.fn(() => 'http://127.0.0.1:4567/mcp'), - grantCustomMcp: vi.fn(() => 'custom-capability'), + getEndpoint: vi.fn(() => "http://127.0.0.1:4567/mcp"), + grantCustomMcp: vi.fn(() => "custom-capability"), prepareCustomMcpTools: vi.fn(async () => [ { - name: 'mcp_12345678_abcdef01_private_tool', - inputSchema: { type: 'object' } - } + name: "mcp_12345678_abcdef01_private_tool", + inputSchema: { type: "object" }, + }, ]), - revoke: vi.fn() - } as unknown as KnowledgeMcpGateway + revoke: vi.fn(), + } as unknown as KnowledgeMcpGateway; const runtime = embeddedRuntime(setup.client, { knowledgeGateway: gateway, mcpServers: [ { - id: '00000000-0000-4000-8000-000000000092', - name: 'Private MCP', - description: '', + id: "00000000-0000-4000-8000-000000000092", + name: "Private MCP", + description: "", enabled: true, allowDynamicTools: false, - assignments: ['opencode'], + assignments: ["opencode"], secretConfigured: true, - secret: 'must-stay-in-main', - transport: 'http', - url: 'https://private.example/mcp' - } - ] - }) + secret: "must-stay-in-main", + transport: "http", + url: "https://private.example/mcp", + }, + ], + }); - await collectRun(runtime, 'execute') + await collectRun(runtime, "execute"); expect(gateway.grantCustomMcp).toHaveBeenCalledWith( - '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + "3f496642-f47d-4e0a-8944-a32c77b0d6ef", expect.any(Array), - expect.any(AbortSignal) - ) + expect.any(AbortSignal), + ); expect(setup.client.mcp.add).toHaveBeenCalledWith( { directory: process.cwd(), name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u), config: { - type: 'remote', - url: 'http://127.0.0.1:4567/mcp', + type: "remote", + url: "http://127.0.0.1:4567/mcp", enabled: true, headers: { - Authorization: 'Bearer custom-capability' + Authorization: "Bearer custom-capability", }, - oauth: false - } + oauth: false, + }, }, - { signal: expect.any(AbortSignal) } - ) - expect(JSON.stringify( - (setup.client.mcp.add as unknown as ReturnType) - .mock.calls - )).not.toContain('must-stay-in-main') - expect(JSON.stringify( - (setup.client.mcp.add as unknown as ReturnType) - .mock.calls - )).not.toContain('private.example') - expect(gateway.revoke).toHaveBeenCalledWith('custom-capability') - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ); + expect( + JSON.stringify( + (setup.client.mcp.add as unknown as ReturnType).mock + .calls, + ), + ).not.toContain("must-stay-in-main"); + expect( + JSON.stringify( + (setup.client.mcp.add as unknown as ReturnType).mock + .calls, + ), + ).not.toContain("private.example"); + expect(gateway.revoke).toHaveBeenCalledWith("custom-capability"); + await runtime.dispose(); + }); - it.each([ - ['ask', true] as const, - ['execute', false] as const - ])( - 'does not share custom MCP with OpenCode in %s mode when embedded is %s', + it.each([["ask", true] as const, ["execute", false] as const])( + "does not share custom MCP with OpenCode in %s mode when embedded is %s", async (workMode, embedded) => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const gateway = { - getEndpoint: vi.fn(() => 'http://127.0.0.1:4567/mcp'), - grantCustomMcp: vi.fn(() => 'custom-capability'), + getEndpoint: vi.fn(() => "http://127.0.0.1:4567/mcp"), + grantCustomMcp: vi.fn(() => "custom-capability"), prepareCustomMcpTools: vi.fn(async () => []), - revoke: vi.fn() - } as unknown as KnowledgeMcpGateway + revoke: vi.fn(), + } as unknown as KnowledgeMcpGateway; const runtime = embedded ? embeddedRuntime(setup.client, { knowledgeGateway: gateway, mcpServers: [ { - id: '00000000-0000-4000-8000-000000000093', - name: 'Private MCP', - description: '', + id: "00000000-0000-4000-8000-000000000093", + name: "Private MCP", + description: "", enabled: true, allowDynamicTools: false, - assignments: ['opencode'], + assignments: ["opencode"], secretConfigured: false, - transport: 'stdio', - command: 'private-command', - args: [] - } - ] + transport: "stdio", + command: "private-command", + args: [], + }, + ], }) : new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', + baseUrl: "http://127.0.0.1:4096", embedded: false, knowledgeGateway: gateway, mcpServers: [ { - id: '00000000-0000-4000-8000-000000000093', - name: 'Private MCP', - description: '', + id: "00000000-0000-4000-8000-000000000093", + name: "Private MCP", + description: "", enabled: true, allowDynamicTools: false, - assignments: ['opencode'], + assignments: ["opencode"], secretConfigured: false, - transport: 'stdio', - command: 'private-command', - args: [] - } - ] + transport: "stdio", + command: "private-command", + args: [], + }, + ], }), dependencies(fakeChild(), { createClient: vi.fn( - () => setup.client - ) as unknown as typeof createOpencodeClient - }).deps - ) + () => setup.client, + ) as unknown as typeof createOpencodeClient, + }).deps, + ); - await collectRun(runtime, workMode) + await collectRun(runtime, workMode); - expect(gateway.grantCustomMcp).not.toHaveBeenCalled() - expect(setup.client.mcp.add).not.toHaveBeenCalled() - await runtime.dispose() - } - ) + expect(gateway.grantCustomMcp).not.toHaveBeenCalled(); + expect(setup.client.mcp.add).not.toHaveBeenCalled(); + await runtime.dispose(); + }, + ); - it('parses OpenCode questions and sends the selected answers back', async () => { + it("parses OpenCode questions and sends the selected answers back", async () => { const setup = runClient([ { - id: 'question-event', - type: 'question.asked', + id: "question-event", + type: "question.asked", properties: { - id: 'question-1', - sessionID: 'session-1', + id: "question-1", + sessionID: "session-1", questions: [ { - header: '实现方式', - question: '请选择实现方式', + header: "实现方式", + question: "请选择实现方式", options: [ { - label: '直接修改', - description: '立即更新现有实现' + label: "直接修改", + description: "立即更新现有实现", }, { - label: '先写测试', - description: '先增加回归测试' - } + label: "先写测试", + description: "先增加回归测试", + }, ], multiple: false, - custom: true - } + custom: true, + }, ], tool: { - messageID: 'message-1', - callID: 'call-question-1' - } - } + messageID: "message-1", + callID: "call-question-1", + }, + }, }, { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(setup.client) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(setup.client); const stream = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'test', - workMode: 'execute' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "test", + workMode: "execute", }, - new AbortController().signal - ) + new AbortController().signal, + ); await expect(stream.next()).resolves.toMatchObject({ - value: { type: 'status' } - }) - const questionEvent = await stream.next() + value: { type: "status" }, + }); + const questionEvent = await stream.next(); expect(questionEvent.value).toMatchObject({ - type: 'question', + type: "question", questionId: expect.stringMatching(/^opencode-[a-f0-9]{48}$/u), questions: [ { - header: '实现方式', - question: '请选择实现方式', + header: "实现方式", + question: "请选择实现方式", multiple: false, - custom: true - } - ] - }) + custom: true, + }, + ], + }); const questionId = - questionEvent.value?.type === 'question' + questionEvent.value?.type === "question" ? questionEvent.value.questionId - : '' - await runtime.respondToQuestion(questionId, [['先写测试']]) + : ""; + await runtime.respondToQuestion(questionId, [["先写测试"]]); expect(setup.questionReply).toHaveBeenCalledWith( { - requestID: 'question-1', + requestID: "question-1", directory: process.cwd(), - answers: [['先写测试']] + answers: [["先写测试"]], }, - { signal: expect.any(AbortSignal) } - ) + { signal: expect.any(AbortSignal) }, + ); await expect(stream.next()).resolves.toMatchObject({ - value: { type: 'done' } - }) - await runtime.dispose() - }) + value: { type: "done" }, + }); + await runtime.dispose(); + }); - it('namespaces identical upstream question IDs across concurrent external conversations', async () => { - const setup = runClient([]) - ;( + it("namespaces identical upstream question IDs across concurrent external conversations", async () => { + const setup = runClient([]); + ( setup.event.subscribe as unknown as ReturnType ).mockImplementation(async () => ({ stream: (async function* () { yield { - id: 'question-event', - type: 'question.asked', + id: "question-event", + type: "question.asked", properties: { - id: 'shared-question', - sessionID: 'session-1', + id: "shared-question", + sessionID: "session-1", questions: [ { - header: 'Choice', - question: 'Choose', + header: "Choice", + question: "Choose", options: [], multiple: false, - custom: true - } - ] - } - } + custom: true, + }, + ], + }, + }; yield { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - })() - })) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + })); const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', - embedded: false + baseUrl: "http://127.0.0.1:4096", + embedded: false, }), dependencies(fakeChild(), { createClient: vi.fn( - () => setup.client - ) as unknown as typeof createOpencodeClient - }).deps - ) + () => setup.client, + ) as unknown as typeof createOpencodeClient, + }).deps, + ); const first = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'first', - workMode: 'execute' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "first", + workMode: "execute", }, - new AbortController().signal - ) + new AbortController().signal, + ); const second = runtime.run( { - requestId: '4f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-2', - prompt: 'second', - workMode: 'execute' + requestId: "4f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-2", + prompt: "second", + workMode: "execute", }, - new AbortController().signal - ) + new AbortController().signal, + ); - await Promise.all([first.next(), second.next()]) + await Promise.all([first.next(), second.next()]); const [firstQuestion, secondQuestion] = await Promise.all([ first.next(), - second.next() - ]) + second.next(), + ]); const firstId = - firstQuestion.value?.type === 'question' + firstQuestion.value?.type === "question" ? firstQuestion.value.questionId - : '' + : ""; const secondId = - secondQuestion.value?.type === 'question' + secondQuestion.value?.type === "question" ? secondQuestion.value.questionId - : '' - expect(firstId).toMatch(/^opencode-[a-f0-9]{48}$/u) - expect(secondId).toMatch(/^opencode-[a-f0-9]{48}$/u) - expect(firstId).not.toBe(secondId) + : ""; + expect(firstId).toMatch(/^opencode-[a-f0-9]{48}$/u); + expect(secondId).toMatch(/^opencode-[a-f0-9]{48}$/u); + expect(firstId).not.toBe(secondId); await Promise.all([ - runtime.respondToQuestion(firstId, [['first answer']]), - runtime.respondToQuestion(secondId, [['second answer']]) - ]) - expect(setup.questionReply).toHaveBeenCalledTimes(2) + runtime.respondToQuestion(firstId, [["first answer"]]), + runtime.respondToQuestion(secondId, [["second answer"]]), + ]); + expect(setup.questionReply).toHaveBeenCalledTimes(2); expect(setup.questionReply).toHaveBeenNthCalledWith( 1, { - requestID: 'shared-question', + requestID: "shared-question", directory: process.cwd(), - answers: [['first answer']] + answers: [["first answer"]], }, - { signal: expect.any(AbortSignal) } - ) + { signal: expect.any(AbortSignal) }, + ); expect(setup.questionReply).toHaveBeenNthCalledWith( 2, { - requestID: 'shared-question', + requestID: "shared-question", directory: process.cwd(), - answers: [['second answer']] + answers: [["second answer"]], }, - { signal: expect.any(AbortSignal) } - ) - await Promise.all([first.next(), second.next()]) - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ); + await Promise.all([first.next(), second.next()]); + await runtime.dispose(); + }); - it('emits native Task calls as subagents instead of generic tools', async () => { + it("emits native Task calls as subagents instead of generic tools", async () => { const taskInput = { - subagent_type: 'explorer', - description: 'Review application architecture', - prompt: 'Inspect the complete source tree.' - } + subagent_type: "explorer", + description: "Review application architecture", + prompt: "Inspect the complete source tree.", + }; const setup = runClient([ { - id: 'task-running', - type: 'message.part.updated', + id: "task-running", + type: "message.part.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", part: { - id: 'part-task-1', - callID: 'call-task-1', - type: 'tool', - tool: 'task', + id: "part-task-1", + callID: "call-task-1", + type: "tool", + tool: "task", state: { - status: 'running', + status: "running", input: taskInput, - time: { start: 1 } - } - } - } + time: { start: 1 }, + }, + }, + }, }, { - id: 'task-completed', - type: 'message.part.updated', + id: "task-completed", + type: "message.part.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", part: { - id: 'part-task-1', - callID: 'call-task-1', - type: 'tool', - tool: 'task', + id: "part-task-1", + callID: "call-task-1", + type: "tool", + tool: "task", state: { - status: 'completed', + status: "completed", input: taskInput, - output: 'Architecture review complete.', - title: 'Review application architecture', + output: "Architecture review complete.", + title: "Review application architecture", metadata: {}, - time: { start: 1, end: 2 } - } - } - } + time: { start: 1, end: 2 }, + }, + }, + }, }, { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(setup.client) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(setup.client); - const events = await collectRun(runtime) - const subagents = events.filter( - (event) => event.type === 'subagent' - ) + const events = await collectRun(runtime); + const subagents = events.filter((event) => event.type === "subagent"); - expect(subagents).toHaveLength(2) + expect(subagents).toHaveLength(2); expect(subagents[0]).toMatchObject({ - expertName: 'explorer', - routingMode: 'native', - runtimeCallId: 'call-task-1', - state: 'running', - reason: 'Review application architecture' - }) + expertName: "explorer", + routingMode: "native", + runtimeCallId: "call-task-1", + state: "running", + reason: "Review application architecture", + }); expect(subagents[1]).toMatchObject({ childTaskId: - subagents[0]?.type === 'subagent' + subagents[0]?.type === "subagent" ? subagents[0].childTaskId : undefined, - state: 'completed', - output: 'Architecture review complete.' - }) - expect(events.some((event) => event.type === 'tool')).toBe(false) - await runtime.dispose() - }) + state: "completed", + output: "Architecture review complete.", + }); + expect(events.some((event) => event.type === "tool")).toBe(false); + await runtime.dispose(); + }); - it('keeps non-Task tools with Task-shaped input as generic tools', async () => { + it("keeps non-Task tools with Task-shaped input as generic tools", async () => { const setup = runClient([ { - id: 'custom-tool-completed', - type: 'message.part.updated', + id: "custom-tool-completed", + type: "message.part.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", part: { - id: 'part-custom-1', - callID: 'call-custom-1', - type: 'tool', - tool: 'custom_delegate', + id: "part-custom-1", + callID: "call-custom-1", + type: "tool", + tool: "custom_delegate", state: { - status: 'completed', + status: "completed", input: { - subagent_type: 'explorer', - prompt: 'Inspect the complete source tree.' + subagent_type: "explorer", + prompt: "Inspect the complete source tree.", }, - output: 'Custom tool result.', - title: 'Custom delegate', + output: "Custom tool result.", + title: "Custom delegate", metadata: {}, - time: { start: 1, end: 2 } - } - } - } + time: { start: 1, end: 2 }, + }, + }, + }, }, { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(setup.client) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(setup.client); - const events = await collectRun(runtime) + const events = await collectRun(runtime); expect(events).toContainEqual( expect.objectContaining({ - type: 'tool', - callId: 'call-custom-1', - name: 'custom_delegate', - state: 'completed' - }) - ) - expect(events.some((event) => event.type === 'subagent')).toBe(false) - await runtime.dispose() - }) + type: "tool", + callId: "call-custom-1", + name: "custom_delegate", + state: "completed", + }), + ); + expect(events.some((event) => event.type === "subagent")).toBe(false); + await runtime.dispose(); + }); - it('does not impose a default wall-clock deadline on OpenCode runs', async () => { - vi.useFakeTimers() + it("does not impose a default wall-clock deadline on OpenCode runs", async () => { + vi.useFakeTimers(); try { - let release!: () => void + let release!: () => void; const gate = new Promise((resolve) => { - release = resolve - }) - const setup = runClient([]) - ;( + release = resolve; + }); + const setup = runClient([]); + ( setup.event.subscribe as unknown as ReturnType ).mockResolvedValue({ stream: (async function* () { - await gate + await gate; yield { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - })() - }) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + }); const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', - embedded: false + baseUrl: "http://127.0.0.1:4096", + embedded: false, }), dependencies(fakeChild(), { createClient: vi.fn( - () => setup.client - ) as unknown as typeof createOpencodeClient - }).deps - ) + () => setup.client, + ) as unknown as typeof createOpencodeClient, + }).deps, + ); const stream = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'work for more than a day', - workMode: 'execute' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "work for more than a day", + workMode: "execute", }, - new AbortController().signal - ) + new AbortController().signal, + ); await expect(stream.next()).resolves.toMatchObject({ - value: { type: 'status' } - }) - const pending = stream.next() - await vi.advanceTimersByTimeAsync(25 * 60 * 60_000) - expect(setup.session.abort).not.toHaveBeenCalled() - release() + value: { type: "status" }, + }); + const pending = stream.next(); + await vi.advanceTimersByTimeAsync(25 * 60 * 60_000); + expect(setup.session.abort).not.toHaveBeenCalled(); + release(); await expect(pending).resolves.toMatchObject({ - value: { type: 'done' } - }) - await runtime.dispose() + value: { type: "done" }, + }); + await runtime.dispose(); } finally { - vi.useRealTimers() + vi.useRealTimers(); } - }) + }); - it('aborts an OpenCode run at its total execution deadline', async () => { - const setup = runClient([]) - ;( + it("aborts an OpenCode run at its total execution deadline", async () => { + const setup = runClient([]); + ( setup.event.subscribe as unknown as ReturnType ).mockImplementation( - async ( - _input: unknown, - options: { signal: AbortSignal } - ) => ({ + async (_input: unknown, options: { signal: AbortSignal }) => ({ stream: (async function* () { await new Promise((_resolve, reject) => { options.signal.addEventListener( - 'abort', + "abort", () => reject(options.signal.reason), - { once: true } - ) - }) + { once: true }, + ); + }); yield { - id: 'unreachable', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - })() - }) - ) + id: "unreachable", + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + }), + ); const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', - embedded: false + baseUrl: "http://127.0.0.1:4096", + embedded: false, }), dependencies(fakeChild(), { createClient: vi.fn( - () => setup.client + () => setup.client, ) as unknown as typeof createOpencodeClient, - executionTimeoutMs: 20 - }).deps - ) + executionTimeoutMs: 20, + }).deps, + ); const stream = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'never finish', - workMode: 'execute' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "never finish", + workMode: "execute", }, - new AbortController().signal - ) + new AbortController().signal, + ); await expect(stream.next()).resolves.toMatchObject({ - value: { type: 'status' } - }) + value: { type: "status" }, + }); await expect(stream.next()).rejects.toThrow( - 'OpenCode 执行超过 20 毫秒总时限' - ) - expect(setup.session.abort).toHaveBeenCalled() - await runtime.dispose() - }) + "OpenCode 执行超过 20 毫秒总时限", + ); + expect(setup.session.abort).toHaveBeenCalled(); + await runtime.dispose(); + }); - it('applies the total deadline while agent discovery is stalled', async () => { - const setup = runClient([]) - const agents = vi.fn( - () => new Promise(() => undefined) - ) + it("applies the total deadline while agent discovery is stalled", async () => { + const setup = runClient([]); + const agents = vi.fn(() => new Promise(() => undefined)); Object.assign(setup.client, { - app: { agents } - }) - const child = fakeChild() + app: { agents }, + }); + const child = fakeChild(); const runtime = new OpenCodeRuntime( options({ - customization: { defaultAgent: 'build' } + customization: { defaultAgent: "build" }, }), dependencies(child, { createClient: vi.fn( - () => setup.client + () => setup.client, ) as unknown as typeof createOpencodeClient, - executionTimeoutMs: 20 - }).deps - ) + executionTimeoutMs: 20, + }).deps, + ); const stream = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'never reach the stream', - workMode: 'execute' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "never reach the stream", + workMode: "execute", }, - new AbortController().signal - ) + new AbortController().signal, + ); await expect(stream.next()).rejects.toThrow( - 'OpenCode 执行超过 20 毫秒总时限' - ) + "OpenCode 执行超过 20 毫秒总时限", + ); expect(agents).toHaveBeenCalledWith( { directory: process.cwd() }, - { signal: expect.any(AbortSignal) } - ) - expect(setup.event.subscribe).not.toHaveBeenCalled() - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ); + expect(setup.event.subscribe).not.toHaveBeenCalled(); + await runtime.dispose(); + }); - it('deletes a session created after timeout and does not reuse it', async () => { + it("deletes a session created after timeout and does not reuse it", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); let resolveStalledCreation!: (value: { - data: { id: string } - error: undefined - }) => void - ;( + data: { id: string }; + error: undefined; + }) => void; + ( setup.session.create as unknown as ReturnType ).mockImplementationOnce( () => new Promise((resolve) => { - resolveStalledCreation = resolve - }) - ) + resolveStalledCreation = resolve; + }), + ); const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', - embedded: false + baseUrl: "http://127.0.0.1:4096", + embedded: false, }), dependencies(fakeChild(), { createClient: vi.fn( - () => setup.client + () => setup.client, ) as unknown as typeof createOpencodeClient, - executionTimeoutMs: 20 - }).deps - ) + executionTimeoutMs: 20, + }).deps, + ); const first = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'stalled creation', - workMode: 'execute' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "stalled creation", + workMode: "execute", }, - new AbortController().signal - ) + new AbortController().signal, + ); await expect(first.next()).rejects.toThrow( - 'OpenCode 执行超过 20 毫秒总时限' - ) + "OpenCode 执行超过 20 毫秒总时限", + ); resolveStalledCreation({ - data: { id: 'stale-session' }, - error: undefined - }) + data: { id: "stale-session" }, + error: undefined, + }); await vi.waitFor(() => expect(setup.session.delete).toHaveBeenCalledWith( { - sessionID: 'stale-session', - directory: process.cwd() + sessionID: "stale-session", + directory: process.cwd(), }, - { signal: expect.any(AbortSignal) } - ) - ) + { signal: expect.any(AbortSignal) }, + ), + ); - const secondEvents = await collectRun(runtime) - expect(secondEvents.at(-1)).toMatchObject({ type: 'done' }) - expect(setup.session.create).toHaveBeenCalledTimes(2) - expect(setup.session.update).not.toHaveBeenCalled() - await runtime.dispose() - }) + const secondEvents = await collectRun(runtime); + expect(secondEvents.at(-1)).toMatchObject({ type: "done" }); + expect(setup.session.create).toHaveBeenCalledTimes(2); + expect(setup.session.update).not.toHaveBeenCalled(); + await runtime.dispose(); + }); - it('truncates oversized text display without stopping the run', async () => { + it("truncates oversized text display without stopping the run", async () => { const setup = runClient([ { - id: 'first-text', - type: 'message.part.delta', + id: "first-text", + type: "message.part.delta", properties: { - sessionID: 'session-1', - partID: 'part-1', - field: 'text', - delta: 'a'.repeat(600_000) - } + sessionID: "session-1", + partID: "part-1", + field: "text", + delta: "a".repeat(600_000), + }, }, { - id: 'second-text', - type: 'message.part.delta', + id: "second-text", + type: "message.part.delta", properties: { - sessionID: 'session-1', - partID: 'part-1', - field: 'text', - delta: 'b'.repeat(600_000) - } + sessionID: "session-1", + partID: "part-1", + field: "text", + delta: "b".repeat(600_000), + }, }, { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(setup.client) - const events = await collectRun(runtime, 'execute') + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(setup.client); + const events = await collectRun(runtime, "execute"); const text = events - .flatMap((event) => event.type === 'text' ? [event.delta] : []) - .join('') + .flatMap((event) => (event.type === "text" ? [event.delta] : [])) + .join(""); - expect(Buffer.byteLength(text)).toBe(1024 * 1024) + expect(Buffer.byteLength(text)).toBe(1024 * 1024); expect(events).toContainEqual({ - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - type: 'status', - message: - 'OpenCode 输出较长,已停止继续展示文本;任务仍在继续' - }) - expect(events.at(-1)).toMatchObject({ type: 'done' }) - expect(setup.session.abort).not.toHaveBeenCalled() - await runtime.dispose() - }) + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + type: "status", + message: "OpenCode 输出较长,已停止继续展示文本;任务仍在继续", + }); + expect(events.at(-1)).toMatchObject({ type: "done" }); + expect(setup.session.abort).not.toHaveBeenCalled(); + await runtime.dispose(); + }); - it('completes more than 100 distinct tool calls in one run', async () => { + it("completes more than 100 distinct tool calls in one run", async () => { const setup = runClient([ ...Array.from({ length: 101 }, (_, index) => - completedToolEvent(`call-${index + 1}`) + completedToolEvent(`call-${index + 1}`), ), { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(setup.client) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(setup.client); - const events = await collectRun(runtime, 'execute') + const events = await collectRun(runtime, "execute"); - expect( - events.filter((event) => event.type === 'tool') - ).toHaveLength(101) - expect(events.at(-1)).toMatchObject({ type: 'done' }) - await runtime.dispose() - }) + expect(events.filter((event) => event.type === "tool")).toHaveLength(101); + expect(events.at(-1)).toMatchObject({ type: "done" }); + await runtime.dispose(); + }); - it('adds only request-scoped built-in read tools for Ask and disconnects them', async () => { + it("adds only request-scoped built-in read tools for Ask and disconnects them", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const toolIds = setup.tool.ids as unknown as ReturnType + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const toolIds = setup.tool.ids as unknown as ReturnType; toolIds .mockResolvedValueOnce({ - data: ['read', 'write', 'bash'], - error: undefined + data: ["read", "write", "bash"], + error: undefined, }) .mockResolvedValueOnce({ - data: [ - 'read', - 'write', - 'bash', - 'goodbuddy_knowledge_search' - ], - error: undefined + data: ["read", "write", "bash", "goodbuddy_knowledge_search"], + error: undefined, }) .mockResolvedValue({ - data: [ - 'read', - 'write', - 'bash', - 'goodbuddy_knowledge_search' - ], - error: undefined - }) + data: ["read", "write", "bash", "goodbuddy_knowledge_search"], + error: undefined, + }); const gateway = { - getEndpoint: () => 'http://127.0.0.1:4567/mcp', - getAvailableToolNames: () => ['knowledge_search'] - } as unknown as KnowledgeMcpGateway - const child = fakeChild() + getEndpoint: () => "http://127.0.0.1:4567/mcp", + getAvailableToolNames: () => ["knowledge_search"], + } as unknown as KnowledgeMcpGateway; + const child = fakeChild(); const { deps } = dependencies(child, { createClient: vi.fn( - () => setup.client - ) as unknown as typeof createOpencodeClient - }) + () => setup.client, + ) as unknown as typeof createOpencodeClient, + }); const runtime = new OpenCodeRuntime( options({ knowledgeGateway: gateway }), - deps - ) + deps, + ); - const events = [] + const events = []; for await (const event of runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'search', - workMode: 'ask', - knowledgeCapabilityToken: 'secret-capability' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "search", + workMode: "ask", + knowledgeCapabilityToken: "secret-capability", }, - new AbortController().signal + new AbortController().signal, )) { - events.push(event) + events.push(event); } expect(setup.client.mcp.add).toHaveBeenCalledWith( @@ -2516,341 +2527,526 @@ describe('OpenCodeRuntime embedded permission mediation', () => { directory: process.cwd(), name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u), config: { - type: 'remote', - url: 'http://127.0.0.1:4567/mcp', + type: "remote", + url: "http://127.0.0.1:4567/mcp", enabled: true, headers: { - Authorization: 'Bearer secret-capability' + Authorization: "Bearer secret-capability", }, - oauth: false - } + oauth: false, + }, }, - { signal: expect.any(AbortSignal) } - ) + { signal: expect.any(AbortSignal) }, + ); const knowledgeMcpName = ( - ( - setup.client.mcp.add as unknown as ReturnType - ).mock.calls[0]?.[0] as { name: string } - ).name - const knowledgeToolId = `${knowledgeMcpName}_knowledge_search` + (setup.client.mcp.add as unknown as ReturnType).mock + .calls[0]?.[0] as { name: string } + ).name; + const knowledgeToolId = `${knowledgeMcpName}_knowledge_search`; expect(setup.session.create).toHaveBeenCalledWith( expect.objectContaining({ permission: [ - { permission: '*', pattern: '*', action: 'deny' }, + { permission: "*", pattern: "*", action: "deny" }, { permission: knowledgeToolId, - pattern: '*', - action: 'allow' - } - ] + pattern: "*", + action: "allow", + }, + ], }), - { signal: expect.any(AbortSignal) } - ) + { signal: expect.any(AbortSignal) }, + ); expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: { read: false, write: false, bash: false, - 'goodbuddy-data-*': false, - 'goodbuddy-custom-*': false, - [knowledgeToolId]: true - } + "goodbuddy-data-*": false, + "goodbuddy-custom-*": false, + [knowledgeToolId]: true, + }, }), - expect.anything() - ) + expect.anything(), + ); expect(setup.client.mcp.disconnect).toHaveBeenCalledWith( { name: expect.stringMatching(/^goodbuddy-data-/u), - directory: process.cwd() + directory: process.cwd(), }, - { signal: expect.any(AbortSignal) } - ) - expect(events.at(-1)).toMatchObject({ type: 'done' }) - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ); + expect(events.at(-1)).toMatchObject({ type: "done" }); + await runtime.dispose(); + }); - it('enables the deterministic MCP tool name when tool ids omit dynamic tools', async () => { + it("enables the deterministic MCP tool name when tool ids omit dynamic tools", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const baseline = { - data: ['read', 'write', 'bash'], - error: undefined - } - const toolIds = setup.tool.ids as unknown as ReturnType - toolIds.mockResolvedValue(baseline) - const child = fakeChild() + data: ["read", "write", "bash"], + error: undefined, + }; + const toolIds = setup.tool.ids as unknown as ReturnType; + toolIds.mockResolvedValue(baseline); + const child = fakeChild(); const { deps } = dependencies(child, { createClient: vi.fn( - () => setup.client - ) as unknown as typeof createOpencodeClient - }) + () => setup.client, + ) as unknown as typeof createOpencodeClient, + }); const runtime = new OpenCodeRuntime( options({ knowledgeGateway: { - getEndpoint: () => 'http://127.0.0.1:4567/mcp', - getAvailableToolNames: () => ['knowledge_search'] - } as unknown as KnowledgeMcpGateway + getEndpoint: () => "http://127.0.0.1:4567/mcp", + getAvailableToolNames: () => ["knowledge_search"], + } as unknown as KnowledgeMcpGateway, }), - deps - ) + deps, + ); for await (const _event of runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'search', - workMode: 'ask', - knowledgeCapabilityToken: 'secret-capability' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "search", + workMode: "ask", + knowledgeCapabilityToken: "secret-capability", }, - new AbortController().signal + new AbortController().signal, )) { - void _event + void _event; } const knowledgeMcpName = ( - ( - setup.client.mcp.add as unknown as ReturnType - ).mock.calls[0]?.[0] as { name: string } - ).name - const knowledgeToolId = `${knowledgeMcpName}_knowledge_search` - expect(toolIds).toHaveBeenCalledTimes(1) + (setup.client.mcp.add as unknown as ReturnType).mock + .calls[0]?.[0] as { name: string } + ).name; + const knowledgeToolId = `${knowledgeMcpName}_knowledge_search`; + expect(toolIds).toHaveBeenCalledTimes(1); expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ read: false, write: false, bash: false, - [knowledgeToolId]: true - }) + [knowledgeToolId]: true, + }), }), - expect.anything() - ) - await runtime.dispose() - }) + expect.anything(), + ); + await runtime.dispose(); + }); - it('runs embedded conversations concurrently with request-scoped MCP registrations', async () => { + it("runs embedded conversations concurrently while serializing MCP registrations", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const toolIds = setup.tool.ids as unknown as ReturnType + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const toolIds = setup.tool.ids as unknown as ReturnType; const baseline = { - data: ['read', 'write'], - error: undefined - } + data: ["read", "write"], + error: undefined, + }; const withKnowledge = { - data: ['read', 'write', 'goodbuddy_knowledge_search'], - error: undefined - } + data: ["read", "write", "goodbuddy_knowledge_search"], + error: undefined, + }; for (const response of [ baseline, withKnowledge, withKnowledge, baseline, withKnowledge, - withKnowledge + withKnowledge, ]) { - toolIds.mockResolvedValueOnce(response) + toolIds.mockResolvedValueOnce(response); } - let resolveFirstAdd!: () => void + let resolveFirstAdd!: () => void; const firstAdd = new Promise((resolve) => { - resolveFirstAdd = resolve - }) - const mcpAdd = setup.client.mcp.add as unknown as ReturnType + resolveFirstAdd = resolve; + }); + const mcpAdd = setup.client.mcp.add as unknown as ReturnType; mcpAdd .mockImplementationOnce(async (input: { name: string }) => { - await firstAdd + await firstAdd; return { data: { - [input.name]: { status: 'connected' } + [input.name]: { status: "connected" }, }, - error: undefined - } + error: undefined, + }; }) .mockImplementation(async (input: { name: string }) => ({ data: { - [input.name]: { status: 'connected' } + [input.name]: { status: "connected" }, }, - error: undefined - })) - const child = fakeChild() + error: undefined, + })); + const child = fakeChild(); const { deps } = dependencies(child, { createClient: vi.fn( - () => setup.client - ) as unknown as typeof createOpencodeClient - }) + () => setup.client, + ) as unknown as typeof createOpencodeClient, + }); const runtime = new OpenCodeRuntime( options({ knowledgeGateway: { - getEndpoint: () => 'http://127.0.0.1:4567/mcp', - getAvailableToolNames: () => ['knowledge_search'] - } as unknown as KnowledgeMcpGateway + getEndpoint: () => "http://127.0.0.1:4567/mcp", + getAvailableToolNames: () => ["knowledge_search"], + } as unknown as KnowledgeMcpGateway, }), - deps - ) + deps, + ); const collect = async ( requestId: string, conversationId: string, - token: string + token: string, ): Promise => { for await (const _event of runtime.run( { requestId, conversationId, - prompt: 'search', - workMode: 'ask', - knowledgeCapabilityToken: token + prompt: "search", + workMode: "ask", + knowledgeCapabilityToken: token, }, - new AbortController().signal + new AbortController().signal, )) { - void _event + void _event; } - } + }; const first = collect( - '3f496642-f47d-4e0a-8944-a32c77b0d6e1', - 'conversation-one', - 'first-token' - ) - await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(1)) + "3f496642-f47d-4e0a-8944-a32c77b0d6e1", + "conversation-one", + "first-token", + ); + await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(1)); const second = collect( - '3f496642-f47d-4e0a-8944-a32c77b0d6e2', - 'conversation-two', - 'second-token' - ) + "3f496642-f47d-4e0a-8944-a32c77b0d6e2", + "conversation-two", + "second-token", + ); try { - await vi.waitFor( - () => expect(mcpAdd).toHaveBeenCalledTimes(2), - { timeout: 500 } - ) - await vi.waitFor( - () => expect(setup.session.promptAsync).toHaveBeenCalledOnce(), - { timeout: 500 } - ) + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mcpAdd).toHaveBeenCalledTimes(1); + expect(setup.session.promptAsync).not.toHaveBeenCalled(); } finally { - resolveFirstAdd() + resolveFirstAdd(); } - await Promise.all([first, second]) + await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2)); + await Promise.all([first, second]); expect( mcpAdd.mock.calls.map( ([input]) => - (input as { - config: { headers: { Authorization: string } } - }).config.headers.Authorization - ) - ).toEqual(['Bearer first-token', 'Bearer second-token']) - expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(2) - await runtime.dispose() - }) + ( + input as { + config: { headers: { Authorization: string } }; + } + ).config.headers.Authorization, + ), + ).toEqual(["Bearer first-token", "Bearer second-token"]); + expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(2); + await runtime.dispose(); + }); - it('does not send a knowledge capability to external OpenCode', async () => { + it.each(["knowledge", "custom"])("releases the conversation when a queued %s MCP add is cancelled", async (kind) => { + const setup = runClient([ + { id: "idle", type: "session.idle", properties: { sessionID: "session-1" } }, + ]); + let releaseRegistration!: () => void; + const registration = new Promise((resolve) => { releaseRegistration = resolve; }); + const add = setup.client.mcp.add as unknown as ReturnType; + add.mockImplementationOnce(async (input: { name: string }) => { + await registration; + return { data: { [input.name]: { status: "connected" } } }; + }); + const revoke = vi.fn(); + const runtime = embeddedRuntime(setup.client, { + knowledgeGateway: { + getEndpoint: () => "http://127.0.0.1:4567/mcp", + getAvailableToolNames: () => ["knowledge_search"], + grantCustomMcp: () => "custom-token", + prepareCustomMcpTools: async () => [{ name: "custom_tool" }], + revoke, + } as unknown as KnowledgeMcpGateway, + mcpServers: [{ + id: "custom", + name: "Custom", + description: "", + transport: "http", + url: "https://example.com/mcp", + enabled: true, + allowDynamicTools: false, + assignments: ["opencode"], + secretConfigured: false, + }], + }); + const mutations = vi.spyOn(runtime as unknown as { mutateMcp: (...args: unknown[]) => Promise }, "mutateMcp"); + const controller = new AbortController(); + const collect = async (requestId: string, conversationId: string, signal: AbortSignal, mcp: boolean) => { + const events: RuntimeEvent[] = []; + for await (const event of runtime.run({ + requestId, conversationId, prompt: "test", + workMode: mcp && kind === "custom" ? "execute" : "ask", + ...(mcp && kind === "knowledge" ? { knowledgeCapabilityToken: requestId } : {}), + }, signal)) events.push(event); + return events; + }; + const runs: Promise[] = []; + try { + runs.push(collect("first", "conversation-first", new AbortController().signal, true)); + await vi.waitFor(() => expect(add).toHaveBeenCalledOnce()); + const cancelled = collect("cancelled", "conversation-second", controller.signal, true).catch((error: unknown) => error); + runs.push(cancelled); + await vi.waitFor(() => expect(mutations).toHaveBeenCalledTimes(2)); + controller.abort(new Error("cancelled queued add")); + let cancellationFinished = false; + void cancelled.then(() => { cancellationFinished = true; }); + await vi.waitFor(() => expect(cancellationFinished).toBe(true)); + await expect(cancelled).resolves.toMatchObject({ message: "cancelled queued add" }); + if (kind === "custom") expect(revoke).toHaveBeenCalledWith("custom-token"); + const next = collect("next", "conversation-second", new AbortController().signal, false); + runs.push(next); + await expect(next).resolves.toContainEqual(expect.objectContaining({ type: "done" })); + expect(add).toHaveBeenCalledOnce(); + expect(setup.client.mcp.disconnect).not.toHaveBeenCalled(); + releaseRegistration(); + await Promise.all(runs); + expect(setup.client.mcp.disconnect).toHaveBeenCalledExactlyOnceWith( + { name: add.mock.calls[0]![0].name, directory: process.cwd() }, + { signal: expect.any(AbortSignal) }, + ); + } finally { + releaseRegistration(); + await Promise.allSettled(runs); + await runtime.dispose(); + } + }); + + it("keeps cleanup queued behind blocked registration while sessions run concurrently", async () => { + const setup = runClient([]); + const releases: (() => void)[] = []; + const finished = Array.from({ length: 3 }, () => + new Promise((resolve) => releases.push(resolve)), + ); + let releaseRegistration!: () => void; + const registration = new Promise((resolve) => { + releaseRegistration = resolve; + }); + const create = setup.session.create as unknown as ReturnType; + let sessionCount = 0; + create.mockImplementation(async () => ({ + data: { id: `session-${++sessionCount}` }, + error: undefined, + })); + const subscribe = setup.event.subscribe as unknown as ReturnType< + typeof vi.fn + >; + let subscriptionCount = 0; + subscribe.mockImplementation(async () => { + const index = subscriptionCount++; + return { + stream: (async function* () { + await finished[index]; + yield { + id: `idle-${index}`, + type: "session.idle", + properties: { sessionID: `session-${index + 1}` }, + }; + })(), + }; + }); + const add = setup.client.mcp.add as unknown as ReturnType; + add.mockImplementation(async (input: { name: string }) => { + if (add.mock.calls.length === 3) await registration; + return { data: { [input.name]: { status: "connected" } } }; + }); + const disconnect = setup.client.mcp.disconnect as unknown as ReturnType< + typeof vi.fn + >; + const runtime = embeddedRuntime(setup.client, { + knowledgeGateway: { + getEndpoint: () => "http://127.0.0.1:4567/mcp", + getAvailableToolNames: () => ["knowledge_search"], + } as unknown as KnowledgeMcpGateway, + }); + const events: RuntimeEvent[] = []; + const collect = async (index: number): Promise => { + for await (const event of runtime.run( + { + requestId: `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`, + conversationId: `conversation-${index}`, + prompt: "search", + workMode: "ask", + knowledgeCapabilityToken: `token-${index}`, + }, + new AbortController().signal, + )) { + events.push(event); + } + }; + const runs: Promise[] = []; + try { + runs.push(collect(1)); + await vi.waitFor(() => + expect(setup.session.promptAsync).toHaveBeenCalledTimes(1), + ); + runs.push(collect(2)); + await vi.waitFor(() => + expect(setup.session.promptAsync).toHaveBeenCalledTimes(2), + ); + expect(events.filter((event) => event.type === "done")).toHaveLength(0); + expect(create).toHaveBeenCalledTimes(2); + expect(setup.session.promptAsync).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ sessionID: "session-2" }), + expect.anything(), + ); + + runs.push(collect(3)); + await vi.waitFor(() => expect(add).toHaveBeenCalledTimes(3)); + releases[0]!(); + await vi.waitFor(() => + expect(events.filter((event) => event.type === "done")).toHaveLength(1), + ); + // Exceed the disconnect budget while another registration owns the queue. + await new Promise((resolve) => setTimeout(resolve, 1_100)); + expect(disconnect).not.toHaveBeenCalled(); + expect(setup.session.promptAsync).toHaveBeenCalledTimes(2); + + releaseRegistration(); + await runs[0]; + expect(disconnect).toHaveBeenCalledWith( + { name: add.mock.calls[0]![0].name, directory: process.cwd() }, + { signal: expect.any(AbortSignal) }, + ); + expect(disconnect.mock.calls[0]![1].signal.aborted).toBe(false); + await vi.waitFor(() => + expect(setup.session.promptAsync).toHaveBeenCalledTimes(3), + ); + releases[1]!(); + releases[2]!(); + await Promise.all(runs); + expect(disconnect.mock.calls.map(([input]) => input.name)).toEqual( + add.mock.calls.map(([input]) => input.name), + ); + expect(events.filter((event) => event.type === "done")).toHaveLength(3); + } finally { + releaseRegistration(); + releases.forEach((release) => release()); + await Promise.allSettled(runs); + await runtime.dispose(); + } + }); + + it("does not send a knowledge capability to external OpenCode", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const runtime = new OpenCodeRuntime( options({ embedded: false, - baseUrl: 'http://127.0.0.1:4096', + baseUrl: "http://127.0.0.1:4096", knowledgeGateway: { - getEndpoint: () => 'http://127.0.0.1:4567/mcp', - getAvailableToolNames: () => ['knowledge_search'] - } as unknown as KnowledgeMcpGateway + getEndpoint: () => "http://127.0.0.1:4567/mcp", + getAvailableToolNames: () => ["knowledge_search"], + } as unknown as KnowledgeMcpGateway, }), { createClient: vi.fn( - () => setup.client - ) as unknown as typeof createOpencodeClient - } - ) + () => setup.client, + ) as unknown as typeof createOpencodeClient, + }, + ); for await (const _event of runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'search', - workMode: 'ask', - knowledgeCapabilityToken: 'must-not-leave-main' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "search", + workMode: "ask", + knowledgeCapabilityToken: "must-not-leave-main", }, - new AbortController().signal + new AbortController().signal, )) { - void _event + void _event; } - expect(setup.client.mcp.add).not.toHaveBeenCalled() + expect(setup.client.mcp.add).not.toHaveBeenCalled(); expect( JSON.stringify( - ( - setup.session.promptAsync as unknown as ReturnType - ).mock.calls - ) - ).not.toContain('must-not-leave-main') - await runtime.dispose() - }) + (setup.session.promptAsync as unknown as ReturnType).mock + .calls, + ), + ).not.toContain("must-not-leave-main"); + await runtime.dispose(); + }); - it('allows only registered native Skills in read-only modes', async () => { + it("allows only registered native Skills in read-only modes", async () => { const sourceRoot = await mkdtemp( - join(tmpdir(), 'goodbuddy-opencode-permission-skill-') - ) - const skillDirectory = join(sourceRoot, 'longdoc-docx') - await mkdir(skillDirectory) + join(tmpdir(), "goodbuddy-opencode-permission-skill-"), + ); + const skillDirectory = join(sourceRoot, "longdoc-docx"); + await mkdir(skillDirectory); await writeFile( - join(skillDirectory, 'SKILL.md'), + join(skillDirectory, "SKILL.md"), [ - '---', - 'name: longdoc-docx', - 'description: Build a DOCX', - '---', - '', - '# Long document' - ].join('\n'), - 'utf8' - ) + "---", + "name: longdoc-docx", + "description: Build a DOCX", + "---", + "", + "# Long document", + ].join("\n"), + "utf8", + ); const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const runtime = embeddedRuntime(setup.client, { - skillInstructions: '# Original path: C:\\private\\skills', + skillInstructions: "# Original path: C:\\private\\skills", skillPackages: [ { - id: 'longdoc-docx', - directory: skillDirectory - } - ] - }) + id: "longdoc-docx", + directory: skillDirectory, + }, + ], + }); try { - await collectRun(runtime, 'ask') + await collectRun(runtime, "ask"); expect(setup.session.create).toHaveBeenCalledWith( { - title: 'GoodBuddy 对话', + title: "GoodBuddy 对话", directory: process.cwd(), permission: [ - { permission: '*', pattern: '*', action: 'deny' }, - { permission: 'skill', pattern: '*', action: 'deny' }, + { permission: "*", pattern: "*", action: "deny" }, + { permission: "skill", pattern: "*", action: "deny" }, { - permission: 'skill', - pattern: 'longdoc-docx', - action: 'allow' - } - ] + permission: "skill", + pattern: "longdoc-docx", + action: "allow", + }, + ], }, - { signal: expect.any(AbortSignal) } - ) + { signal: expect.any(AbortSignal) }, + ); expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ system: undefined, @@ -2859,1162 +3055,1140 @@ describe('OpenCodeRuntime embedded permission mediation', () => { write: false, bash: false, task: false, - 'goodbuddy-data-*': false, - 'goodbuddy-custom-*': false, - skill: true - } + "goodbuddy-data-*": false, + "goodbuddy-custom-*": false, + skill: true, + }, }), - expect.anything() - ) + expect.anything(), + ); } finally { - await runtime.dispose() - await rm(sourceRoot, { recursive: true, force: true }) + await runtime.dispose(); + await rm(sourceRoot, { recursive: true, force: true }); } - }) + }); - it('configures Execute tools as allowed before prompting', async () => { - const { - client, - callOrder, - permissionReply, - session - } = runClient([ - permissionEvent({ sessionID: 'unrelated-session' }), + it("configures Execute tools as allowed before prompting", async () => { + const { client, callOrder, permissionReply, session } = runClient([ + permissionEvent({ sessionID: "unrelated-session" }), permissionEvent(), permissionEvent(), completedToolEvent(), { - id: 'event-reasoning-part', - type: 'message.part.updated', + id: "event-reasoning-part", + type: "message.part.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", part: { - id: 'part-reasoning', - sessionID: 'session-1', - messageID: 'message-1', - type: 'reasoning', - text: '', - time: { start: 1 } - } - } + id: "part-reasoning", + sessionID: "session-1", + messageID: "message-1", + type: "reasoning", + text: "", + time: { start: 1 }, + }, + }, }, { - id: 'event-reasoning', - type: 'message.part.delta', + id: "event-reasoning", + type: "message.part.delta", properties: { - sessionID: 'session-1', - messageID: 'message-1', - partID: 'part-reasoning', - field: 'text', - delta: 'reasoning output' - } + sessionID: "session-1", + messageID: "message-1", + partID: "part-reasoning", + field: "text", + delta: "reasoning output", + }, }, { - id: 'event-text', - type: 'message.part.delta', + id: "event-text", + type: "message.part.delta", properties: { - sessionID: 'session-1', - messageID: 'message-1', - partID: 'part-1', - field: 'text', - delta: 'approved output' - } + sessionID: "session-1", + messageID: "message-1", + partID: "part-1", + field: "text", + delta: "approved output", + }, }, { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) - const events = await collectRun(runtime, 'execute') + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); + const events = await collectRun(runtime, "execute"); - expect(callOrder).toEqual(['subscribe', 'prompt']) + expect(callOrder).toEqual(["subscribe", "prompt"]); expect(session.create).toHaveBeenCalledWith( { - title: 'GoodBuddy 对话', + title: "GoodBuddy 对话", directory: process.cwd(), permission: [ - { permission: '*', pattern: '*', action: 'allow' }, + { permission: "*", pattern: "*", action: "allow" }, { - permission: 'goodbuddy-data-*', - pattern: '*', - action: 'deny' + permission: "goodbuddy-data-*", + pattern: "*", + action: "deny", }, { - permission: 'goodbuddy-custom-*', - pattern: '*', - action: 'deny' - } - ] + permission: "goodbuddy-custom-*", + pattern: "*", + action: "deny", + }, + ], }, - { signal: expect.any(AbortSignal) } - ) + { signal: expect.any(AbortSignal) }, + ); expect(session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: { - 'goodbuddy-data-*': false, - 'goodbuddy-custom-*': false - } + "goodbuddy-data-*": false, + "goodbuddy-custom-*": false, + }, }), - expect.anything() - ) - expect(permissionReply).toHaveBeenCalledOnce() + expect.anything(), + ); + expect(permissionReply).toHaveBeenCalledOnce(); expect(permissionReply).toHaveBeenCalledWith( { - requestID: 'permission-1', + requestID: "permission-1", directory: process.cwd(), - reply: 'once' + reply: "once", }, - { signal: expect.any(AbortSignal) } - ) + { signal: expect.any(AbortSignal) }, + ); expect(events).toContainEqual( expect.objectContaining({ - type: 'reasoning', - delta: 'reasoning output' - }) - ) + type: "reasoning", + delta: "reasoning output", + }), + ); expect(events).toContainEqual( expect.objectContaining({ - type: 'tool', - callId: 'call-1', - state: 'pending' - }) - ) + type: "tool", + callId: "call-1", + state: "pending", + }), + ); expect(events).toContainEqual( expect.objectContaining({ - type: 'tool', - callId: 'call-1', - state: 'completed', - input: - '{\n "command": "npm test",\n "token": "visible-token"\n}', - output: - 'Tests passed\nAuthorization: Bearer secret-token' - }) - ) + type: "tool", + callId: "call-1", + state: "completed", + input: '{\n "command": "npm test",\n "token": "visible-token"\n}', + output: "Tests passed\nAuthorization: Bearer secret-token", + }), + ); expect(events).toContainEqual( expect.objectContaining({ - type: 'text', - delta: 'approved output' - }) - ) + type: "text", + delta: "approved output", + }), + ); expect( events.filter( (event) => - event.type === 'reasoning' || - event.type === 'text' || - event.type === 'tool' - ) + event.type === "reasoning" || + event.type === "text" || + event.type === "tool", + ), ).toEqual([ expect.objectContaining({ - type: 'tool', - callId: 'call-1', - state: 'pending' + type: "tool", + callId: "call-1", + state: "pending", }), expect.objectContaining({ - type: 'tool', - callId: 'call-1', - state: 'completed' + type: "tool", + callId: "call-1", + state: "completed", }), expect.objectContaining({ - type: 'reasoning', - delta: 'reasoning output' + type: "reasoning", + delta: "reasoning output", }), expect.objectContaining({ - type: 'text', - delta: 'approved output' - }) - ]) - expect(events.at(-1)).toMatchObject({ type: 'done' }) - await runtime.dispose() - }) + type: "text", + delta: "approved output", + }), + ]); + expect(events.at(-1)).toMatchObject({ type: "done" }); + await runtime.dispose(); + }); - it('auto-allows bounded fallback permission requests without GoodBuddy approval', async () => { + it("auto-allows bounded fallback permission requests without GoodBuddy approval", async () => { const { client, permissionReply } = runClient([ permissionEvent(), permissionEvent({ - id: 'permission-2', - patterns: ['npm run lint'], - metadata: { command: 'npm run lint' }, - always: ['npm run lint'], + id: "permission-2", + patterns: ["npm run lint"], + metadata: { command: "npm run lint" }, + always: ["npm run lint"], tool: { - messageID: 'message-2', - callID: 'call-2' - } + messageID: "message-2", + callID: "call-2", + }, }), - completedToolEvent('call-1'), - completedToolEvent('call-2'), + completedToolEvent("call-1"), + completedToolEvent("call-2"), { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) - await collectRun(runtime, 'execute') + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); + await collectRun(runtime, "execute"); expect(permissionReply.mock.calls).toEqual([ [ { - requestID: 'permission-1', + requestID: "permission-1", directory: process.cwd(), - reply: 'once' + reply: "once", }, - { signal: expect.any(AbortSignal) } + { signal: expect.any(AbortSignal) }, ], [ { - requestID: 'permission-2', + requestID: "permission-2", directory: process.cwd(), - reply: 'once' + reply: "once", }, - { signal: expect.any(AbortSignal) } - ] - ]) - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ], + ]); + await runtime.dispose(); + }); - it('fails the run when a tool reports an error before session idle', async () => { + it("fails the run when a tool reports an error before session idle", async () => { const { client, session } = runClient([ { - id: 'event-tool-error', - type: 'message.part.updated', + id: "event-tool-error", + type: "message.part.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", part: { - id: 'part-1', - callID: 'call-1', - type: 'tool', - tool: 'write', + id: "part-1", + callID: "call-1", + type: "tool", + tool: "write", state: { - status: 'error', - error: - 'write failed Authorization: Bearer secret-token' - } - } - } + status: "error", + error: "write failed Authorization: Bearer secret-token", + }, + }, + }, }, { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); const stream = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'test', - workMode: 'execute' + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "test", + workMode: "execute", }, - new AbortController().signal - ) + new AbortController().signal, + ); await expect(stream.next()).resolves.toMatchObject({ - value: { type: 'status' } - }) + value: { type: "status" }, + }); await expect(stream.next()).resolves.toMatchObject({ value: { - type: 'tool', - callId: 'call-1', - state: 'failed', - error: - 'write failed Authorization: Bearer secret-token' - } - }) + type: "tool", + callId: "call-1", + state: "failed", + error: "write failed Authorization: Bearer secret-token", + }, + }); await expect(stream.next()).rejects.toThrow( - 'write failed Authorization: Bearer secret-token' - ) - expect(session.abort).toHaveBeenCalledOnce() - await runtime.dispose() - }) + "write failed Authorization: Bearer secret-token", + ); + expect(session.abort).toHaveBeenCalledOnce(); + await runtime.dispose(); + }); - it('keeps a completed response when an earlier tool attempt failed', async () => { + it("keeps a completed response when an earlier tool attempt failed", async () => { const { client, session } = runClient([ { - id: 'event-tool-error', - type: 'message.part.updated', + id: "event-tool-error", + type: "message.part.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", part: { - id: 'part-1', - callID: 'call-1', - type: 'tool', - tool: 'read', + id: "part-1", + callID: "call-1", + type: "tool", + tool: "read", state: { - status: 'error', - error: 'Cannot read binary file' - } - } - } + status: "error", + error: "Cannot read binary file", + }, + }, + }, }, - completedToolEvent('call-2', 'write'), + completedToolEvent("call-2", "write"), { - id: 'event-text', - type: 'message.part.delta', + id: "event-text", + type: "message.part.delta", properties: { - sessionID: 'session-1', - messageID: 'message-1', - partID: 'part-text', - field: 'text', - delta: 'PPT 已生成并保存。' - } + sessionID: "session-1", + messageID: "message-1", + partID: "part-text", + field: "text", + delta: "PPT 已生成并保存。", + }, }, { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) - const events = await collectRun(runtime, 'execute') + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); + const events = await collectRun(runtime, "execute"); expect( events.filter( - (event) => - event.type === 'tool' && event.callId === 'call-1' - ) + (event) => event.type === "tool" && event.callId === "call-1", + ), ).toEqual([ expect.objectContaining({ - state: 'failed', - error: 'Cannot read binary file' + state: "failed", + error: "Cannot read binary file", }), expect.objectContaining({ - state: 'recoverable', - error: 'Cannot read binary file' - }) - ]) + state: "recoverable", + error: "Cannot read binary file", + }), + ]); expect(events).toContainEqual( expect.objectContaining({ - type: 'text', - delta: 'PPT 已生成并保存。' - }) - ) - expect(events.at(-1)).toMatchObject({ type: 'done' }) - expect(session.abort).not.toHaveBeenCalled() - await runtime.dispose() - }) + type: "text", + delta: "PPT 已生成并保存。", + }), + ); + expect(events.at(-1)).toMatchObject({ type: "done" }); + expect(session.abort).not.toHaveBeenCalled(); + await runtime.dispose(); + }); - it('surfaces a rejected async prompt instead of reporting success', async () => { + it("surfaces a rejected async prompt instead of reporting success", async () => { const { client, session } = runClient([ { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); vi.mocked(session.promptAsync).mockResolvedValueOnce({ data: undefined, error: { data: { - message: - 'prompt rejected Authorization: Bearer secret-token' - } - } - } as never) - const runtime = embeddedRuntime(client) + message: "prompt rejected Authorization: Bearer secret-token", + }, + }, + } as never); + const runtime = embeddedRuntime(client); await expect(collectRun(runtime)).rejects.toThrow( - 'prompt rejected Authorization: Bearer secret-token' - ) - await runtime.dispose() - }) + "prompt rejected Authorization: Bearer secret-token", + ); + await runtime.dispose(); + }); - it('deletes an ephemeral OpenCode session when released', async () => { + it("deletes an ephemeral OpenCode session when released", async () => { const { client, session } = runClient([ { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); - await collectRun(runtime) - await runtime.releaseConversation('conversation-1') + await collectRun(runtime); + await runtime.releaseConversation("conversation-1"); expect(session.delete).toHaveBeenCalledWith( { - sessionID: 'session-1', - directory: process.cwd() + sessionID: "session-1", + directory: process.cwd(), }, - { signal: expect.any(AbortSignal) } - ) - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ); + await runtime.dispose(); + }); - it('ignores unrelated requests and rejects bounded malformed requests without prompting', async () => { + it("ignores unrelated requests and rejects bounded malformed requests without prompting", async () => { const { client, permissionReply } = runClient([ - permissionEvent({ sessionID: 'unrelated-session' }), + permissionEvent({ sessionID: "unrelated-session" }), permissionEvent({ - patterns: Array.from({ length: 33 }, () => '*') + patterns: Array.from({ length: 33 }, () => "*"), }), { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) - await collectRun(runtime, 'execute') + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); + await collectRun(runtime, "execute"); - expect(permissionReply).toHaveBeenCalledOnce() + expect(permissionReply).toHaveBeenCalledOnce(); expect(permissionReply).toHaveBeenCalledWith( { - requestID: 'permission-1', + requestID: "permission-1", directory: process.cwd(), - reply: 'reject' + reply: "reject", }, - { signal: expect.any(AbortSignal) } - ) - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ); + await runtime.dispose(); + }); - it('fails closed when the OpenCode permission reply fails', async () => { - const { client, permissionReply, session } = runClient([ - permissionEvent() - ]) + it("fails closed when the OpenCode permission reply fails", async () => { + const { client, permissionReply, session } = runClient([permissionEvent()]); permissionReply.mockResolvedValue({ data: false, - error: { message: 'secret server error' } - }) - const runtime = embeddedRuntime(client) + error: { message: "secret server error" }, + }); + const runtime = embeddedRuntime(client); - await expect( - collectRun(runtime, 'execute') - ).rejects.toThrow('OpenCode 权限回复失败') + await expect(collectRun(runtime, "execute")).rejects.toThrow( + "OpenCode 权限回复失败", + ); expect(session.abort).toHaveBeenCalledWith({ - sessionID: 'session-1', - directory: process.cwd() - }) - await runtime.dispose() - }) + sessionID: "session-1", + directory: process.cwd(), + }); + await runtime.dispose(); + }); - it('uses deny-all session rules and hard tool disable in Ask mode', async () => { - const { client, session, tool } = runClient([ - { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) + it("uses deny-all session rules and hard tool disable in Ask mode", async () => { + const { client, session, tool } = runClient([ + { + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); - await collectRun(runtime, 'ask') + await collectRun(runtime, "ask"); - expect(session.create).toHaveBeenCalledWith( - { - title: 'GoodBuddy 对话', - directory: process.cwd(), - permission: [ - { permission: '*', pattern: '*', action: 'deny' } - ] + expect(session.create).toHaveBeenCalledWith( + { + title: "GoodBuddy 对话", + directory: process.cwd(), + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }, + { signal: expect.any(AbortSignal) }, + ); + expect(tool.ids).toHaveBeenCalledWith( + { directory: process.cwd() }, + { signal: expect.any(AbortSignal) }, + ); + expect(session.promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + tools: { + read: false, + write: false, + bash: false, + task: false, + "goodbuddy-data-*": false, + "goodbuddy-custom-*": false, }, - { signal: expect.any(AbortSignal) } - ) - expect(tool.ids).toHaveBeenCalledWith( - { directory: process.cwd() }, - { signal: expect.any(AbortSignal) } - ) - expect(session.promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ - tools: { - read: false, - write: false, - bash: false, - task: false, - 'goodbuddy-data-*': false, - 'goodbuddy-custom-*': false - } - }), - expect.anything() - ) - await runtime.dispose() - }) + }), + expect.anything(), + ); + await runtime.dispose(); + }); - it('updates reused sessions when the work mode changes', async () => { + it("updates reused sessions when the work mode changes", async () => { const { client, session } = runClient([ { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); - await collectRun(runtime, 'execute') - await collectRun(runtime, 'ask') + await collectRun(runtime, "execute"); + await collectRun(runtime, "ask"); expect(session.update).toHaveBeenCalledWith( { - sessionID: 'session-1', + sessionID: "session-1", directory: process.cwd(), - permission: [ - { permission: '*', pattern: '*', action: 'deny' } - ] + permission: [{ permission: "*", pattern: "*", action: "deny" }], }, - { signal: expect.any(AbortSignal) } - ) - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ); + await runtime.dispose(); + }); - it('configures external Execute sessions without whole-run approval', async () => { + it("configures external Execute sessions without whole-run approval", async () => { const { client, session, permissionReply } = runClient([ permissionEvent(), { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', - embedded: false + baseUrl: "http://127.0.0.1:4096", + embedded: false, }), { createClient: vi.fn( - () => client - ) as unknown as typeof createOpencodeClient - } - ) - await collectRun(runtime, 'execute') + () => client, + ) as unknown as typeof createOpencodeClient, + }, + ); + await collectRun(runtime, "execute"); - expect(runtime.requiresToolApproval).toBe(false) + expect(runtime.requiresToolApproval).toBe(false); expect(session.create).toHaveBeenCalledWith( { - title: 'GoodBuddy 对话', + title: "GoodBuddy 对话", directory: process.cwd(), - permission: [ - { permission: '*', pattern: '*', action: 'allow' } - ] + permission: [{ permission: "*", pattern: "*", action: "allow" }], }, - { signal: expect.any(AbortSignal) } - ) - expect(permissionReply).not.toHaveBeenCalled() - await runtime.dispose() - }) -}) + { signal: expect.any(AbortSignal) }, + ); + expect(permissionReply).not.toHaveBeenCalled(); + await runtime.dispose(); + }); +}); -describe('OpenCodeRuntime native customization', () => { - it('maps bounded native inventory and filters GoodBuddy-owned capabilities', async () => { +describe("OpenCodeRuntime native customization", () => { + it("maps bounded native inventory and filters GoodBuddy-owned capabilities", async () => { const sourceRoot = await mkdtemp( - join(tmpdir(), 'goodbuddy-opencode-native-snapshot-') - ) - const assignedSkillDirectory = join( - sourceRoot, - 'assigned-skill' - ) - await mkdir(assignedSkillDirectory) + join(tmpdir(), "goodbuddy-opencode-native-snapshot-"), + ); + const assignedSkillDirectory = join(sourceRoot, "assigned-skill"); + await mkdir(assignedSkillDirectory); await writeFile( - join(assignedSkillDirectory, 'SKILL.md'), + join(assignedSkillDirectory, "SKILL.md"), [ - '---', - 'name: assigned-skill', - 'description: Assigned test skill', - '---', - '', - '# Assigned skill' - ].join('\n'), - 'utf8' - ) + "---", + "name: assigned-skill", + "description: Assigned test skill", + "---", + "", + "# Assigned skill", + ].join("\n"), + "utf8", + ); const client = { session: { list: vi.fn().mockResolvedValue({ data: [], - error: undefined - }) + error: undefined, + }), }, app: { agents: vi.fn().mockResolvedValue({ data: [ { - name: 'build', - description: 'Primary builder', - mode: 'primary', + name: "build", + description: "Primary builder", + mode: "primary", native: true, hidden: false, permission: [], - options: {} + options: {}, }, { - name: 'hidden', - mode: 'all', + name: "hidden", + mode: "all", hidden: true, permission: [], - options: {} - } - ] + options: {}, + }, + ], }), skills: vi.fn().mockResolvedValue({ data: [ { - name: 'native-skill', - description: 'Native skill', - location: 'C:\\private\\native', - content: 'must not be exposed' + name: "native-skill", + description: "Native skill", + location: "C:\\private\\native", + content: "must not be exposed", }, { - name: 'assigned-skill', - location: 'C:\\private\\assigned', - content: 'assigned content' - } - ] - }) + name: "assigned-skill", + location: "C:\\private\\assigned", + content: "assigned content", + }, + ], + }), }, tool: { ids: vi.fn().mockResolvedValue({ data: [ - 'apply_patch', - 'bash', - 'edit', - 'glob', - 'grep', - 'invalid', - 'question', - 'read', - 'skill', - 'task', - 'todowrite', - 'webfetch', - 'websearch', - 'write', - 'goodbuddy-data-123_search', - 'extension_tool' + "apply_patch", + "bash", + "edit", + "glob", + "grep", + "invalid", + "question", + "read", + "skill", + "task", + "todowrite", + "webfetch", + "websearch", + "write", + "goodbuddy-data-123_search", + "extension_tool", ], - error: undefined - }) + error: undefined, + }), }, command: { list: vi.fn().mockResolvedValue({ data: [ { - name: 'review', - description: 'Review changes', - source: 'command', - template: 'private command template', - hints: [] + name: "review", + description: "Review changes", + source: "command", + template: "private command template", + hints: [], }, { - name: 'mcp-prompt', - description: 'Prompt from MCP', - source: 'mcp', - template: 'Inspect $ARGUMENTS', - hints: [] + name: "mcp-prompt", + description: "Prompt from MCP", + source: "mcp", + template: "Inspect $ARGUMENTS", + hints: [], }, { - name: 'assigned-skill', - source: 'skill', - template: 'assigned skill template', - hints: [] + name: "assigned-skill", + source: "skill", + template: "assigned skill template", + hints: [], }, { - name: 'goodbuddy-data-123', - source: 'mcp', - template: 'temporary prompt', - hints: [] - } - ] - }) + name: "goodbuddy-data-123", + source: "mcp", + template: "temporary prompt", + hints: [], + }, + ], + }), }, lsp: { status: vi.fn().mockResolvedValue({ data: [ { - id: 'typescript', - name: 'TypeScript', - root: 'C:\\private\\workspace', - status: 'connected' - } - ] - }) + id: "typescript", + name: "TypeScript", + root: "C:\\private\\workspace", + status: "connected", + }, + ], + }), }, formatter: { status: vi.fn().mockResolvedValue({ data: [ { - name: 'prettier', + name: "prettier", enabled: true, - extensions: ['.ts', '.tsx'] - } - ] - }) + extensions: [".ts", ".tsx"], + }, + ], + }), }, mcp: { status: vi.fn().mockResolvedValue({ data: { - public: { status: 'failed', error: 'private failure' }, - 'goodbuddy-custom-123': { status: 'connected' } - } - }) + public: { status: "failed", error: "private failure" }, + "goodbuddy-custom-123": { status: "connected" }, + }, + }), }, experimental: { resource: { list: vi.fn().mockResolvedValue({ data: { - 'public-resource': { - name: 'Public resource', - uri: 'docs://public', - description: 'Reference', - mimeType: 'text/plain', - client: 'public' + "public-resource": { + name: "Public resource", + uri: "docs://public", + description: "Reference", + mimeType: "text/plain", + client: "public", }, temporary: { - name: 'Temporary resource', - uri: 'docs://temporary', - client: 'goodbuddy-data-123' - } - } - }) - } - } - } as unknown as ReturnType + name: "Temporary resource", + uri: "docs://temporary", + client: "goodbuddy-data-123", + }, + }, + }), + }, + }, + } as unknown as ReturnType; const runtime = embeddedRuntime(client, { skillPackages: [ { - id: 'assigned-skill', - directory: assignedSkillDirectory - } - ] - }) + id: "assigned-skill", + directory: assignedSkillDirectory, + }, + ], + }); - const snapshot = await runtime.getNativeSnapshot() + const snapshot = await runtime.getNativeSnapshot(); expect(snapshot).toMatchObject({ available: true, - inventoryStatus: 'available', - detail: 'OpenCode 原生能力已就绪', + inventoryStatus: "available", + detail: "OpenCode 原生能力已就绪", agents: [ { - id: 'build', - mode: 'primary', + id: "build", + mode: "primary", native: true, - hidden: false + hidden: false, }, { - id: 'hidden', - hidden: true - } + id: "hidden", + hidden: true, + }, ], toolsSupported: true, tools: expect.arrayContaining([ { - id: 'edit', - name: 'edit', - kind: 'write', - source: 'runtime', - ask: 'blocked', - execute: 'allowed' + id: "edit", + name: "edit", + kind: "write", + source: "runtime", + ask: "blocked", + execute: "allowed", }, { - id: 'read', - name: 'read', - kind: 'read', - source: 'runtime', - ask: 'blocked', - execute: 'allowed' + id: "read", + name: "read", + kind: "read", + source: "runtime", + ask: "blocked", + execute: "allowed", }, { - id: 'skill', - name: 'skill', - kind: 'agent', - source: 'runtime', - ask: 'conditional', - execute: 'allowed' + id: "skill", + name: "skill", + kind: "agent", + source: "runtime", + ask: "conditional", + execute: "allowed", }, { - id: 'extension_tool', - name: 'extension_tool', - kind: 'other', - source: 'unknown', - ask: 'blocked', - execute: 'allowed' - } + id: "extension_tool", + name: "extension_tool", + kind: "other", + source: "unknown", + ask: "blocked", + execute: "allowed", + }, ]), commands: [ { - id: 'review', - source: 'command' + id: "review", + source: "command", }, { - id: 'mcp-prompt', - source: 'mcp' - } + id: "mcp-prompt", + source: "mcp", + }, ], prompts: [ { - id: 'mcp-prompt', - prompt: 'Inspect $ARGUMENTS', - source: 'mcp' - } + id: "mcp-prompt", + prompt: "Inspect $ARGUMENTS", + source: "mcp", + }, ], lsp: [ { - id: 'typescript', - name: 'TypeScript', - status: 'connected' - } + id: "typescript", + name: "TypeScript", + status: "connected", + }, ], formatters: [ { - id: 'prettier', + id: "prettier", enabled: true, - extensions: ['.ts', '.tsx'] - } + extensions: [".ts", ".tsx"], + }, ], mcpServers: [ { - id: 'public', - status: 'failed' - } + id: "public", + status: "failed", + }, ], skills: [ { - id: 'native-skill', - description: 'Native skill' - } + id: "native-skill", + description: "Native skill", + }, ], resources: [ { - id: 'public-resource', - uri: 'docs://public', - server: 'public' - } + id: "public-resource", + uri: "docs://public", + server: "public", + }, ], resourcesSupported: true, context: { - strategy: 'native', - manualCompact: true - } - }) - const serialized = JSON.stringify(snapshot) - expect(serialized).not.toContain('private failure') - expect(serialized).not.toContain('private command template') - expect(serialized).not.toContain('must not be exposed') - expect(serialized).not.toContain('C:\\private') - expect(serialized).not.toContain('assigned-skill') - expect(serialized).not.toContain('goodbuddy-data-') - expect(serialized).not.toContain('goodbuddy-custom-') - expect(serialized).not.toContain('"invalid"') + strategy: "native", + manualCompact: true, + }, + }); + const serialized = JSON.stringify(snapshot); + expect(serialized).not.toContain("private failure"); + expect(serialized).not.toContain("private command template"); + expect(serialized).not.toContain("must not be exposed"); + expect(serialized).not.toContain("C:\\private"); + expect(serialized).not.toContain("assigned-skill"); + expect(serialized).not.toContain("goodbuddy-data-"); + expect(serialized).not.toContain("goodbuddy-custom-"); + expect(serialized).not.toContain('"invalid"'); vi.mocked(client.tool.ids).mockRejectedValueOnce( - new Error('tool inventory unavailable') - ) - const partialSnapshot = await runtime.getNativeSnapshot() + new Error("tool inventory unavailable"), + ); + const partialSnapshot = await runtime.getNativeSnapshot(); expect(partialSnapshot).toMatchObject({ available: true, - inventoryStatus: 'partial', + inventoryStatus: "partial", tools: [], - toolsSupported: false - }) - expect(partialSnapshot.detail).toContain('工具') - await runtime.dispose() - await rm(sourceRoot, { recursive: true, force: true }) - }) + toolsSupported: false, + }); + expect(partialSnapshot.detail).toContain("工具"); + await runtime.dispose(); + await rm(sourceRoot, { recursive: true, force: true }); + }); - it('reports external OpenCode connectivity without claiming readable native inventory', async () => { - const client = runClient([]).client + it("reports external OpenCode connectivity without claiming readable native inventory", async () => { + const client = runClient([]).client; const runtime = new OpenCodeRuntime( options({ - baseUrl: 'http://127.0.0.1:4096', - embedded: false + baseUrl: "http://127.0.0.1:4096", + embedded: false, }), { createClient: vi.fn( - () => client - ) as unknown as typeof createOpencodeClient - } - ) + () => client, + ) as unknown as typeof createOpencodeClient, + }, + ); await expect(runtime.getNativeSnapshot()).resolves.toMatchObject({ available: true, - inventoryStatus: 'connection-only', + inventoryStatus: "connection-only", tools: [], - toolsSupported: false - }) - expect(client.tool.ids).not.toHaveBeenCalled() - await runtime.dispose() - }) + toolsSupported: false, + }); + expect(client.tool.ids).not.toHaveBeenCalled(); + await runtime.dispose(); + }); - it('uses an explicit valid agent over the configured default', async () => { + it("uses an explicit valid agent over the configured default", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); Object.assign(setup.client, { app: { agents: vi.fn().mockResolvedValue({ data: [ { - name: 'build', - mode: 'primary', + name: "build", + mode: "primary", hidden: false, permission: [], - options: {} + options: {}, }, { - name: 'plan', - mode: 'all', + name: "plan", + mode: "all", hidden: false, permission: [], - options: {} - } - ] - }) - } - }) + options: {}, + }, + ], + }), + }, + }); const runtime = embeddedRuntime(setup.client, { - customization: { defaultAgent: 'build' } - }) + customization: { defaultAgent: "build" }, + }); for await (const _event of runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'test', - workMode: 'execute', + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "test", + workMode: "execute", runtimeControl: { - provider: 'opencode', - agent: 'plan' - } + provider: "opencode", + agent: "plan", + }, }, - new AbortController().signal + new AbortController().signal, )) { - void _event + void _event; } expect(setup.session.create).toHaveBeenCalledWith( - expect.objectContaining({ agent: 'plan' }), - { signal: expect.any(AbortSignal) } - ) + expect.objectContaining({ agent: "plan" }), + { signal: expect.any(AbortSignal) }, + ); expect(setup.session.promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ agent: 'plan' }), - expect.anything() - ) - await runtime.dispose() - }) + expect.objectContaining({ agent: "plan" }), + expect.anything(), + ); + await runtime.dispose(); + }); - it('uses the configured default agent when no request override is present', async () => { + it("uses the configured default agent when no request override is present", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); Object.assign(setup.client, { app: { agents: vi.fn().mockResolvedValue({ data: [ { - name: 'build', - mode: 'primary', + name: "build", + mode: "primary", hidden: false, permission: [], - options: {} - } - ] - }) - } - }) + options: {}, + }, + ], + }), + }, + }); const runtime = embeddedRuntime(setup.client, { - customization: { defaultAgent: 'build' } - }) + customization: { defaultAgent: "build" }, + }); - await collectRun(runtime) + await collectRun(runtime); expect(setup.session.create).toHaveBeenCalledWith( - expect.objectContaining({ agent: 'build' }), - { signal: expect.any(AbortSignal) } - ) + expect.objectContaining({ agent: "build" }), + { signal: expect.any(AbortSignal) }, + ); expect(setup.session.promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ agent: 'build' }), - expect.anything() - ) - await runtime.dispose() - }) + expect.objectContaining({ agent: "build" }), + expect.anything(), + ); + await runtime.dispose(); + }); - it('rejects a stale or hidden agent instead of falling back', async () => { - const setup = runClient([]) + it("rejects a stale or hidden agent instead of falling back", async () => { + const setup = runClient([]); Object.assign(setup.client, { app: { agents: vi.fn().mockResolvedValue({ data: [ { - name: 'hidden', - mode: 'primary', + name: "hidden", + mode: "primary", hidden: true, permission: [], - options: {} - } - ] - }) - } - }) - const runtime = embeddedRuntime(setup.client) + options: {}, + }, + ], + }), + }, + }); + const runtime = embeddedRuntime(setup.client); const stream = runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'test', - workMode: 'execute', + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "test", + workMode: "execute", runtimeControl: { - provider: 'opencode', - agent: 'hidden' - } + provider: "opencode", + agent: "hidden", + }, }, - new AbortController().signal - ) + new AbortController().signal, + ); await expect(stream.next()).rejects.toThrow( - 'OpenCode Agent 不存在、已隐藏或不可作为主 Agent:hidden' - ) - expect(setup.session.create).not.toHaveBeenCalled() - expect(setup.session.promptAsync).not.toHaveBeenCalled() - await runtime.dispose() - }) + "OpenCode Agent 不存在、已隐藏或不可作为主 Agent:hidden", + ); + expect(setup.session.create).not.toHaveBeenCalled(); + expect(setup.session.promptAsync).not.toHaveBeenCalled(); + await runtime.dispose(); + }); - it('executes validated native commands through the command API', async () => { + it("executes validated native commands through the command API", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const command = vi.fn().mockResolvedValue({ - data: { info: {}, parts: [] } - }) + data: { info: {}, parts: [] }, + }); Object.assign(setup.client, { app: { agents: vi.fn().mockResolvedValue({ data: [ { - name: 'build', - mode: 'primary', + name: "build", + mode: "primary", hidden: false, permission: [], - options: {} - } - ] - }) + options: {}, + }, + ], + }), }, command: { list: vi.fn().mockResolvedValue({ data: [ { - name: 'review', - source: 'command', - template: 'Review $ARGUMENTS', - hints: [] - } - ] - }) - } - }) - Object.assign(setup.client.session, { command }) - const runtime = embeddedRuntime(setup.client) + name: "review", + source: "command", + template: "Review $ARGUMENTS", + hints: [], + }, + ], + }), + }, + }); + Object.assign(setup.client.session, { command }); + const runtime = embeddedRuntime(setup.client); - const events = [] + const events = []; for await (const event of runtime.run( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - prompt: 'must not become slash text', - workMode: 'execute', + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + prompt: "must not become slash text", + workMode: "execute", runtimeControl: { - provider: 'opencode', - agent: 'build', + provider: "opencode", + agent: "build", command: { - name: 'review', - arguments: '--staged' - } - } + name: "review", + arguments: "--staged", + }, + }, }, - new AbortController().signal + new AbortController().signal, )) { - events.push(event) + events.push(event); } expect(command).toHaveBeenCalledWith( { - sessionID: 'session-1', + sessionID: "session-1", directory: process.cwd(), - command: 'review', - arguments: '--staged', - agent: 'build' + command: "review", + arguments: "--staged", + agent: "build", }, expect.objectContaining({ - signal: expect.any(AbortSignal) - }) - ) - expect(setup.session.promptAsync).not.toHaveBeenCalled() + signal: expect.any(AbortSignal), + }), + ); + expect(setup.session.promptAsync).not.toHaveBeenCalled(); expect(events.at(-1)).toMatchObject({ - type: 'done', - sessionId: 'session-1' - }) - await runtime.dispose() - }) + type: "done", + sessionId: "session-1", + }); + await runtime.dispose(); + }); - it('compacts a managed session through the supported native API', async () => { + it("compacts a managed session through the supported native API", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const context = vi.fn().mockResolvedValue({ data: { data: [ { - type: 'assistant', + type: "assistant", model: { - providerID: 'anthropic', - id: 'claude-sonnet' - } - } - ] - } - }) + providerID: "anthropic", + id: "claude-sonnet", + }, + }, + ], + }, + }); const summarize = vi.fn().mockResolvedValue({ data: true, - error: undefined - }) + error: undefined, + }); Object.assign(setup.client, { v2: { - session: { context } + session: { context }, }, - session: { ...setup.client.session, summarize } - }) - const runtime = embeddedRuntime(setup.client) - await collectRun(runtime) + session: { ...setup.client.session, summarize }, + }); + const runtime = embeddedRuntime(setup.client); + await collectRun(runtime); vi.mocked(setup.event.subscribe).mockResolvedValueOnce({ stream: (async function* () { yield { - type: 'message.updated', + type: "message.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", info: { - id: 'compaction-message', - sessionID: 'session-1', - role: 'assistant', + id: "compaction-message", + sessionID: "session-1", + role: "assistant", time: { created: 1, - completed: 2 + completed: 2, }, - parentID: 'compaction-parent', - modelID: 'claude-sonnet', - providerID: 'anthropic', - mode: 'compaction', - agent: 'build', + parentID: "compaction-parent", + modelID: "claude-sonnet", + providerID: "anthropic", + mode: "compaction", + agent: "build", path: { cwd: process.cwd(), - root: process.cwd() + root: process.cwd(), }, cost: 0, tokens: { @@ -4023,161 +4197,161 @@ describe('OpenCodeRuntime native customization', () => { reasoning: 0, cache: { read: 30, - write: 4 + write: 4, }, - total: 124 + total: 124, }, - finish: 'stop' - } - } - } + finish: "stop", + }, + }, + }; yield { - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - })() - } as never) - const signal = new AbortController().signal + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + } as never); + const signal = new AbortController().signal; await expect( runtime.compactConversation( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - runtimeSelection: { provider: 'opencode' }, + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + runtimeSelection: { provider: "opencode" }, history: [], - historyMessageIds: [] + historyMessageIds: [], }, - signal - ) + signal, + ), ).resolves.toEqual({ result: { - provider: 'opencode', - strategy: 'native', + provider: "opencode", + strategy: "native", compacted: true, - detail: 'OpenCode 已完成原生上下文压缩' + detail: "OpenCode 已完成原生上下文压缩", }, usageEvents: [ { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - type: 'model-usage', - callId: 'compaction-message', - runtime: 'opencode', - provider: 'anthropic', - model: 'claude-sonnet', + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + type: "model-usage", + callId: "compaction-message", + runtime: "opencode", + provider: "anthropic", + model: "claude-sonnet", inputTokens: 100, outputTokens: 20, cacheReadTokens: 30, cacheWriteTokens: 4, - reportedTotalTokens: 124 - } - ] - }) + reportedTotalTokens: 124, + }, + ], + }); expect(context).toHaveBeenCalledWith( - { sessionID: 'session-1' }, - { signal: expect.any(AbortSignal) } - ) + { sessionID: "session-1" }, + { signal: expect.any(AbortSignal) }, + ); expect(summarize).toHaveBeenCalledWith( { - sessionID: 'session-1', + sessionID: "session-1", directory: process.cwd(), - providerID: 'anthropic', - modelID: 'claude-sonnet', - auto: false + providerID: "anthropic", + modelID: "claude-sonnet", + auto: false, }, - { signal: expect.any(AbortSignal) } - ) - await runtime.dispose() - }) + { signal: expect.any(AbortSignal) }, + ); + await runtime.dispose(); + }); - it('reports when a managed session has no model to compact with', async () => { + it("reports when a managed session has no model to compact with", async () => { const setup = runClient([ { - id: 'idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) + id: "idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); const context = vi.fn().mockResolvedValue({ - data: { data: [] } - }) - const summarize = vi.fn() + data: { data: [] }, + }); + const summarize = vi.fn(); Object.assign(setup.client, { v2: { - session: { context } + session: { context }, }, - session: { ...setup.client.session, summarize } - }) - const runtime = embeddedRuntime(setup.client) - await collectRun(runtime) + session: { ...setup.client.session, summarize }, + }); + const runtime = embeddedRuntime(setup.client); + await collectRun(runtime); await expect( runtime.compactConversation( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'conversation-1', - runtimeSelection: { provider: 'opencode' }, + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "conversation-1", + runtimeSelection: { provider: "opencode" }, history: [], - historyMessageIds: [] + historyMessageIds: [], }, - new AbortController().signal - ) + new AbortController().signal, + ), ).resolves.toEqual({ result: { - provider: 'opencode', - strategy: 'native', + provider: "opencode", + strategy: "native", compacted: false, - detail: '当前 OpenCode 会话尚无可用于压缩的模型记录' - } - }) - expect(summarize).not.toHaveBeenCalled() - await runtime.dispose() - }) + detail: "当前 OpenCode 会话尚无可用于压缩的模型记录", + }, + }); + expect(summarize).not.toHaveBeenCalled(); + await runtime.dispose(); + }); - it('reports when no managed OpenCode session can be compacted', async () => { - const runtime = new OpenCodeRuntime(options()) + it("reports when no managed OpenCode session can be compacted", async () => { + const runtime = new OpenCodeRuntime(options()); await expect( runtime.compactConversation( { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - conversationId: 'missing-conversation', - runtimeSelection: { provider: 'opencode' }, + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + conversationId: "missing-conversation", + runtimeSelection: { provider: "opencode" }, history: [], - historyMessageIds: [] + historyMessageIds: [], }, - new AbortController().signal - ) + new AbortController().signal, + ), ).resolves.toEqual({ result: { - provider: 'opencode', - strategy: 'native', + provider: "opencode", + strategy: "native", compacted: false, - detail: '当前 GoodBuddy 对话尚无可压缩的 OpenCode 会话' - } - }) - await runtime.dispose() - }) -}) + detail: "当前 GoodBuddy 对话尚无可压缩的 OpenCode 会话", + }, + }); + await runtime.dispose(); + }); +}); -describe('OpenCodeRuntime model usage', () => { - it('emits one provider-reported usage event for each terminal assistant message', async () => { +describe("OpenCodeRuntime model usage", () => { + it("emits one provider-reported usage event for each terminal assistant message", async () => { const assistantMessage = { - id: 'message-assistant-1', - sessionID: 'session-1', - role: 'assistant', + id: "message-assistant-1", + sessionID: "session-1", + role: "assistant", time: { created: 1, - completed: 2 + completed: 2, }, - parentID: 'message-user-1', - modelID: 'claude-sonnet-provider', - providerID: 'anthropic', - mode: 'build', - agent: 'build', + parentID: "message-user-1", + modelID: "claude-sonnet-provider", + providerID: "anthropic", + mode: "build", + agent: "build", path: { cwd: process.cwd(), - root: process.cwd() + root: process.cwd(), }, cost: 0.01, tokens: { @@ -4187,93 +4361,91 @@ describe('OpenCodeRuntime model usage', () => { reasoning: 3, cache: { read: 7, - write: 5 - } - } - } + write: 5, + }, + }, + }; const { client } = runClient([ { - id: 'event-incomplete', - type: 'message.updated', + id: "event-incomplete", + type: "message.updated", properties: { - sessionID: 'session-1', + sessionID: "session-1", info: { ...assistantMessage, - time: { created: 1 } - } - } + time: { created: 1 }, + }, + }, }, { - id: 'event-terminal', - type: 'message.updated', + id: "event-terminal", + type: "message.updated", properties: { - sessionID: 'session-1', - info: assistantMessage - } + sessionID: "session-1", + info: assistantMessage, + }, }, { - id: 'event-terminal-duplicate', - type: 'message.updated', + id: "event-terminal-duplicate", + type: "message.updated", properties: { - sessionID: 'session-1', - info: assistantMessage - } + sessionID: "session-1", + info: assistantMessage, + }, }, { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); - const events = await collectRun(runtime) + const events = await collectRun(runtime); - expect( - events.filter((event) => event.type === 'model-usage') - ).toEqual([ + expect(events.filter((event) => event.type === "model-usage")).toEqual([ { - requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', - type: 'model-usage', - callId: 'message-assistant-1', - runtime: 'opencode', - provider: 'anthropic', - model: 'claude-sonnet-provider', + requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", + type: "model-usage", + callId: "message-assistant-1", + runtime: "opencode", + provider: "anthropic", + model: "claude-sonnet-provider", inputTokens: 23, outputTokens: 11, cacheReadTokens: 7, cacheWriteTokens: 5, - reportedTotalTokens: 42 - } - ]) - expect(events.at(-2)).toMatchObject({ type: 'model-usage' }) - expect(events.at(-1)).toMatchObject({ type: 'done' }) - await runtime.dispose() - }) + reportedTotalTokens: 42, + }, + ]); + expect(events.at(-2)).toMatchObject({ type: "model-usage" }); + expect(events.at(-1)).toMatchObject({ type: "done" }); + await runtime.dispose(); + }); - it('ignores assistant usage from another session', async () => { + it("ignores assistant usage from another session", async () => { const { client } = runClient([ { - id: 'event-unrelated-usage', - type: 'message.updated', + id: "event-unrelated-usage", + type: "message.updated", properties: { - sessionID: 'session-2', + sessionID: "session-2", info: { - id: 'message-assistant-2', - sessionID: 'session-2', - role: 'assistant', + id: "message-assistant-2", + sessionID: "session-2", + role: "assistant", time: { created: 1, - completed: 2 + completed: 2, }, - parentID: 'message-user-2', - modelID: 'unrelated-model', - providerID: 'unrelated-provider', - mode: 'build', - agent: 'build', + parentID: "message-user-2", + modelID: "unrelated-model", + providerID: "unrelated-provider", + mode: "build", + agent: "build", path: { cwd: process.cwd(), - root: process.cwd() + root: process.cwd(), }, cost: 0, tokens: { @@ -4282,25 +4454,23 @@ describe('OpenCodeRuntime model usage', () => { reasoning: 0, cache: { read: 0, - write: 0 - } - } - } - } + write: 0, + }, + }, + }, + }, }, { - id: 'event-idle', - type: 'session.idle', - properties: { sessionID: 'session-1' } - } - ]) - const runtime = embeddedRuntime(client) + id: "event-idle", + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + ]); + const runtime = embeddedRuntime(client); - const events = await collectRun(runtime) + const events = await collectRun(runtime); - expect( - events.filter((event) => event.type === 'model-usage') - ).toEqual([]) - await runtime.dispose() - }) -}) + expect(events.filter((event) => event.type === "model-usage")).toEqual([]); + await runtime.dispose(); + }); +}); diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index ed55113c..554d037a 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -4,10 +4,10 @@ import { type OpencodeClient, type PermissionRequest, type PermissionRuleset, - type QuestionRequest -} from '@opencode-ai/sdk/v2' -import spawn from 'cross-spawn' -import { createHash, randomBytes } from 'node:crypto' + type QuestionRequest, +} from "@opencode-ai/sdk/v2"; +import spawn from "cross-spawn"; +import { createHash, randomBytes } from "node:crypto"; import { cp, mkdir, @@ -16,222 +16,219 @@ import { rename, rm, stat, - writeFile -} from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import type { AgentQuestionAnswer, AgentRuntimeStatus, - SubagentEvent -} from '../../shared/contracts' + SubagentEvent, +} from "../../shared/contracts"; import { boundedRuntimeIdentifierSchema, runtimeNativeInventoryLimits, type RuntimeConversationCompactInput, type RuntimeCustomizationSettings, type RuntimeNativeSnapshot, - type RuntimeNativeTool -} from '../../shared/runtime-customization-contracts' -import { createAnthropicApiBaseUrl } from './anthropic-endpoint' -import { createOpenAIApiBaseUrl } from './openai-endpoint' + type RuntimeNativeTool, +} from "../../shared/runtime-customization-contracts"; +import { createAnthropicApiBaseUrl } from "./anthropic-endpoint"; +import { createOpenAIApiBaseUrl } from "./openai-endpoint"; import type { AgentExecutionRequest, AgentRuntime, RuntimeConversationCompactOutcome, RuntimeEvent, - RuntimeModelUsageEvent -} from './runtime' -import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway' -import { detectRuntimeBinary } from './runtime-discovery' -import { getAvailableLoopbackPort } from './loopback-port' -import type { ResolvedModelProfile } from '../runtime-settings-store' + RuntimeModelUsageEvent, +} from "./runtime"; +import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway"; +import { detectRuntimeBinary } from "./runtime-discovery"; +import { getAvailableLoopbackPort } from "./loopback-port"; +import type { ResolvedModelProfile } from "../runtime-settings-store"; import { applyLaunchEnvironmentPath, buildExplicitProfileRuntimeEnvironment, buildRuntimeEnvironment, - runtimePrivacyEnvironment -} from './process-environment' -import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' -import { - boundedToolDetail, - safeToolErrorDetail -} from './approval-summary' + runtimePrivacyEnvironment, +} from "./process-environment"; +import type { LaunchEnvironmentProvider } from "../local-tool-environment/launch-environment-provider"; +import { boundedToolDetail, safeToolErrorDetail } from "./approval-summary"; import type { ResolvedMcpServer, - RuntimeSkillPackage -} from '../capabilities/capability-service' -import { stageRuntimeSkillPackages } from './runtime-skill-packages' + RuntimeSkillPackage, +} from "../capabilities/capability-service"; +import { stageRuntimeSkillPackages } from "./runtime-skill-packages"; import { requestProcessTreeTermination, - waitForProcessExit -} from './child-process-termination' -import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent' -import { promptWithUntrustedConversationHistory } from './runtime-conversation-history' + waitForProcessExit, +} from "./child-process-termination"; +import { + OpenCodeSubagentProgress, + toOpenCodeSubagentEvent, +} from "./opencode-subagent"; +import { promptWithUntrustedConversationHistory } from "./runtime-conversation-history"; -const STARTUP_TIMEOUT_MS = 30_000 -const STARTUP_POLL_INTERVAL_MS = 100 -const STARTUP_PROBE_TIMEOUT_MS = 500 -const CONTROL_REQUEST_TIMEOUT_MS = 30_000 -const MAX_PERMISSION_NAME_LENGTH = 128 -const MAX_PERMISSION_PATTERNS = 32 -const MAX_PERMISSION_PATTERN_LENGTH = 1_024 -const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024 -const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024 -const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024 -const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024 -const MAX_QUESTIONS_PER_REQUEST = 4 -const MAX_QUESTION_OPTIONS = 20 -const MAX_NATIVE_AGENTS = runtimeNativeInventoryLimits.agents -const MAX_NATIVE_TOOLS = runtimeNativeInventoryLimits.tools -const MAX_NATIVE_COMMANDS = runtimeNativeInventoryLimits.commands -const MAX_NATIVE_LSP = runtimeNativeInventoryLimits.lsp -const MAX_NATIVE_FORMATTERS = runtimeNativeInventoryLimits.formatters -const MAX_NATIVE_MCP_SERVERS = - runtimeNativeInventoryLimits.mcpServers -const MAX_NATIVE_SKILLS = runtimeNativeInventoryLimits.skills -const MAX_NATIVE_RESOURCES = runtimeNativeInventoryLimits.resources -const COMPACTION_USAGE_EVENT_GRACE_MS = 1_000 -const EMBEDDED_SERVER_USERNAME = 'goodbuddy' -const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u +const STARTUP_TIMEOUT_MS = 30_000; +const STARTUP_POLL_INTERVAL_MS = 100; +const STARTUP_PROBE_TIMEOUT_MS = 500; +const CONTROL_REQUEST_TIMEOUT_MS = 30_000; +const MAX_PERMISSION_NAME_LENGTH = 128; +const MAX_PERMISSION_PATTERNS = 32; +const MAX_PERMISSION_PATTERN_LENGTH = 1_024; +const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024; +const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024; +const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024; +const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024; +const MAX_QUESTIONS_PER_REQUEST = 4; +const MAX_QUESTION_OPTIONS = 20; +const MAX_NATIVE_AGENTS = runtimeNativeInventoryLimits.agents; +const MAX_NATIVE_TOOLS = runtimeNativeInventoryLimits.tools; +const MAX_NATIVE_COMMANDS = runtimeNativeInventoryLimits.commands; +const MAX_NATIVE_LSP = runtimeNativeInventoryLimits.lsp; +const MAX_NATIVE_FORMATTERS = runtimeNativeInventoryLimits.formatters; +const MAX_NATIVE_MCP_SERVERS = runtimeNativeInventoryLimits.mcpServers; +const MAX_NATIVE_SKILLS = runtimeNativeInventoryLimits.skills; +const MAX_NATIVE_RESOURCES = runtimeNativeInventoryLimits.resources; +const COMPACTION_USAGE_EVENT_GRACE_MS = 1_000; +const EMBEDDED_SERVER_USERNAME = "goodbuddy"; +const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const TEMPORARY_MCP_PREFIXES = [ - 'goodbuddy-data-', - 'goodbuddy-custom-' -] as const + "goodbuddy-data-", + "goodbuddy-custom-", +] as const; const temporaryMcpToolOverrides = Object.fromEntries( - TEMPORARY_MCP_PREFIXES.map((prefix) => [`${prefix}*`, false]) -) -const temporaryMcpDenyRules: PermissionRuleset = - TEMPORARY_MCP_PREFIXES.map((prefix) => ({ + TEMPORARY_MCP_PREFIXES.map((prefix) => [`${prefix}*`, false]), +); +const temporaryMcpDenyRules: PermissionRuleset = TEMPORARY_MCP_PREFIXES.map( + (prefix) => ({ permission: `${prefix}*`, - pattern: '*', - action: 'deny' - })) -const OPENCODE_INTERNAL_TOOL_IDS = new Set(['invalid']) + pattern: "*", + action: "deny", + }), +); +const OPENCODE_INTERNAL_TOOL_IDS = new Set(["invalid"]); const OPENCODE_BUILTIN_TOOL_KINDS: Readonly< - Partial> + Partial> > = { - apply_patch: 'write', - bash: 'shell', - edit: 'write', - glob: 'read', - grep: 'read', - question: 'interaction', - read: 'read', - skill: 'agent', - task: 'agent', - todowrite: 'agent', - webfetch: 'network', - websearch: 'network', - write: 'write' -} + apply_patch: "write", + bash: "shell", + edit: "write", + glob: "read", + grep: "read", + question: "interaction", + read: "read", + skill: "agent", + task: "agent", + todowrite: "agent", + webfetch: "network", + websearch: "network", + write: "write", +}; -type SpawnedProcess = ReturnType +type SpawnedProcess = ReturnType; type OpenCodeProviderConfig = { - model: string + model: string; provider: Record< string, { - name: string - npm: string + name: string; + npm: string; options: { - apiKey?: string - baseURL: string - headers?: Record - } + apiKey?: string; + baseURL: string; + headers?: Record; + }; models: Record< string, { - name: string - attachment: boolean + name: string; + attachment: boolean; modalities: { - input: Array<'text' | 'image'> - output: ['text'] - } + input: Array<"text" | "image">; + output: ["text"]; + }; provider: { - npm: string - } + npm: string; + }; } - > + >; } - > -} + >; +}; type OpenCodeProviderDescriptor = { - id: string - npm: string - baseURL: string -} + id: string; + npm: string; + baseURL: string; +}; type OpenCodeServer = { - url: string - authorization: string - close: () => Promise -} + url: string; + authorization: string; + close: () => Promise; +}; type OpenCodeSkillRegistration = { - root: string - configDirectory: string - skillsRoot: string -} + root: string; + configDirectory: string; + skillsRoot: string; +}; -const sharedSkillPreparations = new Map>() -const sharedConfigPreparations = new Map>() +const sharedSkillPreparations = new Map>(); +const sharedConfigPreparations = new Map>(); const executePermissionRules: PermissionRuleset = [ - { permission: '*', pattern: '*', action: 'allow' } -] + { permission: "*", pattern: "*", action: "allow" }, +]; const readOnlyPermissionRules: PermissionRuleset = [ - { permission: '*', pattern: '*', action: 'deny' } -] + { permission: "*", pattern: "*", action: "deny" }, +]; function resolveOpenCodeProvider( - profile: ResolvedModelProfile + profile: ResolvedModelProfile, ): OpenCodeProviderDescriptor { - if (profile.protocol === 'openai-images-generations') { - throw new Error( - 'OpenCode 独立模型连接不支持图像生成协议' - ) + if (profile.protocol === "openai-images-generations") { + throw new Error("OpenCode 独立模型连接不支持图像生成协议"); } - return profile.protocol === 'anthropic-messages' + return profile.protocol === "anthropic-messages" ? { - id: 'goodbuddy-anthropic', - npm: '@ai-sdk/anthropic', - baseURL: createAnthropicApiBaseUrl(profile.baseUrl) + id: "goodbuddy-anthropic", + npm: "@ai-sdk/anthropic", + baseURL: createAnthropicApiBaseUrl(profile.baseUrl), } - : profile.protocol === 'openai-chat-completions' + : profile.protocol === "openai-chat-completions" ? { - id: 'goodbuddy-openai-chat', - npm: '@ai-sdk/openai-compatible', - baseURL: createOpenAIApiBaseUrl(profile.baseUrl) + id: "goodbuddy-openai-chat", + npm: "@ai-sdk/openai-compatible", + baseURL: createOpenAIApiBaseUrl(profile.baseUrl), } : { - id: 'goodbuddy-openai-responses', - npm: '@ai-sdk/openai', - baseURL: createOpenAIApiBaseUrl(profile.baseUrl) - } + id: "goodbuddy-openai-responses", + npm: "@ai-sdk/openai", + baseURL: createOpenAIApiBaseUrl(profile.baseUrl), + }; } function createOpenCodeProviderConfig( - profile: ResolvedModelProfile + profile: ResolvedModelProfile, ): OpenCodeProviderConfig { - const provider = resolveOpenCodeProvider(profile) + const provider = resolveOpenCodeProvider(profile); const options: { - apiKey?: string - baseURL: string - headers?: Record + apiKey?: string; + baseURL: string; + headers?: Record; } = { baseURL: provider.baseURL, - ...(profile.requestHeaders && - Object.keys(profile.requestHeaders).length > 0 + ...(profile.requestHeaders && Object.keys(profile.requestHeaders).length > 0 ? { headers: profile.requestHeaders } - : {}) - } - if (profile.authentication === 'api-key' && profile.apiKey) { - options.apiKey = profile.apiKey + : {}), + }; + if (profile.authentication === "api-key" && profile.apiKey) { + options.apiKey = profile.apiKey; } return { model: `${provider.id}/${profile.modelName}`, @@ -245,73 +242,70 @@ function createOpenCodeProviderConfig( name: profile.name, attachment: profile.supportsImageInput === true, modalities: { - input: profile.supportsImageInput === true - ? ['text', 'image'] - : ['text'], - output: ['text'] + input: + profile.supportsImageInput === true + ? ["text", "image"] + : ["text"], + output: ["text"], }, provider: { - npm: provider.npm - } - } - } - } - } - } + npm: provider.npm, + }, + }, + }, + }, + }, + }; } function isRecord(value: unknown): value is Record { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ) + return typeof value === "object" && value !== null && !Array.isArray(value); } function opencodeErrorMessage(value: unknown, fallback: string): string { - return safeToolErrorDetail(value, 1_000) ?? fallback + return safeToolErrorDetail(value, 1_000) ?? fallback; } function byteLengthWithin(value: string, maximum: number): boolean { - return Buffer.byteLength(value) <= maximum + return Buffer.byteLength(value) <= maximum; } function createPublicQuestionId( requestId: string, sessionId: string, - upstreamQuestionId: string + upstreamQuestionId: string, ): string { - return `opencode-${createHash('sha256') + return `opencode-${createHash("sha256") .update(`${requestId}\0${sessionId}\0${upstreamQuestionId}`) - .digest('hex') - .slice(0, 48)}` + .digest("hex") + .slice(0, 48)}`; } function executionDeadlineLabel(milliseconds: number): string { return milliseconds % 60_000 === 0 ? `${milliseconds / 60_000} 分钟` - : `${milliseconds} 毫秒` + : `${milliseconds} 毫秒`; } function awaitWithAbort( operation: Promise, - signal: AbortSignal + signal: AbortSignal, ): Promise { - signal.throwIfAborted() + signal.throwIfAborted(); return new Promise((resolveOperation, rejectOperation) => { - const abort = (): void => rejectOperation(signal.reason) - signal.addEventListener('abort', abort, { once: true }) + const abort = (): void => rejectOperation(signal.reason); + signal.addEventListener("abort", abort, { once: true }); operation.then( (value) => { - signal.removeEventListener('abort', abort) - resolveOperation(value) + signal.removeEventListener("abort", abort); + resolveOperation(value); }, (error: unknown) => { - signal.removeEventListener('abort', abort) - rejectOperation(error) - } - ) - }) + signal.removeEventListener("abort", abort); + rejectOperation(error); + }, + ); + }); } function areBoundedPatterns(value: unknown): value is string[] { @@ -320,30 +314,26 @@ function areBoundedPatterns(value: unknown): value is string[] { value.length <= MAX_PERMISSION_PATTERNS && value.every( (pattern) => - typeof pattern === 'string' && - pattern.length <= MAX_PERMISSION_PATTERN_LENGTH + typeof pattern === "string" && + pattern.length <= MAX_PERMISSION_PATTERN_LENGTH, ) && - byteLengthWithin( - value.join('\0'), - MAX_PERMISSION_PATTERNS_BYTES - ) - ) + byteLengthWithin(value.join("\0"), MAX_PERMISSION_PATTERNS_BYTES) + ); } function parsePermissionRequest( properties: unknown, - sessionId: string + sessionId: string, ): PermissionRequest | undefined { if (!isRecord(properties) || properties.sessionID !== sessionId) { - return undefined + return undefined; } - const { id, permission, patterns, metadata, always, tool } = - properties + const { id, permission, patterns, metadata, always, tool } = properties; if ( - typeof id !== 'string' || + typeof id !== "string" || id.length === 0 || id.length > MAX_PERMISSION_NAME_LENGTH || - typeof permission !== 'string' || + typeof permission !== "string" || permission.length === 0 || permission.length > MAX_PERMISSION_NAME_LENGTH || !areBoundedPatterns(patterns) || @@ -351,42 +341,37 @@ function parsePermissionRequest( !areBoundedPatterns(always) || (tool !== undefined && (!isRecord(tool) || - typeof tool.messageID !== 'string' || + typeof tool.messageID !== "string" || tool.messageID.length === 0 || tool.messageID.length > 256 || - typeof tool.callID !== 'string' || + typeof tool.callID !== "string" || tool.callID.length === 0 || tool.callID.length > 256)) ) { - throw new Error('OpenCode 权限请求格式无效') + throw new Error("OpenCode 权限请求格式无效"); } - let serializedMetadata: string + let serializedMetadata: string; try { - serializedMetadata = JSON.stringify(metadata) + serializedMetadata = JSON.stringify(metadata); } catch { - throw new Error('OpenCode 权限请求元数据无效') + throw new Error("OpenCode 权限请求元数据无效"); } - if ( - !byteLengthWithin( - serializedMetadata, - MAX_PERMISSION_METADATA_BYTES - ) - ) { - throw new Error('OpenCode 权限请求元数据超过安全限制') + if (!byteLengthWithin(serializedMetadata, MAX_PERMISSION_METADATA_BYTES)) { + throw new Error("OpenCode 权限请求元数据超过安全限制"); } - return properties as PermissionRequest + return properties as PermissionRequest; } function parseQuestionRequest( properties: unknown, - sessionId: string + sessionId: string, ): QuestionRequest | undefined { if (!isRecord(properties) || properties.sessionID !== sessionId) { - return undefined + return undefined; } - const { id, questions, tool } = properties + const { id, questions, tool } = properties; if ( - typeof id !== 'string' || + typeof id !== "string" || id.length === 0 || id.length > MAX_PERMISSION_NAME_LENGTH || !Array.isArray(questions) || @@ -395,10 +380,10 @@ function parseQuestionRequest( !questions.every( (question) => isRecord(question) && - typeof question.question === 'string' && + typeof question.question === "string" && question.question.trim().length > 0 && question.question.length <= 2_000 && - typeof question.header === 'string' && + typeof question.header === "string" && question.header.trim().length > 0 && question.header.length <= 120 && Array.isArray(question.options) && @@ -406,49 +391,48 @@ function parseQuestionRequest( question.options.every( (option) => isRecord(option) && - typeof option.label === 'string' && + typeof option.label === "string" && option.label.trim().length > 0 && option.label.length <= 200 && - typeof option.description === 'string' && - option.description.length <= 1_000 + typeof option.description === "string" && + option.description.length <= 1_000, ) && (question.multiple === undefined || - typeof question.multiple === 'boolean') && - (question.custom === undefined || - typeof question.custom === 'boolean') + typeof question.multiple === "boolean") && + (question.custom === undefined || typeof question.custom === "boolean"), ) || (tool !== undefined && (!isRecord(tool) || - typeof tool.messageID !== 'string' || + typeof tool.messageID !== "string" || tool.messageID.length === 0 || tool.messageID.length > 256 || - typeof tool.callID !== 'string' || + typeof tool.callID !== "string" || tool.callID.length === 0 || tool.callID.length > 256)) ) { - throw new Error('OpenCode 提问请求格式无效') + throw new Error("OpenCode 提问请求格式无效"); } - let serialized: string + let serialized: string; try { - serialized = JSON.stringify(properties) + serialized = JSON.stringify(properties); } catch { - throw new Error('OpenCode 提问请求无法序列化') + throw new Error("OpenCode 提问请求无法序列化"); } if (!byteLengthWithin(serialized, MAX_QUESTION_REQUEST_BYTES)) { - throw new Error('OpenCode 提问请求超过安全限制') + throw new Error("OpenCode 提问请求超过安全限制"); } - return properties as QuestionRequest + return properties as QuestionRequest; } function isSafeTokenCount(value: number): boolean { - return Number.isSafeInteger(value) && value >= 0 + return Number.isSafeInteger(value) && value >= 0; } function createUsageEvent( requestId: string, - message: AssistantMessage + message: AssistantMessage, ): RuntimeModelUsageEvent | undefined { - const { tokens } = message + const { tokens } = message; if ( message.time.completed === undefined || !isSafeTokenCount(tokens.input) || @@ -457,14 +441,14 @@ function createUsageEvent( !isSafeTokenCount(tokens.cache.write) || (tokens.total !== undefined && !isSafeTokenCount(tokens.total)) ) { - return undefined + return undefined; } return { requestId, - type: 'model-usage', + type: "model-usage", callId: message.id.slice(0, 256), - runtime: 'opencode', + runtime: "opencode", provider: message.providerID.slice(0, 100), model: message.modelID.slice(0, 500), inputTokens: tokens.input, @@ -473,112 +457,110 @@ function createUsageEvent( cacheWriteTokens: tokens.cache.write, ...(tokens.total === undefined ? {} - : { reportedTotalTokens: tokens.total }) - } + : { reportedTotalTokens: tokens.total }), + }; } export type OpenCodeRuntimeDependencies = { - spawn: typeof spawn + spawn: typeof spawn; detectBinary: ( - runtime: 'opencode', + runtime: "opencode", configuredPath: string, - bundledPath?: string - ) => Promise<{ path?: string; detail: string }> + bundledPath?: string, + ) => Promise<{ path?: string; detail: string }>; validateBinary: ( - runtime: 'opencode', + runtime: "opencode", configuredPath: string, - bundledPath?: string - ) => Promise<{ path?: string; detail: string }> - createClient: typeof createOpencodeClient + bundledPath?: string, + ) => Promise<{ path?: string; detail: string }>; + createClient: typeof createOpencodeClient; checkServerHealth: ( url: string, authorization: string, - signal: AbortSignal - ) => Promise - platform: NodeJS.Platform - startupTimeoutMs: number - controlRequestTimeoutMs: number - executionTimeoutMs?: number -} + signal: AbortSignal, + ) => Promise; + platform: NodeJS.Platform; + startupTimeoutMs: number; + controlRequestTimeoutMs: number; + executionTimeoutMs?: number; +}; export type OpenCodeRuntimeOptions = { - baseUrl?: string - embedded: boolean - binaryPath: string - bundledBinaryPath?: string - bundledConfigPath?: string - configPath: string - defaultWorkspace: string - modelProfile?: ResolvedModelProfile - skillInstructions?: string - skillPackages?: RuntimeSkillPackage[] - sharedCacheRoot?: string - knowledgeGateway?: KnowledgeMcpGateway - mcpServers?: ResolvedMcpServer[] - customization?: RuntimeCustomizationSettings['opencode'] - launchEnvironmentProvider?: LaunchEnvironmentProvider -} + baseUrl?: string; + embedded: boolean; + binaryPath: string; + bundledBinaryPath?: string; + bundledConfigPath?: string; + configPath: string; + defaultWorkspace: string; + modelProfile?: ResolvedModelProfile; + skillInstructions?: string; + skillPackages?: RuntimeSkillPackage[]; + sharedCacheRoot?: string; + knowledgeGateway?: KnowledgeMcpGateway; + mcpServers?: ResolvedMcpServer[]; + customization?: RuntimeCustomizationSettings["opencode"]; + launchEnvironmentProvider?: LaunchEnvironmentProvider; +}; function boundedNativeIdentifier(value: unknown): string | undefined { - const parsed = boundedRuntimeIdentifierSchema.safeParse(value) - return parsed.success ? parsed.data : undefined + const parsed = boundedRuntimeIdentifierSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; } function boundedNativeText( value: unknown, - maximum: number + maximum: number, ): string | undefined { - if (typeof value !== 'string') { - return undefined + if (typeof value !== "string") { + return undefined; } - const normalized = value.trim() - return normalized ? normalized.slice(0, maximum) : undefined + const normalized = value.trim(); + return normalized ? normalized.slice(0, maximum) : undefined; } function isTemporaryMcpName(value: string): boolean { - return TEMPORARY_MCP_PREFIXES.some((prefix) => - value.startsWith(prefix) - ) + return TEMPORARY_MCP_PREFIXES.some((prefix) => value.startsWith(prefix)); } function isTemporaryMcpInventoryItem( ...values: Array ): boolean { return values.some( - (value) => value !== undefined && isTemporaryMcpName(value) - ) + (value) => value !== undefined && isTemporaryMcpName(value), + ); } function mapOpenCodeNativeTools( - values: readonly string[] + values: readonly string[], ): RuntimeNativeTool[] { - const seen = new Set() - const tools: RuntimeNativeTool[] = [] + const seen = new Set(); + const tools: RuntimeNativeTool[] = []; for (const value of values) { if (tools.length >= MAX_NATIVE_TOOLS) { - break + break; } - const id = boundedNativeIdentifier(value) + const id = boundedNativeIdentifier(value); if ( !id || seen.has(id) || OPENCODE_INTERNAL_TOOL_IDS.has(id) || isTemporaryMcpName(id) ) { - continue + continue; } - seen.add(id) - const builtinKind = OPENCODE_BUILTIN_TOOL_KINDS[id] + seen.add(id); + const builtinKind = OPENCODE_BUILTIN_TOOL_KINDS[id]; tools.push({ id, name: id.slice(0, 200), - kind: builtinKind ?? 'other', - source: builtinKind ? 'runtime' : 'unknown', - ask: id === 'skill' ? 'conditional' : 'blocked', - execute: 'allowed' - }) + kind: builtinKind ?? "other", + source: builtinKind ? "runtime" : "unknown", + ask: id === "skill" ? "conditional" : "blocked", + execute: "allowed", + }); } - return tools + return tools; } function isLocalPathLikeResourceUri(value: string): boolean { @@ -586,183 +568,175 @@ function isLocalPathLikeResourceUri(value: string): boolean { /^file:/iu.test(value) || /^[a-z]:[\\/]/iu.test(value) || /^(?:[\\/]{1,2}|\.\.?[\\/]|~[\\/])/u.test(value) - ) + ); } function boundedResourceUri(value: unknown): string | undefined { - const bounded = boundedNativeText(value, 2_048) + const bounded = boundedNativeText(value, 2_048); if ( !bounded || isLocalPathLikeResourceUri(bounded) || /^data:/iu.test(bounded) ) { - return undefined + return undefined; } try { - const url = new URL(bounded) - if (url.protocol === 'file:' || url.protocol === 'data:') { - return undefined + const url = new URL(bounded); + if (url.protocol === "file:" || url.protocol === "data:") { + return undefined; } - url.username = '' - url.password = '' - url.search = '' - url.hash = '' - return url.toString().slice(0, 2_048) + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString().slice(0, 2_048); } catch { - return bounded.includes('?') || bounded.includes('#') - ? undefined - : bounded + return bounded.includes("?") || bounded.includes("#") ? undefined : bounded; } } function createSkillPermissionRules( - skillIds: readonly string[] + skillIds: readonly string[], ): PermissionRuleset { if (skillIds.length === 0) { - return [] + return []; } return Object.entries(createSkillPermissionConfig(skillIds)).map( ([pattern, action]) => ({ - permission: 'skill', + permission: "skill", pattern, - action - }) - ) + action, + }), + ); } function createSkillPermissionConfig( - skillIds: readonly string[] -): Record { + skillIds: readonly string[], +): Record { return Object.fromEntries([ - ['*', 'deny' as const], - ...skillIds.map((skillId) => [skillId, 'allow' as const]) - ]) + ["*", "deny" as const], + ...skillIds.map((skillId) => [skillId, "allow" as const]), + ]); } function createOpenCodeSkillConfig( registration: OpenCodeSkillRegistration, - skillIds: readonly string[] + skillIds: readonly string[], ): { - skills: { paths: string[]; urls: never[] } + skills: { paths: string[]; urls: never[] }; permission: { - skill: Record - } + skill: Record; + }; } { return { skills: { paths: [registration.skillsRoot], - urls: [] + urls: [], }, permission: { - skill: createSkillPermissionConfig(skillIds) - } - } + skill: createSkillPermissionConfig(skillIds), + }, + }; } async function normalizeOpenCodeSkillManifest( skillDirectory: string, - skillId: string + skillId: string, ): Promise { - const manifestPath = join(skillDirectory, 'SKILL.md') - const content = await readFile(manifestPath, 'utf8') - const match = - /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(content) + const manifestPath = join(skillDirectory, "SKILL.md"); + const content = await readFile(manifestPath, "utf8"); + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(content); if (!match?.[1] || !match[2]?.trim()) { - throw new Error('OpenCode Skill 清单格式无效') + throw new Error("OpenCode Skill 清单格式无效"); } - const metadata = parseYaml(match[1]) + const metadata = parseYaml(match[1]); if ( - typeof metadata !== 'object' || + typeof metadata !== "object" || metadata === null || Array.isArray(metadata) ) { - throw new Error('OpenCode Skill 清单元数据无效') + throw new Error("OpenCode Skill 清单元数据无效"); } const normalizedMetadata: Record = { ...metadata, - name: skillId - } - delete normalizedMetadata.id + name: skillId, + }; + delete normalizedMetadata.id; await writeFile( manifestPath, - [ - '---', - stringifyYaml(normalizedMetadata).trimEnd(), - '---', - match[2] - ].join('\n'), - 'utf8' - ) + ["---", stringifyYaml(normalizedMetadata).trimEnd(), "---", match[2]].join( + "\n", + ), + "utf8", + ); } async function defaultDetectBinary( - runtime: 'opencode', + runtime: "opencode", configuredPath: string, - bundledPath?: string + bundledPath?: string, ): Promise<{ path?: string; detail: string }> { return detectRuntimeBinary({ binaryPath: configuredPath, bundledPath, - validation: 'path-only', + validation: "path-only", binaryNames: [runtime], - label: 'OpenCode CLI' - }) + label: "OpenCode CLI", + }); } async function defaultValidateBinary( - runtime: 'opencode', - configuredPath: string + runtime: "opencode", + configuredPath: string, ): Promise<{ path?: string; detail: string }> { return detectRuntimeBinary({ binaryPath: configuredPath, - validation: 'canonical-file', + validation: "canonical-file", allowAutomaticDiscovery: false, binaryNames: [runtime], - label: 'OpenCode CLI' - }) + label: "OpenCode CLI", + }); } async function defaultCheckServerHealth( url: string, authorization: string, - signal: AbortSignal + signal: AbortSignal, ): Promise { const client = createOpencodeClient({ baseUrl: url, - headers: { Authorization: authorization } - }) - const response = await client.global.health({ signal }) - return response.error === undefined && response.data?.healthy === true + headers: { Authorization: authorization }, + }); + const response = await client.global.health({ signal }); + return response.error === undefined && response.data?.healthy === true; } export class OpenCodeRuntime implements AgentRuntime { - readonly runtimeId = 'opencode' - readonly requiresToolApproval = false - readonly supportsToolExecution = true - private client?: OpencodeClient - private clientInitialization?: Promise - private server?: OpenCodeServer - private startingChild?: SpawnedProcess - private readonly sessions = new Map() - private readonly sessionInitializations = new Map< - string, - Promise - >() + readonly runtimeId = "opencode"; + readonly requiresToolApproval = false; + readonly supportsToolExecution = true; + private client?: OpencodeClient; + private clientInitialization?: Promise; + private server?: OpenCodeServer; + private startingChild?: SpawnedProcess; + private readonly sessions = new Map(); + private readonly sessionInitializations = new Map>(); private readonly pendingQuestions = new Map< string, { - client: OpencodeClient - directory: string - questionCount: number - upstreamQuestionId: string + client: OpencodeClient; + directory: string; + questionCount: number; + upstreamQuestionId: string; } - >() - private readonly conversationRunTails = new Map>() - private readonly dependencies: OpenCodeRuntimeDependencies + >(); + private readonly conversationRunTails = new Map>(); + private mcpMutationTail: Promise = Promise.resolve(); + private readonly dependencies: OpenCodeRuntimeDependencies; constructor( private readonly options: OpenCodeRuntimeOptions, - dependencies: Partial = {} + dependencies: Partial = {}, ) { this.dependencies = { spawn, @@ -773,468 +747,461 @@ export class OpenCodeRuntime implements AgentRuntime { platform: process.platform, startupTimeoutMs: STARTUP_TIMEOUT_MS, controlRequestTimeoutMs: CONTROL_REQUEST_TIMEOUT_MS, - ...dependencies - } + ...dependencies, + }; } private usesEmbeddedPermissionMediation(): boolean { - return this.options.embedded && !this.options.baseUrl + return this.options.embedded && !this.options.baseUrl; } private supportsNativeCustomization(): boolean { - return this.usesEmbeddedPermissionMediation() + return this.usesEmbeddedPermissionMediation(); } get supportsScopedDataTools(): boolean { - return this.usesEmbeddedPermissionMediation() + return this.usesEmbeddedPermissionMediation(); } private async acquireConversationRun( conversationId: string, - signal: AbortSignal + signal: AbortSignal, ): Promise<() => void> { - signal.throwIfAborted() + signal.throwIfAborted(); const previous = - this.conversationRunTails.get(conversationId) ?? Promise.resolve() - let releaseGate!: () => void + this.conversationRunTails.get(conversationId) ?? Promise.resolve(); + let releaseGate!: () => void; const gate = new Promise((resolve) => { - releaseGate = resolve - }) + releaseGate = resolve; + }); const tail = previous.then( () => gate, - () => gate - ) - this.conversationRunTails.set(conversationId, tail) - let abort!: () => void + () => gate, + ); + this.conversationRunTails.set(conversationId, tail); + let abort!: () => void; const aborted = new Promise((_resolve, reject) => { - abort = () => reject(signal.reason) - }) - signal.addEventListener('abort', abort, { once: true }) + abort = () => reject(signal.reason); + }); + signal.addEventListener("abort", abort, { once: true }); try { - await Promise.race([previous, aborted]) - signal.throwIfAborted() + await Promise.race([previous, aborted]); + signal.throwIfAborted(); return () => { - releaseGate() + releaseGate(); if (this.conversationRunTails.get(conversationId) === tail) { - this.conversationRunTails.delete(conversationId) + this.conversationRunTails.delete(conversationId); } - } + }; } catch (error) { - releaseGate() + releaseGate(); if (this.conversationRunTails.get(conversationId) === tail) { - this.conversationRunTails.delete(conversationId) + this.conversationRunTails.delete(conversationId); } - throw error + throw error; } finally { - signal.removeEventListener('abort', abort) + signal.removeEventListener("abort", abort); } } private terminate(child: SpawnedProcess): void { requestProcessTreeTermination(child, { platform: this.dependencies.platform, - spawn: this.dependencies.spawn - }) + spawn: this.dependencies.spawn, + }); } private waitForExit(child: SpawnedProcess): Promise { - return waitForProcessExit(child) + return waitForProcessExit(child); } private getNativeSkillIds(): string[] { if (!this.usesEmbeddedPermissionMediation()) { - return [] + return []; } - const ids = (this.options.skillPackages ?? []).map( - (skill) => skill.id - ) + const ids = (this.options.skillPackages ?? []).map((skill) => skill.id); if ( new Set(ids).size !== ids.length || - ids.some( - (id) => - id.length > 64 || !OPENCODE_SKILL_NAME_PATTERN.test(id) - ) + ids.some((id) => id.length > 64 || !OPENCODE_SKILL_NAME_PATTERN.test(id)) ) { - throw new Error('OpenCode Skill 注册信息无效') + throw new Error("OpenCode Skill 注册信息无效"); } - return ids + return ids; } private async normalizeSkillPackages(skillsRoot: string): Promise { for (const skill of this.options.skillPackages ?? []) { await normalizeOpenCodeSkillManifest( join(skillsRoot, skill.id), - skill.id - ) + skill.id, + ); + } + } + + private async mutateMcp( + signal: AbortSignal, + operation: () => Promise, + ): Promise { + signal.throwIfAborted(); + const previous = this.mcpMutationTail; + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + this.mcpMutationTail = previous.then( + () => current, + () => current, + ); + try { + await awaitWithAbort(previous, signal); + signal.throwIfAborted(); + return await operation(); + } finally { + release(); } } private async prepareSharedSkills(sharedRoot: string): Promise { - const packages = this.options.skillPackages ?? [] - const fingerprint = createHash('sha256') + const packages = this.options.skillPackages ?? []; + const fingerprint = createHash("sha256") .update( packages .map((skill) => ({ id: skill.id, - digest: skill.digest ?? resolve(skill.directory) + digest: skill.digest ?? resolve(skill.directory), })) .sort((left, right) => left.id.localeCompare(right.id)) .map((skill) => `${skill.id}\0${skill.digest}`) - .join('\0') + .join("\0"), ) - .digest('hex') - const cacheRoot = join(sharedRoot, 'skills') - const target = join(cacheRoot, fingerprint) - const existing = sharedSkillPreparations.get(target) + .digest("hex"); + const cacheRoot = join(sharedRoot, "skills"); + const target = join(cacheRoot, fingerprint); + const existing = sharedSkillPreparations.get(target); if (existing) { - return existing + return existing; } const preparation = (async () => { try { if ((await stat(target)).isDirectory()) { - return target + return target; } } catch { // The immutable Skill snapshot has not been prepared yet. } - await mkdir(cacheRoot, { recursive: true, mode: 0o700 }) - const stagingRoot = await mkdtemp(join(cacheRoot, '.staging-')) + await mkdir(cacheRoot, { recursive: true, mode: 0o700 }); + const stagingRoot = await mkdtemp(join(cacheRoot, ".staging-")); try { const staged = await stageRuntimeSkillPackages( stagingRoot, packages, - 'OpenCode' - ) - await this.normalizeSkillPackages(staged) + "OpenCode", + ); + await this.normalizeSkillPackages(staged); try { - await rename(staged, target) + await rename(staged, target); } catch (error) { try { if ((await stat(target)).isDirectory()) { - return target + return target; } } catch { // Preserve the original rename failure below. } - throw error + throw error; } - return target + return target; } finally { - await rm(stagingRoot, { recursive: true, force: true }) + await rm(stagingRoot, { recursive: true, force: true }); } - })() - sharedSkillPreparations.set(target, preparation) + })(); + sharedSkillPreparations.set(target, preparation); try { - return await preparation + return await preparation; } catch (error) { - sharedSkillPreparations.delete(target) - throw error + sharedSkillPreparations.delete(target); + throw error; } } private async createSkillRegistration(): Promise { - const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-')) + const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-")); try { - const sharedRoot = this.options.sharedCacheRoot?.trim() + const sharedRoot = this.options.sharedCacheRoot?.trim(); if (sharedRoot) { - const configDirectory = join( - resolve(sharedRoot), - 'config', - 'opencode' - ) - await this.prepareSharedConfig(configDirectory) - const skillsRoot = await this.prepareSharedSkills( - resolve(sharedRoot) - ) - return { root, configDirectory, skillsRoot } + const configDirectory = join(resolve(sharedRoot), "config", "opencode"); + await this.prepareSharedConfig(configDirectory); + const skillsRoot = await this.prepareSharedSkills(resolve(sharedRoot)); + return { root, configDirectory, skillsRoot }; } - const configDirectory = join(root, 'config') + const configDirectory = join(root, "config"); const skillsRoot = await stageRuntimeSkillPackages( configDirectory, this.options.skillPackages ?? [], - 'OpenCode' - ) - await this.normalizeSkillPackages(skillsRoot) - return { root, configDirectory, skillsRoot } + "OpenCode", + ); + await this.normalizeSkillPackages(skillsRoot); + return { root, configDirectory, skillsRoot }; } catch (error) { - await rm(root, { recursive: true, force: true }) - throw error + await rm(root, { recursive: true, force: true }); + throw error; } } - private async prepareSharedConfig( - configDirectory: string - ): Promise { - const bundledConfigPath = this.options.bundledConfigPath?.trim() + private async prepareSharedConfig(configDirectory: string): Promise { + const bundledConfigPath = this.options.bundledConfigPath?.trim(); if (!bundledConfigPath) { - await mkdir(configDirectory, { recursive: true, mode: 0o700 }) - return + await mkdir(configDirectory, { recursive: true, mode: 0o700 }); + return; } - const target = resolve(configDirectory) - const existing = sharedConfigPreparations.get(target) + const target = resolve(configDirectory); + const existing = sharedConfigPreparations.get(target); if (existing) { - return existing + return existing; } const preparation = (async () => { - const source = resolve(bundledConfigPath) - const markerName = '.goodbuddy-ready.json' - const sourceMarker = await readFile(join(source, markerName), 'utf8') + const source = resolve(bundledConfigPath); + const markerName = ".goodbuddy-ready.json"; + const sourceMarker = await readFile(join(source, markerName), "utf8"); try { if ( - (await readFile(join(target, markerName), 'utf8')) === - sourceMarker + (await readFile(join(target, markerName), "utf8")) === sourceMarker ) { - return + return; } } catch { // Install the bundled dependency tree below. } - await mkdir(dirname(target), { recursive: true, mode: 0o700 }) - const staging = `${target}.staging-${process.pid}-${randomBytes(6).toString('hex')}` - await rm(staging, { recursive: true, force: true }) + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + const staging = `${target}.staging-${process.pid}-${randomBytes(6).toString("hex")}`; + await rm(staging, { recursive: true, force: true }); try { await cp(source, staging, { recursive: true, errorOnExist: true, - force: false - }) - await rm(target, { recursive: true, force: true }) - await rename(staging, target) + force: false, + }); + await rm(target, { recursive: true, force: true }); + await rename(staging, target); } finally { - await rm(staging, { recursive: true, force: true }) + await rm(staging, { recursive: true, force: true }); } - })() - sharedConfigPreparations.set(target, preparation) + })(); + sharedConfigPreparations.set(target, preparation); try { - await preparation + await preparation; } catch (error) { - sharedConfigPreparations.delete(target) - throw error + sharedConfigPreparations.delete(target); + throw error; } } - private async inspectEmbeddedConfiguration( - validatePath = false - ): Promise<{ - path?: string - detail: string + private async inspectEmbeddedConfiguration(validatePath = false): Promise<{ + path?: string; + detail: string; }> { if ( - this.options.modelProfile?.authentication === 'api-key' && + this.options.modelProfile?.authentication === "api-key" && !this.options.modelProfile.apiKey ) { - throw new Error('OpenCode 独立模型连接尚未配置 API Key') + throw new Error("OpenCode 独立模型连接尚未配置 API Key"); } const detection = await this.dependencies.detectBinary( - 'opencode', + "opencode", this.options.binaryPath, - this.options.bundledBinaryPath - ) + this.options.bundledBinaryPath, + ); if (!validatePath || !detection.path) { - return detection + return detection; } - return this.dependencies.validateBinary( - 'opencode', - detection.path - ) + return this.dependencies.validateBinary("opencode", detection.path); } private async launchEmbedded(signal?: AbortSignal): Promise { if (signal?.aborted) { - throw new Error('OpenCode Server 启动已取消') + throw new Error("OpenCode Server 启动已取消"); } - const detection = await this.inspectEmbeddedConfiguration() - const binaryPath = detection.path + const detection = await this.inspectEmbeddedConfiguration(); + const binaryPath = detection.path; if (!binaryPath) { - throw new Error(detection.detail) + throw new Error(detection.detail); } if (signal?.aborted) { - throw new Error('OpenCode Server 启动已取消') + throw new Error("OpenCode Server 启动已取消"); } - const port = await getAvailableLoopbackPort() + const port = await getAvailableLoopbackPort(); if (signal?.aborted) { - throw new Error('OpenCode Server 启动已取消') + throw new Error("OpenCode Server 启动已取消"); } - const skillIds = this.getNativeSkillIds() - const registration = await this.createSkillRegistration() + const skillIds = this.getNativeSkillIds(); + const registration = await this.createSkillRegistration(); try { if (signal?.aborted) { - throw new Error('OpenCode Server 启动已取消') + throw new Error("OpenCode Server 启动已取消"); } - const profile = this.options.modelProfile + const profile = this.options.modelProfile; let env = profile ? buildExplicitProfileRuntimeEnvironment( runtimePrivacyEnvironment, - profile.authentication === 'api-key' && profile.apiKey + profile.authentication === "api-key" && profile.apiKey ? { name: - profile.protocol === 'anthropic-messages' - ? 'ANTHROPIC_API_KEY' - : 'OPENAI_API_KEY', - value: profile.apiKey + profile.protocol === "anthropic-messages" + ? "ANTHROPIC_API_KEY" + : "OPENAI_API_KEY", + value: profile.apiKey, } - : undefined + : undefined, ) - : buildRuntimeEnvironment(runtimePrivacyEnvironment) + : buildRuntimeEnvironment(runtimePrivacyEnvironment); env = applyLaunchEnvironmentPath( env, - this.options.launchEnvironmentProvider - ) - delete env.OPENCODE_CONFIG - delete env.OPENCODE_CONFIG_CONTENT - delete env.OPENCODE_CONFIG_DIR - delete env.OPENCODE_SERVER_PASSWORD - delete env.OPENCODE_SERVER_USERNAME - const serverPassword = randomBytes(32).toString('base64url') + this.options.launchEnvironmentProvider, + ); + delete env.OPENCODE_CONFIG; + delete env.OPENCODE_CONFIG_CONTENT; + delete env.OPENCODE_CONFIG_DIR; + delete env.OPENCODE_SERVER_PASSWORD; + delete env.OPENCODE_SERVER_USERNAME; + const serverPassword = randomBytes(32).toString("base64url"); const authorization = `Basic ${Buffer.from( - `${EMBEDDED_SERVER_USERNAME}:${serverPassword}` - ).toString('base64')}` - env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME - env.OPENCODE_SERVER_PASSWORD = serverPassword - env.OPENCODE_CONFIG_DIR = registration.configDirectory - env.OPENCODE_DISABLE_AUTOUPDATE = '1' - env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = '1' - env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = '1' - env.OPENCODE_DISABLE_EXTERNAL_SKILLS = '1' - env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = '1' - env.OPENCODE_DISABLE_LSP_DOWNLOAD = '1' - env.OPENCODE_DISABLE_MODELS_FETCH = '1' - env.OPENCODE_DISABLE_PROJECT_CONFIG = '1' - env.OPENCODE_DISABLE_SHARE = '1' + `${EMBEDDED_SERVER_USERNAME}:${serverPassword}`, + ).toString("base64")}`; + env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME; + env.OPENCODE_SERVER_PASSWORD = serverPassword; + env.OPENCODE_CONFIG_DIR = registration.configDirectory; + env.OPENCODE_DISABLE_AUTOUPDATE = "1"; + env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = "1"; + env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "1"; + env.OPENCODE_DISABLE_EXTERNAL_SKILLS = "1"; + env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "1"; + env.OPENCODE_DISABLE_LSP_DOWNLOAD = "1"; + env.OPENCODE_DISABLE_MODELS_FETCH = "1"; + env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"; + env.OPENCODE_DISABLE_SHARE = "1"; env.XDG_CACHE_HOME = this.options.sharedCacheRoot?.trim() - ? join(resolve(this.options.sharedCacheRoot), 'cache') - : join(registration.root, 'xdg-cache') + ? join(resolve(this.options.sharedCacheRoot), "cache") + : join(registration.root, "xdg-cache"); env.XDG_CONFIG_HOME = this.options.sharedCacheRoot?.trim() ? dirname(registration.configDirectory) - : join(registration.root, 'xdg-config') - env.XDG_DATA_HOME = join(registration.root, 'xdg-data') - env.XDG_STATE_HOME = join(registration.root, 'xdg-state') - const skillConfig = createOpenCodeSkillConfig( - registration, - skillIds - ) + : join(registration.root, "xdg-config"); + env.XDG_DATA_HOME = join(registration.root, "xdg-data"); + env.XDG_STATE_HOME = join(registration.root, "xdg-state"); + const skillConfig = createOpenCodeSkillConfig(registration, skillIds); env.OPENCODE_CONFIG_CONTENT = JSON.stringify( profile ? { ...createOpenCodeProviderConfig(profile), - ...skillConfig + ...skillConfig, } - : skillConfig - ) + : skillConfig, + ); if (!profile && this.options.configPath.trim()) { - env.OPENCODE_CONFIG = resolve(this.options.configPath) + env.OPENCODE_CONFIG = resolve(this.options.configPath); } - const serverArgs = [ - 'serve', - '--hostname=127.0.0.1', - `--port=${port}` - ] + const serverArgs = ["serve", "--hostname=127.0.0.1", `--port=${port}`]; return await new Promise((resolveServer, reject) => { - const child = this.dependencies.spawn( - binaryPath, - serverArgs, - { - cwd: this.options.defaultWorkspace, - env, - shell: false, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - } - ) - this.startingChild = child - const { stdout, stderr } = child - let settled = false - let pollTimer: NodeJS.Timeout | undefined - const probeController = new AbortController() + const child = this.dependencies.spawn(binaryPath, serverArgs, { + cwd: this.options.defaultWorkspace, + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + this.startingChild = child; + const { stdout, stderr } = child; + let settled = false; + let pollTimer: NodeJS.Timeout | undefined; + const probeController = new AbortController(); const probeSignal = signal ? AbortSignal.any([signal, probeController.signal]) - : probeController.signal - const url = `http://127.0.0.1:${port}` + : probeController.signal; + const url = `http://127.0.0.1:${port}`; const cleanupStartupListeners = (): void => { - clearTimeout(timeout) + clearTimeout(timeout); if (pollTimer) { - clearTimeout(pollTimer) + clearTimeout(pollTimer); } - probeController.abort() - signal?.removeEventListener('abort', abort) - child.removeListener('error', onError) - child.removeListener('close', onClose) - } + probeController.abort(); + signal?.removeEventListener("abort", abort); + child.removeListener("error", onError); + child.removeListener("close", onClose); + }; const fail = (message: string): void => { if (settled) { - return + return; } - settled = true - cleanupStartupListeners() + settled = true; + cleanupStartupListeners(); const clearStartingChild = (): void => { if (this.startingChild === child) { - this.startingChild = undefined + this.startingChild = undefined; } - } - const exited = this.waitForExit(child) - this.terminate(child) + }; + const exited = this.waitForExit(child); + this.terminate(child); void exited.finally(() => { if (child.exitCode !== null) { - clearStartingChild() + clearStartingChild(); } - reject(new Error(message.slice(0, 1_000))) - }) - } + reject(new Error(message.slice(0, 1_000))); + }); + }; const succeed = (url: string): void => { if (settled) { - return + return; } - settled = true - cleanupStartupListeners() + settled = true; + cleanupStartupListeners(); if (this.startingChild === child) { - this.startingChild = undefined + this.startingChild = undefined; } - stdout?.resume() - stderr?.resume() + stdout?.resume(); + stderr?.resume(); resolveServer({ url, authorization, close: async () => { try { - const exited = this.waitForExit(child) - this.terminate(child) - await exited + const exited = this.waitForExit(child); + this.terminate(child); + await exited; } finally { await rm(registration.root, { recursive: true, - force: true - }) + force: true, + }); } - } - }) - } + }, + }); + }; const onError = (): void => { - fail('OpenCode Server 启动失败') - } + fail("OpenCode Server 启动失败"); + }; const onClose = (code: number | null): void => { - fail(`OpenCode Server 启动前退出(code ${code ?? 'unknown'})`) - } + fail(`OpenCode Server 启动前退出(code ${code ?? "unknown"})`); + }; const abort = (): void => { - fail('OpenCode Server 启动已取消') - } + fail("OpenCode Server 启动已取消"); + }; const probe = async (): Promise => { try { const attemptSignal = AbortSignal.any([ probeSignal, - AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS) - ]) + AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS), + ]); if ( await this.dependencies.checkServerHealth( url, authorization, - attemptSignal + attemptSignal, ) ) { - succeed(url) - return + succeed(url); + return; } } catch { // The server is not ready yet. @@ -1242,113 +1209,140 @@ export class OpenCodeRuntime implements AgentRuntime { if (!settled) { pollTimer = setTimeout( () => void probe(), - STARTUP_POLL_INTERVAL_MS - ) + STARTUP_POLL_INTERVAL_MS, + ); } - } + }; const timeout = setTimeout(() => { - fail('OpenCode Server 启动超时(30 秒)') - }, this.dependencies.startupTimeoutMs) + fail("OpenCode Server 启动超时(30 秒)"); + }, this.dependencies.startupTimeoutMs); if (!stdout || !stderr) { - fail('OpenCode Server 管道初始化失败') - return + fail("OpenCode Server 管道初始化失败"); + return; } - stdout.resume() - stderr.resume() - child.once('error', onError) - child.once('close', onClose) - signal?.addEventListener('abort', abort, { once: true }) + stdout.resume(); + stderr.resume(); + child.once("error", onError); + child.once("close", onClose); + signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) { - abort() - return + abort(); + return; } - void probe() - }) + void probe(); + }); } catch (error) { await rm(registration.root, { recursive: true, - force: true - }).catch(() => undefined) - throw error + force: true, + }).catch(() => undefined); + throw error; } } private async getClient(signal?: AbortSignal): Promise { - if (this.client) { - return this.client - } - const existingInitialization = this.clientInitialization - this.clientInitialization ??= this.initializeClient(signal) - try { - return signal && existingInitialization - ? await awaitWithAbort(this.clientInitialization, signal) - : await this.clientInitialization - } catch (error) { - this.clientInitialization = undefined - throw error + signal?.throwIfAborted(); + if (this.client && !this.server) { + return this.client; } + const existingInitialization = this.clientInitialization; + const cached = Boolean(this.client && this.server); + // Cached health checks are shared, but each caller owns only its wait. + const initialization = this.clientInitialization ??= this.initializeClient( + cached ? undefined : signal, + ).finally(() => { + if (this.clientInitialization === initialization) { + this.clientInitialization = undefined; + } + }); + return signal && (cached || existingInitialization) + ? await awaitWithAbort(initialization, signal) + : await initialization; } private async controlRequest( label: string, operation: (signal: AbortSignal) => Promise, callerSignal?: AbortSignal, - onAbortedResult?: (value: T) => void | Promise + onAbortedResult?: (value: T) => void | Promise, ): Promise { - const timeoutController = new AbortController() - const timeout = setTimeout(() => { - timeoutController.abort( - new Error(`OpenCode ${label}超时`) - ) - }, Math.max(1, this.dependencies.controlRequestTimeoutMs)) - timeout.unref?.() + const timeoutController = new AbortController(); + const timeout = setTimeout( + () => { + timeoutController.abort(new Error(`OpenCode ${label}超时`)); + }, + Math.max(1, this.dependencies.controlRequestTimeoutMs), + ); + timeout.unref?.(); const controlSignal = callerSignal ? AbortSignal.any([callerSignal, timeoutController.signal]) - : timeoutController.signal + : timeoutController.signal; try { - controlSignal.throwIfAborted() - const request = operation(controlSignal) + controlSignal.throwIfAborted(); + const request = operation(controlSignal); if (onAbortedResult) { void request .then(async (value) => { if (controlSignal.aborted) { - await onAbortedResult(value) + await onAbortedResult(value); } }) - .catch(() => undefined) + .catch(() => undefined); } - return await awaitWithAbort( - request, - controlSignal - ) + return await awaitWithAbort(request, controlSignal); } catch (error) { - if ( - timeoutController.signal.aborted && - !callerSignal?.aborted - ) { - throw timeoutController.signal.reason + if (timeoutController.signal.aborted && !callerSignal?.aborted) { + throw timeoutController.signal.reason; } - throw error + throw error; } finally { - clearTimeout(timeout) + clearTimeout(timeout); } } private async initializeClient( - signal?: AbortSignal + signal?: AbortSignal, ): Promise { - let baseUrl = this.options.baseUrl + if (this.client && this.server) { + const probeSignal = AbortSignal.any([ + ...(signal ? [signal] : []), + AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS), + ]); + let healthy = false; + try { + healthy = await awaitWithAbort( + this.dependencies.checkServerHealth( + this.server.url, + this.server.authorization, + probeSignal, + ), + probeSignal, + ); + } catch { + // A dead or unresponsive cached server must not poison later requests. + } + signal?.throwIfAborted(); + if (healthy) return this.client; + const server = this.server; + this.server = undefined; + this.client = undefined; + this.sessions.clear(); + this.sessionInitializations.clear(); + this.pendingQuestions.clear(); + await server.close(); + } + let baseUrl = this.options.baseUrl; if (baseUrl && this.options.modelProfile) { - throw new Error('OpenCode 独立模型连接仅支持由 GoodBuddy 启动的本机服务') + throw new Error("OpenCode 独立模型连接仅支持由 GoodBuddy 启动的本机服务"); } if (!baseUrl && this.options.embedded) { - this.server = await this.launchEmbedded(signal) - baseUrl = this.server.url + this.server = await this.launchEmbedded(signal); + baseUrl = this.server.url; } if (!baseUrl) { - throw new Error('未配置 OpenCode Server') + throw new Error("未配置 OpenCode Server"); } this.client = this.dependencies.createClient({ @@ -1357,119 +1351,112 @@ export class OpenCodeRuntime implements AgentRuntime { ...(this.server ? { headers: { - Authorization: this.server.authorization - } + Authorization: this.server.authorization, + }, } - : {}) - }) - return this.client + : {}), + }); + return this.client; } async getStatus(): Promise { if (this.usesEmbeddedPermissionMediation() && !this.client) { try { - const detection = - await this.inspectEmbeddedConfiguration(true) + const detection = await this.inspectEmbeddedConfiguration(true); return { - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: Boolean(detection.path), supportsToolExecution: this.supportsToolExecution, detail: detection.path - ? 'OpenCode 已配置,将在首次使用时启动' - : detection.detail - } + ? "OpenCode 已配置,将在首次使用时启动" + : detection.detail, + }; } catch (error) { return { - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: false, supportsToolExecution: this.supportsToolExecution, - detail: - error instanceof Error ? error.message : 'OpenCode 不可用' - } + detail: error instanceof Error ? error.message : "OpenCode 不可用", + }; } } - return this.checkConnection() + return this.checkConnection(); } async testConnection(): Promise { - return this.checkConnection() + return this.checkConnection(); } private async checkConnection(): Promise { try { - const client = await this.getClient() - const response = await this.controlRequest( - '连接检查', - (signal) => - client.session.list( - { directory: this.options.defaultWorkspace }, - { signal } - ) - ) + const client = await this.getClient(); + const response = await this.controlRequest("连接检查", (signal) => + client.session.list( + { directory: this.options.defaultWorkspace }, + { signal }, + ), + ); if (response.error) { - throw new Error('OpenCode Server 返回错误') + throw new Error("OpenCode Server 返回错误"); } return { - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: true, supportsToolExecution: this.supportsToolExecution, detail: this.server - ? '由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程' - : `已连接 ${this.options.baseUrl}` - } + ? "由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程" + : `已连接 ${this.options.baseUrl}`, + }; } catch (error) { return { - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: false, supportsToolExecution: this.supportsToolExecution, - detail: error instanceof Error ? error.message : 'OpenCode 不可用' - } + detail: error instanceof Error ? error.message : "OpenCode 不可用", + }; } } private async discoverAgents( client: OpencodeClient, - signal?: AbortSignal + signal?: AbortSignal, ): Promise< Array<{ - id: string - name: string - description?: string - mode: 'primary' | 'subagent' | 'all' - native: boolean - hidden: boolean + id: string; + name: string; + description?: string; + mode: "primary" | "subagent" | "all"; + native: boolean; + hidden: boolean; }> > { const response = await this.controlRequest( - '读取 Agent 清单', + "读取 Agent 清单", (controlSignal) => client.app.agents( { - directory: this.options.defaultWorkspace + directory: this.options.defaultWorkspace, }, - { signal: controlSignal } + { signal: controlSignal }, ), - signal - ) + signal, + ); if (response.error || !response.data) { - throw new Error('OpenCode Agent 清单不可用') + throw new Error("OpenCode Agent 清单不可用"); } return response.data .flatMap((agent) => { - const id = boundedNativeIdentifier(agent.name) + const id = boundedNativeIdentifier(agent.name); if (!id) { - return [] + return []; } - const description = boundedNativeText( - agent.description, - 2_000 - ) + const description = boundedNativeText(agent.description, 2_000); return [ { id, @@ -1477,59 +1464,54 @@ export class OpenCodeRuntime implements AgentRuntime { ...(description ? { description } : {}), mode: agent.mode, native: agent.native === true, - hidden: agent.hidden === true - } - ] + hidden: agent.hidden === true, + }, + ]; }) - .slice(0, MAX_NATIVE_AGENTS) + .slice(0, MAX_NATIVE_AGENTS); } private async resolveSelectedAgent( client: OpencodeClient, request: AgentExecutionRequest, - signal: AbortSignal + signal: AbortSignal, ): Promise { const control = - request.runtimeControl?.provider === 'opencode' + request.runtimeControl?.provider === "opencode" ? request.runtimeControl - : undefined - const selected = - control?.agent ?? this.options.customization?.defaultAgent + : undefined; + const selected = control?.agent ?? this.options.customization?.defaultAgent; if (!selected) { - return undefined + return undefined; } if (!this.supportsNativeCustomization()) { - throw new Error( - '外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent' - ) + throw new Error("外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent"); } - const agents = await this.discoverAgents(client, signal) + const agents = await this.discoverAgents(client, signal); if ( !agents.some( (agent) => agent.id === selected && !agent.hidden && - (agent.mode === 'primary' || agent.mode === 'all') + (agent.mode === "primary" || agent.mode === "all"), ) ) { throw new Error( - `OpenCode Agent 不存在、已隐藏或不可作为主 Agent:${selected.slice(0, 128)}` - ) + `OpenCode Agent 不存在、已隐藏或不可作为主 Agent:${selected.slice(0, 128)}`, + ); } - return selected + return selected; } async getNativeSnapshot(): Promise { - const controlled = this.supportsNativeCustomization() + const controlled = this.supportsNativeCustomization(); const empty: RuntimeNativeSnapshot = { - provider: 'opencode', + provider: "opencode", available: false, - inventoryStatus: controlled - ? 'unavailable' - : 'connection-only', + inventoryStatus: controlled ? "unavailable" : "connection-only", detail: controlled - ? 'OpenCode 原生能力不可用' - : '外部 OpenCode Server 仅支持连接状态;GoodBuddy 不支持读取或控制其原生自定义能力', + ? "OpenCode 原生能力不可用" + : "外部 OpenCode Server 仅支持连接状态;GoodBuddy 不支持读取或控制其原生自定义能力", agents: [], tools: [], toolsSupported: controlled, @@ -1543,55 +1525,53 @@ export class OpenCodeRuntime implements AgentRuntime { resources: [], resourcesSupported: false, context: { - strategy: controlled ? 'native' : 'unsupported', + strategy: controlled ? "native" : "unsupported", manualCompact: controlled, detail: controlled - ? '由 OpenCode 原生上下文与手动 Compact 管理' - : '外部 OpenCode Server 不支持由 GoodBuddy 管理上下文' - } - } + ? "由 OpenCode 原生上下文与手动 Compact 管理" + : "外部 OpenCode Server 不支持由 GoodBuddy 管理上下文", + }, + }; if (!controlled) { try { - const client = await this.getClient() - const response = await this.controlRequest( - '连接检查', - (signal) => - client.session.list( - { - directory: this.options.defaultWorkspace, - limit: 1 - }, - { signal } - ) - ) + const client = await this.getClient(); + const response = await this.controlRequest("连接检查", (signal) => + client.session.list( + { + directory: this.options.defaultWorkspace, + limit: 1, + }, + { signal }, + ), + ); if (response.error || !response.data) { - throw new Error('connection check failed') + throw new Error("connection check failed"); } - return { ...empty, available: true } + return { ...empty, available: true }; } catch { return { ...empty, - inventoryStatus: 'unavailable', + inventoryStatus: "unavailable", detail: - '外部 OpenCode Server 无法连接,且不支持由 GoodBuddy 读取或控制原生自定义能力' - } + "外部 OpenCode Server 无法连接,且不支持由 GoodBuddy 读取或控制原生自定义能力", + }; } } - let client: OpencodeClient + let client: OpencodeClient; try { - client = await this.getClient() + client = await this.getClient(); } catch { return { ...empty, - detail: 'OpenCode Server 无法连接' - } + detail: "OpenCode Server 无法连接", + }; } - const directory = this.options.defaultWorkspace + const directory = this.options.defaultWorkspace; const assignedSkillIds = new Set( - (this.options.skillPackages ?? []).map((skill) => skill.id) - ) + (this.options.skillPackages ?? []).map((skill) => skill.id), + ); const [ availabilityResult, agentsResult, @@ -1601,162 +1581,153 @@ export class OpenCodeRuntime implements AgentRuntime { formattersResult, mcpResult, skillsResult, - resourcesResult + resourcesResult, ] = await Promise.allSettled([ - this.controlRequest('连接检查', (signal) => + this.controlRequest("连接检查", (signal) => client.session.list( { directory, - limit: 1 + limit: 1, }, - { signal } - ) + { signal }, + ), ), this.discoverAgents(client), - this.controlRequest('读取工具清单', (signal) => - client.tool.ids({ directory }, { signal }) + this.controlRequest("读取工具清单", (signal) => + client.tool.ids({ directory }, { signal }), ), - this.controlRequest('读取命令清单', (signal) => - client.command.list({ directory }, { signal }) + this.controlRequest("读取命令清单", (signal) => + client.command.list({ directory }, { signal }), ), - this.controlRequest('读取 LSP 状态', (signal) => - client.lsp.status({ directory }, { signal }) + this.controlRequest("读取 LSP 状态", (signal) => + client.lsp.status({ directory }, { signal }), ), - this.controlRequest('读取格式化器状态', (signal) => - client.formatter.status({ directory }, { signal }) + this.controlRequest("读取格式化器状态", (signal) => + client.formatter.status({ directory }, { signal }), ), - this.controlRequest('读取 MCP 状态', (signal) => - client.mcp.status({ directory }, { signal }) + this.controlRequest("读取 MCP 状态", (signal) => + client.mcp.status({ directory }, { signal }), ), - this.controlRequest('读取技能清单', (signal) => - client.app.skills({ directory }, { signal }) + this.controlRequest("读取技能清单", (signal) => + client.app.skills({ directory }, { signal }), ), - this.controlRequest('读取资源清单', (signal) => - client.experimental.resource.list( - { directory }, - { signal } - ) - ) - ]) + this.controlRequest("读取资源清单", (signal) => + client.experimental.resource.list({ directory }, { signal }), + ), + ]); if ( - availabilityResult.status === 'rejected' || + availabilityResult.status === "rejected" || availabilityResult.value.error || !availabilityResult.value.data ) { return { ...empty, - detail: 'OpenCode Server 无法连接' - } + detail: "OpenCode Server 无法连接", + }; } - const unavailable: string[] = [] + const unavailable: string[] = []; const agents = - agentsResult.status === 'fulfilled' + agentsResult.status === "fulfilled" ? agentsResult.value - : (unavailable.push('Agent'), []) + : (unavailable.push("Agent"), []); const toolIds = - toolsResult.status === 'fulfilled' && + toolsResult.status === "fulfilled" && !toolsResult.value.error && toolsResult.value.data ? toolsResult.value.data - : (unavailable.push('工具'), []) - const tools = mapOpenCodeNativeTools(toolIds) + : (unavailable.push("工具"), []); + const tools = mapOpenCodeNativeTools(toolIds); const commandData = - commandsResult.status === 'fulfilled' && + commandsResult.status === "fulfilled" && !commandsResult.value.error && commandsResult.value.data ? commandsResult.value.data - : (unavailable.push('命令'), []) + : (unavailable.push("命令"), []); const nativeCommandData = commandData .filter((command) => { - const name = boundedNativeIdentifier(command.name) + const name = boundedNativeIdentifier(command.name); return ( name !== undefined && !isTemporaryMcpInventoryItem(name) && !assignedSkillIds.has(name) - ) + ); }) - .slice(0, MAX_NATIVE_COMMANDS) + .slice(0, MAX_NATIVE_COMMANDS); const commands = nativeCommandData.flatMap((command) => { - const id = boundedNativeIdentifier(command.name) + const id = boundedNativeIdentifier(command.name); if (!id) { - return [] + return []; } - const description = boundedNativeText( - command.description, - 2_000 - ) - const agent = boundedNativeIdentifier(command.agent) + const description = boundedNativeText(command.description, 2_000); + const agent = boundedNativeIdentifier(command.agent); return [ { id, name: id.slice(0, 200), ...(description ? { description } : {}), - source: command.source ?? ('command' as const), - ...(agent ? { agent } : {}) - } - ] - }) + source: command.source ?? ("command" as const), + ...(agent ? { agent } : {}), + }, + ]; + }); const prompts = nativeCommandData.flatMap((command) => { - if (command.source !== 'mcp') { - return [] + if (command.source !== "mcp") { + return []; } - const id = boundedNativeIdentifier(command.name) - const prompt = boundedNativeText(command.template, 20_000) + const id = boundedNativeIdentifier(command.name); + const prompt = boundedNativeText(command.template, 20_000); if (!id || !prompt) { - return [] + return []; } - const description = boundedNativeText( - command.description, - 2_000 - ) + const description = boundedNativeText(command.description, 2_000); return [ { id, name: id.slice(0, 200), ...(description ? { description } : {}), prompt, - source: 'mcp' as const - } - ] - }) + source: "mcp" as const, + }, + ]; + }); const lspData = - lspResult.status === 'fulfilled' && + lspResult.status === "fulfilled" && !lspResult.value.error && lspResult.value.data ? lspResult.value.data - : (unavailable.push('LSP'), []) + : (unavailable.push("LSP"), []); const lsp = lspData .flatMap((server) => { - const id = boundedNativeIdentifier(server.id) - const name = boundedNativeText(server.name, 200) + const id = boundedNativeIdentifier(server.id); + const name = boundedNativeText(server.name, 200); if (!id || !name) { - return [] + return []; } return [ { id, name, - status: server.status - } - ] + status: server.status, + }, + ]; }) - .slice(0, MAX_NATIVE_LSP) + .slice(0, MAX_NATIVE_LSP); const formatterData = - formattersResult.status === 'fulfilled' && + formattersResult.status === "fulfilled" && !formattersResult.value.error && formattersResult.value.data ? formattersResult.value.data - : (unavailable.push('Formatter'), []) + : (unavailable.push("Formatter"), []); const formatters = formatterData .flatMap((formatter) => { - const id = boundedNativeIdentifier(formatter.name) + const id = boundedNativeIdentifier(formatter.name); if (!id) { - return [] + return []; } return [ { @@ -1765,97 +1736,91 @@ export class OpenCodeRuntime implements AgentRuntime { enabled: formatter.enabled, extensions: formatter.extensions .flatMap((extension) => { - const value = boundedNativeText(extension, 32) - return value ? [value] : [] + const value = boundedNativeText(extension, 32); + return value ? [value] : []; }) - .slice(0, 100) - } - ] + .slice(0, 100), + }, + ]; }) - .slice(0, MAX_NATIVE_FORMATTERS) + .slice(0, MAX_NATIVE_FORMATTERS); const mcpData = - mcpResult.status === 'fulfilled' && + mcpResult.status === "fulfilled" && !mcpResult.value.error && mcpResult.value.data ? mcpResult.value.data - : (unavailable.push('MCP'), {}) + : (unavailable.push("MCP"), {}); const mcpServers = Object.entries(mcpData) .flatMap(([rawName, server]) => { - const id = boundedNativeIdentifier(rawName) + const id = boundedNativeIdentifier(rawName); if (!id || isTemporaryMcpName(id)) { - return [] + return []; } const status = - server.status === 'needs_auth' - ? ('needs-auth' as const) - : server.status === 'needs_client_registration' - ? ('unsupported' as const) - : server.status + server.status === "needs_auth" + ? ("needs-auth" as const) + : server.status === "needs_client_registration" + ? ("unsupported" as const) + : server.status; return [ { id, name: id.slice(0, 200), - status - } - ] + status, + }, + ]; }) - .slice(0, MAX_NATIVE_MCP_SERVERS) + .slice(0, MAX_NATIVE_MCP_SERVERS); const skillData = - skillsResult.status === 'fulfilled' && + skillsResult.status === "fulfilled" && !skillsResult.value.error && skillsResult.value.data ? skillsResult.value.data - : (unavailable.push('Skill'), []) + : (unavailable.push("Skill"), []); const skills = skillData .flatMap((skill) => { - const id = boundedNativeIdentifier(skill.name) + const id = boundedNativeIdentifier(skill.name); if (!id || assignedSkillIds.has(id)) { - return [] + return []; } - const description = boundedNativeText( - skill.description, - 2_000 - ) + const description = boundedNativeText(skill.description, 2_000); return [ { id, name: id.slice(0, 200), ...(description ? { description } : {}), - source: 'unknown' as const - } - ] + source: "unknown" as const, + }, + ]; }) - .slice(0, MAX_NATIVE_SKILLS) + .slice(0, MAX_NATIVE_SKILLS); const resourceData = - resourcesResult.status === 'fulfilled' && + resourcesResult.status === "fulfilled" && !resourcesResult.value.error && resourcesResult.value.data ? resourcesResult.value.data - : (unavailable.push('资源'), {}) + : (unavailable.push("资源"), {}); const resources = Object.entries(resourceData) .flatMap(([rawId, resource]) => { const id = boundedNativeIdentifier(rawId) ?? - boundedNativeIdentifier(resource.name) - const name = boundedNativeText(resource.name, 200) - const uri = boundedResourceUri(resource.uri) - const server = boundedNativeText(resource.client, 200) + boundedNativeIdentifier(resource.name); + const name = boundedNativeText(resource.name, 200); + const uri = boundedResourceUri(resource.uri); + const server = boundedNativeText(resource.client, 200); if ( !id || !name || !uri || isTemporaryMcpInventoryItem(id, name, server) ) { - return [] + return []; } - const description = boundedNativeText( - resource.description, - 2_000 - ) - const mimeType = boundedNativeText(resource.mimeType, 200) + const description = boundedNativeText(resource.description, 2_000); + const mimeType = boundedNativeText(resource.mimeType, 200); return [ { id, @@ -1863,28 +1828,27 @@ export class OpenCodeRuntime implements AgentRuntime { uri, ...(description ? { description } : {}), ...(mimeType ? { mimeType } : {}), - ...(server ? { server } : {}) - } - ] + ...(server ? { server } : {}), + }, + ]; }) - .slice(0, MAX_NATIVE_RESOURCES) + .slice(0, MAX_NATIVE_RESOURCES); return { - provider: 'opencode', + provider: "opencode", available: true, - inventoryStatus: - unavailable.length === 0 ? 'available' : 'partial', + inventoryStatus: unavailable.length === 0 ? "available" : "partial", detail: unavailable.length === 0 - ? 'OpenCode 原生能力已就绪' - : `OpenCode 已连接;部分原生清单暂不可用:${unavailable.join('、')}`.slice( + ? "OpenCode 原生能力已就绪" + : `OpenCode 已连接;部分原生清单暂不可用:${unavailable.join("、")}`.slice( 0, - 1_000 + 1_000, ), agents, tools, toolsSupported: - toolsResult.status === 'fulfilled' && + toolsResult.status === "fulfilled" && !toolsResult.value.error && toolsResult.value.data !== undefined, commands, @@ -1896,15 +1860,15 @@ export class OpenCodeRuntime implements AgentRuntime { prompts, resources, resourcesSupported: - resourcesResult.status === 'fulfilled' && + resourcesResult.status === "fulfilled" && !resourcesResult.value.error && resourcesResult.value.data !== undefined, context: { - strategy: 'native', + strategy: "native", manualCompact: true, - detail: '由 OpenCode 原生上下文与手动 Compact 管理' - } - } + detail: "由 OpenCode 原生上下文与手动 Compact 管理", + }, + }; } private async getSessionId( @@ -1913,102 +1877,95 @@ export class OpenCodeRuntime implements AgentRuntime { directory: string, signal: AbortSignal, agent?: string, - permission?: PermissionRuleset + permission?: PermissionRuleset, ): Promise<{ id: string; created: boolean }> { - const current = this.sessions.get(request.conversationId) + const current = this.sessions.get(request.conversationId); if (current) { - return { id: current, created: false } + return { id: current, created: false }; } - const pending = this.sessionInitializations.get( - request.conversationId - ) + const pending = this.sessionInitializations.get(request.conversationId); if (pending) { return { id: await awaitWithAbort(pending, signal), - created: false - } + created: false, + }; } const creation: Promise = this.controlRequest( - '创建会话', + "创建会话", (controlSignal) => client.session.create( { - title: 'GoodBuddy 对话', + title: "GoodBuddy 对话", directory, ...(agent ? { agent } : {}), - ...(permission ? { permission } : {}) + ...(permission ? { permission } : {}), }, - { signal: controlSignal } + { signal: controlSignal }, ), signal, async (response) => { - const sessionId = response.data?.id + const sessionId = response.data?.id; if (sessionId) { await client.session .delete( { sessionID: sessionId, - directory + directory, }, - { signal: AbortSignal.timeout(1_000) } + { signal: AbortSignal.timeout(1_000) }, ) - .catch(() => undefined) + .catch(() => undefined); } + }, + ).then((response) => { + if (!response.data) { + throw new Error("OpenCode 会话创建失败"); } - ) - .then((response) => { - if (!response.data) { - throw new Error('OpenCode 会话创建失败') + const sessionId = response.data.id; + const stillCurrent = + this.sessionInitializations.get(request.conversationId) === creation; + if (signal.aborted || !stillCurrent) { + if (this.sessions.get(request.conversationId) !== sessionId) { + void client.session + .delete( + { + sessionID: sessionId, + directory, + }, + { signal: AbortSignal.timeout(1_000) }, + ) + .catch(() => undefined); } - const sessionId = response.data.id - const stillCurrent = - this.sessionInitializations.get(request.conversationId) === - creation - if (signal.aborted || !stillCurrent) { - if (this.sessions.get(request.conversationId) !== sessionId) { - void client.session - .delete( - { - sessionID: sessionId, - directory - }, - { signal: AbortSignal.timeout(1_000) } - ) - .catch(() => undefined) - } - if (signal.aborted) { - throw signal.reason - } - throw new Error('OpenCode 会话初始化已失效') + if (signal.aborted) { + throw signal.reason; } - this.sessions.set(request.conversationId, sessionId) - return sessionId - }) - this.sessionInitializations.set(request.conversationId, creation) + throw new Error("OpenCode 会话初始化已失效"); + } + this.sessions.set(request.conversationId, sessionId); + return sessionId; + }); + this.sessionInitializations.set(request.conversationId, creation); try { return { id: await awaitWithAbort(creation, signal), - created: true - } + created: true, + }; } finally { if ( - this.sessionInitializations.get(request.conversationId) === - creation + this.sessionInitializations.get(request.conversationId) === creation ) { - this.sessionInitializations.delete(request.conversationId) + this.sessionInitializations.delete(request.conversationId); } } } async *run( request: AgentExecutionRequest, - signal: AbortSignal + signal: AbortSignal, ): AsyncGenerator { - const configuredTimeout = this.dependencies.executionTimeoutMs + const configuredTimeout = this.dependencies.executionTimeoutMs; const deadline = - configuredTimeout === undefined - ? undefined - : new AbortController() + configuredTimeout === undefined ? undefined : new AbortController(); const deadlineTimer = configuredTimeout === undefined || deadline === undefined ? undefined @@ -2017,229 +1974,225 @@ export class OpenCodeRuntime implements AgentRuntime { deadline.abort( new Error( `OpenCode 执行超过 ${executionDeadlineLabel( - configuredTimeout - )}总时限` - ) + configuredTimeout, + )}总时限`, + ), ), - configuredTimeout - ) - deadlineTimer?.unref?.() + configuredTimeout, + ); + deadlineTimer?.unref?.(); const executionSignal = deadline ? AbortSignal.any([signal, deadline.signal]) - : signal - let releaseConversation: (() => void) | undefined + : signal; + let releaseConversation: (() => void) | undefined; try { releaseConversation = await this.acquireConversationRun( request.conversationId, - executionSignal - ) - yield* this.runUnlocked(request, executionSignal) + executionSignal, + ); + yield* this.runUnlocked(request, executionSignal); } catch (error) { if (deadline?.signal.aborted && !signal.aborted) { - throw deadline.signal.reason + throw deadline.signal.reason; } - throw error + throw error; } finally { if (deadlineTimer) { - clearTimeout(deadlineTimer) + clearTimeout(deadlineTimer); } - releaseConversation?.() + releaseConversation?.(); } } private async *runUnlocked( request: AgentExecutionRequest, - signal: AbortSignal + signal: AbortSignal, ): AsyncGenerator { - signal.throwIfAborted() + signal.throwIfAborted(); if ( request.images?.length && this.options.modelProfile && this.options.modelProfile.supportsImageInput !== true ) { - throw new Error('当前模型连接未启用图像输入') + throw new Error("当前模型连接未启用图像输入"); } - const client = await this.getClient(signal) - const directory = this.options.defaultWorkspace + const client = await this.getClient(signal); + const directory = this.options.defaultWorkspace; const runtimeControl = - request.runtimeControl?.provider === 'opencode' + request.runtimeControl?.provider === "opencode" ? request.runtimeControl - : undefined - if ( - runtimeControl?.command && - !this.supportsNativeCustomization() - ) { - throw new Error( - '外部 OpenCode Server 不支持由 GoodBuddy 执行原生命令' - ) + : undefined; + if (runtimeControl?.command && !this.supportsNativeCustomization()) { + throw new Error("外部 OpenCode Server 不支持由 GoodBuddy 执行原生命令"); } const selectedAgent = await this.resolveSelectedAgent( client, request, - signal - ) + signal, + ); let selectedCommand: | { - name: string - arguments: string + name: string; + arguments: string; } - | undefined + | undefined; if (runtimeControl?.command) { const commandResponse = await this.controlRequest( - '验证原生命令', + "验证原生命令", (controlSignal) => - client.command.list( - { directory }, - { signal: controlSignal } - ), - signal - ) + client.command.list({ directory }, { signal: controlSignal }), + signal, + ); if (commandResponse.error || !commandResponse.data) { - throw new Error('OpenCode 无法验证原生命令') + throw new Error("OpenCode 无法验证原生命令"); } const assignedSkillIds = new Set( - (this.options.skillPackages ?? []).map((skill) => skill.id) - ) - const command = commandResponse.data.find( - (candidate) => { - const name = boundedNativeIdentifier(candidate.name) - return ( - name !== undefined && - name === runtimeControl.command?.name && - !isTemporaryMcpName(name) && - !assignedSkillIds.has(name) - ) - } - ) + (this.options.skillPackages ?? []).map((skill) => skill.id), + ); + const command = commandResponse.data.find((candidate) => { + const name = boundedNativeIdentifier(candidate.name); + return ( + name !== undefined && + name === runtimeControl.command?.name && + !isTemporaryMcpName(name) && + !assignedSkillIds.has(name) + ); + }); if (!command) { throw new Error( - `OpenCode 原生命令不存在或已失效:${runtimeControl.command.name.slice(0, 128)}` - ) + `OpenCode 原生命令不存在或已失效:${runtimeControl.command.name.slice(0, 128)}`, + ); } - selectedCommand = runtimeControl.command + selectedCommand = runtimeControl.command; } - const nativeSkillIds = this.getNativeSkillIds() + const nativeSkillIds = this.getNativeSkillIds(); const nativeSkillPermissionRules = - createSkillPermissionRules(nativeSkillIds) - let knowledgeMcpName: string | undefined - let knowledgeToolIds: string[] = [] - let customMcpName: string | undefined - let customMcpToken: string | undefined - const subscriptionController = new AbortController() + createSkillPermissionRules(nativeSkillIds); + let knowledgeMcpName: string | undefined; + let knowledgeToolIds: string[] = []; + let customMcpName: string | undefined; + let customMcpToken: string | undefined; + const attemptedMcpNames: string[] = []; + const subscriptionController = new AbortController(); try { if ( request.knowledgeCapabilityToken && this.usesEmbeddedPermissionMediation() && this.options.knowledgeGateway?.getEndpoint() ) { - const knowledgeEndpoint = - this.options.knowledgeGateway.getEndpoint()! - knowledgeMcpName = `goodbuddy-data-${createHash('sha256') + const knowledgeEndpoint = this.options.knowledgeGateway.getEndpoint()!; + knowledgeMcpName = `goodbuddy-data-${createHash("sha256") .update(`${request.conversationId}\0${request.requestId}`) - .digest('hex') - .slice(0, 20)}` - const added = await this.controlRequest( - '连接内置只读工具', - (controlSignal) => - client.mcp.add( - { - directory, - name: knowledgeMcpName, - config: { - type: 'remote', - url: knowledgeEndpoint, - enabled: true, - headers: { - Authorization: `Bearer ${request.knowledgeCapabilityToken}` + .digest("hex") + .slice(0, 20)}`; + const added = await this.mutateMcp(signal, () => + this.controlRequest( + "连接内置只读工具", + (controlSignal) => { + attemptedMcpNames.push(knowledgeMcpName!); + return client.mcp.add( + { + directory, + name: knowledgeMcpName, + config: { + type: "remote", + url: knowledgeEndpoint, + enabled: true, + headers: { + Authorization: `Bearer ${request.knowledgeCapabilityToken}`, + }, + oauth: false, + }, }, - oauth: false - } + { signal: controlSignal }, + ); }, - { signal: controlSignal } + signal, ), - signal - ) + ); if (added.error || !added.data) { - throw new Error('OpenCode 内置只读工具连接失败') - } - const addedStatus = added.data[knowledgeMcpName] - if (!addedStatus || addedStatus.status !== 'connected') { throw new Error( - `OpenCode 内置只读工具连接失败(${addedStatus?.status ?? 'unknown'})` - ) + opencodeErrorMessage(added.error, "OpenCode 内置只读工具连接失败"), + ); + } + const addedStatus = added.data[knowledgeMcpName]; + if (!addedStatus || addedStatus.status !== "connected") { + throw new Error( + `OpenCode 内置只读工具连接失败(${addedStatus?.status ?? "unknown"})`, + ); } // OpenCode 1.18.x does not include dynamically added MCP tools in // experimental/tool/ids. Its model tool namespace is deterministic: // "_". - knowledgeToolIds = - this.options.knowledgeGateway - .getAvailableToolNames(request.knowledgeCapabilityToken) - .map((toolName) => `${knowledgeMcpName}_${toolName}`) + knowledgeToolIds = this.options.knowledgeGateway + .getAvailableToolNames(request.knowledgeCapabilityToken) + .map((toolName) => `${knowledgeMcpName}_${toolName}`); } if ( - request.workMode === 'execute' && + request.workMode === "execute" && this.usesEmbeddedPermissionMediation() && this.options.knowledgeGateway?.getEndpoint() && this.options.mcpServers?.length ) { - const customMcpEndpoint = - this.options.knowledgeGateway.getEndpoint()! + const customMcpEndpoint = this.options.knowledgeGateway.getEndpoint()!; customMcpToken = this.options.knowledgeGateway.grantCustomMcp( request.requestId, this.options.mcpServers, - signal - ) + signal, + ); if (customMcpToken) { const tools = await this.options.knowledgeGateway.prepareCustomMcpTools( customMcpToken, - signal - ) - customMcpName = `goodbuddy-custom-${createHash('sha256') + signal, + ); + customMcpName = `goodbuddy-custom-${createHash("sha256") .update(`${request.conversationId}\0${request.requestId}`) - .digest('hex') - .slice(0, 20)}` - const added = await this.controlRequest( - '连接自定义 MCP 工具', - (controlSignal) => - client.mcp.add( - { - directory, - name: customMcpName, - config: { - type: 'remote', - url: customMcpEndpoint, - enabled: true, - headers: { - Authorization: `Bearer ${customMcpToken}` + .digest("hex") + .slice(0, 20)}`; + const added = await this.mutateMcp(signal, () => + this.controlRequest( + "连接自定义 MCP 工具", + (controlSignal) => { + attemptedMcpNames.push(customMcpName!); + return client.mcp.add( + { + directory, + name: customMcpName, + config: { + type: "remote", + url: customMcpEndpoint, + enabled: true, + headers: { + Authorization: `Bearer ${customMcpToken}`, + }, + oauth: false, + }, }, - oauth: false - } + { signal: controlSignal }, + ); }, - { signal: controlSignal } + signal, ), - signal - ) - const addedStatus = added.data?.[customMcpName] + ); + const addedStatus = added.data?.[customMcpName]; if ( added.error || !added.data || !addedStatus || - addedStatus.status !== 'connected' + addedStatus.status !== "connected" ) { throw new Error( - `OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? 'unknown'})` - ) + `OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? "unknown"})`, + ); } knowledgeToolIds.push( - ...tools.map( - (tool) => `${customMcpName}_${tool.name}` - ) - ) + ...tools.map((tool) => `${customMcpName}_${tool.name}`), + ); } } const permission = - request.workMode === 'execute' + request.workMode === "execute" ? [ ...executePermissionRules, ...(this.usesEmbeddedPermissionMediation() @@ -2248,9 +2201,9 @@ export class OpenCodeRuntime implements AgentRuntime { ...nativeSkillPermissionRules, ...knowledgeToolIds.map((toolId) => ({ permission: toolId, - pattern: '*', - action: 'allow' as const - })) + pattern: "*", + action: "allow" as const, + })), ] : knowledgeToolIds.length > 0 ? [ @@ -2258,45 +2211,37 @@ export class OpenCodeRuntime implements AgentRuntime { ...nativeSkillPermissionRules, ...knowledgeToolIds.map((toolId) => ({ permission: toolId, - pattern: '*', - action: 'allow' as const - })) + pattern: "*", + action: "allow" as const, + })), ] - : [ - ...readOnlyPermissionRules, - ...nativeSkillPermissionRules - ] - let toolOverrides: Record | undefined - if (request.workMode !== 'execute') { + : [...readOnlyPermissionRules, ...nativeSkillPermissionRules]; + let toolOverrides: Record | undefined; + if (request.workMode !== "execute") { const tools = await this.controlRequest( - '读取工具清单', + "读取工具清单", (controlSignal) => - client.tool.ids( - { directory }, - { signal: controlSignal } - ), - signal - ) + client.tool.ids({ directory }, { signal: controlSignal }), + signal, + ); if (tools.error || !tools.data) { - throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求') + throw new Error("OpenCode 无法确认工具已禁用,已阻止只读请求"); } toolOverrides = { - ...Object.fromEntries( - tools.data.map((toolId) => [toolId, false]) - ), + ...Object.fromEntries(tools.data.map((toolId) => [toolId, false])), ...temporaryMcpToolOverrides, ...Object.fromEntries( - knowledgeToolIds.map((toolId) => [toolId, true]) + knowledgeToolIds.map((toolId) => [toolId, true]), ), - ...(nativeSkillIds.length > 0 ? { skill: true } : {}) - } + ...(nativeSkillIds.length > 0 ? { skill: true } : {}), + }; } else if (this.usesEmbeddedPermissionMediation()) { toolOverrides = { ...temporaryMcpToolOverrides, ...Object.fromEntries( - knowledgeToolIds.map((toolId) => [toolId, true]) - ) - } + knowledgeToolIds.map((toolId) => [toolId, true]), + ), + }; } const session = await this.getSessionId( client, @@ -2304,802 +2249,764 @@ export class OpenCodeRuntime implements AgentRuntime { directory, signal, selectedAgent, - permission - ) - const sessionId = session.id + permission, + ); + const sessionId = session.id; if (!session.created) { const update = await this.controlRequest( - '更新会话权限', + "更新会话权限", (controlSignal) => client.session.update( - { - sessionID: sessionId, - directory, - permission - }, - { signal: controlSignal } - ), - signal - ) + { + sessionID: sessionId, + directory, + permission, + }, + { signal: controlSignal }, + ), + signal, + ); if (update.error || !update.data) { - throw new Error('OpenCode 会话权限配置失败') + throw new Error("OpenCode 会话权限配置失败"); } } - yield { - requestId: request.requestId, - type: 'status', - message: 'OpenCode 正在处理请求' - } + yield { + requestId: request.requestId, + type: "status", + message: "OpenCode 正在处理请求", + }; - const subscription = await this.controlRequest( - '订阅事件流', - (controlSignal) => - client.event.subscribe( - { directory }, - { signal: controlSignal } - ), - AbortSignal.any([signal, subscriptionController.signal]) - ) + const subscription = await this.controlRequest( + "订阅事件流", + (controlSignal) => + client.event.subscribe({ directory }, { signal: controlSignal }), + AbortSignal.any([signal, subscriptionController.signal]), + ); - const abortSession = (): void => { - void client.session.abort({ - sessionID: sessionId, - directory - }).catch(() => undefined) - } - signal.addEventListener('abort', abortSession, { once: true }) + const abortSession = (): void => { + void client.session + .abort({ + sessionID: sessionId, + directory, + }) + .catch(() => undefined); + }; + signal.addEventListener("abort", abortSession, { once: true }); - const toolStates = new Map< - string, - { - name: string - state: 'pending' | 'running' | 'completed' | 'failed' - input?: string - output?: string - error?: string - subagent?: SubagentEvent - } - >() - const reasoningPartIds = new Set() - const subagentProgress = new OpenCodeSubagentProgress(request.requestId, sessionId) - const reportedQuestionIds = new Map() - let aggregateOutputBytes = 0 - let outputTruncated = false - const retainOutputDelta = ( - delta: string - ): { delta: string; truncated: boolean } => { - if (outputTruncated) { - return { delta: '', truncated: false } - } - const bytes = Buffer.from(delta) - const remaining = - MAX_EXECUTION_OUTPUT_BYTES - aggregateOutputBytes - if (bytes.byteLength <= remaining) { - aggregateOutputBytes += bytes.byteLength - return { delta, truncated: false } - } - outputTruncated = true - aggregateOutputBytes = MAX_EXECUTION_OUTPUT_BYTES - return { - delta: bytes - .subarray(0, Math.max(0, remaining)) - .toString('utf8') - .replace(/\uFFFD+$/u, ''), - truncated: true - } - } - let hasResponseTextAfterFailure = false - try { - const promptText = promptWithUntrustedConversationHistory( - request, - session.created - ) - const imageParts = (request.images ?? []).map((image) => ({ - type: 'file' as const, - mime: image.mediaType, - filename: image.name, - url: `data:${image.mediaType};base64,${image.data}` - })) - const prompt = selectedCommand - ? client.session.command( - { - sessionID: sessionId, - directory, - command: selectedCommand.name, - arguments: selectedCommand.arguments, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(this.options.modelProfile - ? { - model: `${resolveOpenCodeProvider( - this.options.modelProfile - ).id}/${this.options.modelProfile.modelName}` - } - : {}), - ...(imageParts.length > 0 - ? { parts: imageParts } - : {}) - }, - { signal } - ) - : this.controlRequest( - '提交请求', - (controlSignal) => - client.session.promptAsync( - { - sessionID: sessionId, - directory, - model: this.options.modelProfile - ? { - providerID: resolveOpenCodeProvider( - this.options.modelProfile - ).id, - modelID: this.options.modelProfile.modelName - } - : undefined, - ...(selectedAgent - ? { agent: selectedAgent } - : {}), - system: - nativeSkillIds.length > 0 - ? undefined - : this.options.skillInstructions || undefined, - ...(toolOverrides - ? { tools: toolOverrides } - : {}), - parts: [ - { type: 'text' as const, text: promptText }, - ...imageParts - ] - }, - { signal: controlSignal } - ), - signal - ) - prompt.catch(() => undefined) - - const repliedPermissionIds = new Set() - const reportedMessageIds = new Set() - for await (const event of subscription.stream) { - const childProgress = subagentProgress.update(event) - if ( - childProgress && - 'sessionID' in event.properties && - event.properties.sessionID !== sessionId - ) { - const task = childProgress.runtimeCallId - ? toolStates.get(childProgress.runtimeCallId) - : undefined - if (task) task.subagent = childProgress - yield childProgress + const toolStates = new Map< + string, + { + name: string; + state: "pending" | "running" | "completed" | "failed"; + input?: string; + output?: string; + error?: string; + subagent?: SubagentEvent; } - if ( - event.type === 'message.updated' && - event.properties.sessionID === sessionId && - event.properties.info.sessionID === sessionId && - event.properties.info.role === 'assistant' && - !reportedMessageIds.has(event.properties.info.id) - ) { - const usage = createUsageEvent( - request.requestId, - event.properties.info - ) - if (usage) { - reportedMessageIds.add(event.properties.info.id) - yield usage + >(); + const reasoningPartIds = new Set(); + const subagentProgress = new OpenCodeSubagentProgress( + request.requestId, + sessionId, + ); + const reportedQuestionIds = new Map(); + let aggregateOutputBytes = 0; + let outputTruncated = false; + const retainOutputDelta = ( + delta: string, + ): { delta: string; truncated: boolean } => { + if (outputTruncated) { + return { delta: "", truncated: false }; + } + const bytes = Buffer.from(delta); + const remaining = MAX_EXECUTION_OUTPUT_BYTES - aggregateOutputBytes; + if (bytes.byteLength <= remaining) { + aggregateOutputBytes += bytes.byteLength; + return { delta, truncated: false }; + } + outputTruncated = true; + aggregateOutputBytes = MAX_EXECUTION_OUTPUT_BYTES; + return { + delta: bytes + .subarray(0, Math.max(0, remaining)) + .toString("utf8") + .replace(/\uFFFD+$/u, ""), + truncated: true, + }; + }; + let hasResponseTextAfterFailure = false; + try { + const promptText = promptWithUntrustedConversationHistory( + request, + session.created, + ); + const imageParts = (request.images ?? []).map((image) => ({ + type: "file" as const, + mime: image.mediaType, + filename: image.name, + url: `data:${image.mediaType};base64,${image.data}`, + })); + const prompt = selectedCommand + ? client.session.command( + { + sessionID: sessionId, + directory, + command: selectedCommand.name, + arguments: selectedCommand.arguments, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(this.options.modelProfile + ? { + model: `${ + resolveOpenCodeProvider(this.options.modelProfile).id + }/${this.options.modelProfile.modelName}`, + } + : {}), + ...(imageParts.length > 0 ? { parts: imageParts } : {}), + }, + { signal }, + ) + : this.controlRequest( + "提交请求", + (controlSignal) => + client.session.promptAsync( + { + sessionID: sessionId, + directory, + model: this.options.modelProfile + ? { + providerID: resolveOpenCodeProvider( + this.options.modelProfile, + ).id, + modelID: this.options.modelProfile.modelName, + } + : undefined, + ...(selectedAgent ? { agent: selectedAgent } : {}), + system: + nativeSkillIds.length > 0 + ? undefined + : this.options.skillInstructions || undefined, + ...(toolOverrides ? { tools: toolOverrides } : {}), + parts: [ + { type: "text" as const, text: promptText }, + ...imageParts, + ], + }, + { signal: controlSignal }, + ), + signal, + ); + prompt.catch(() => undefined); + + const repliedPermissionIds = new Set(); + const reportedMessageIds = new Set(); + for await (const event of subscription.stream) { + const childProgress = subagentProgress.update(event); + if ( + childProgress && + "sessionID" in event.properties && + event.properties.sessionID !== sessionId + ) { + const task = childProgress.runtimeCallId + ? toolStates.get(childProgress.runtimeCallId) + : undefined; + if (task) task.subagent = childProgress; + yield childProgress; } - } - - if ( - event.type === 'message.part.delta' && - event.properties.sessionID === sessionId && - event.properties.delta - ) { - const reasoning = - reasoningPartIds.has(event.properties.partID) || - [ - 'reasoning', - 'reasoning_content', - 'reasoning_details', - 'thinking' - ].includes(event.properties.field) - if (reasoning || event.properties.field === 'text') { - if ( - !reasoning && - /\S/u.test(event.properties.delta) && - [...toolStates.values()].some( - (tool) => tool.state === 'failed' - ) - ) { - hasResponseTextAfterFailure = true + if ( + event.type === "message.updated" && + event.properties.sessionID === sessionId && + event.properties.info.sessionID === sessionId && + event.properties.info.role === "assistant" && + !reportedMessageIds.has(event.properties.info.id) + ) { + const usage = createUsageEvent( + request.requestId, + event.properties.info, + ); + if (usage) { + reportedMessageIds.add(event.properties.info.id); + yield usage; } - const retained = retainOutputDelta(event.properties.delta) + } + + if ( + event.type === "message.part.delta" && + event.properties.sessionID === sessionId && + event.properties.delta + ) { + const reasoning = + reasoningPartIds.has(event.properties.partID) || + [ + "reasoning", + "reasoning_content", + "reasoning_details", + "thinking", + ].includes(event.properties.field); + if (reasoning || event.properties.field === "text") { + if ( + !reasoning && + /\S/u.test(event.properties.delta) && + [...toolStates.values()].some((tool) => tool.state === "failed") + ) { + hasResponseTextAfterFailure = true; + } + const retained = retainOutputDelta(event.properties.delta); + if (retained.delta) { + yield { + requestId: request.requestId, + type: reasoning ? "reasoning" : "text", + delta: retained.delta, + }; + } + if (retained.truncated) { + yield { + requestId: request.requestId, + type: "status", + message: + "OpenCode 输出较长,已停止继续展示文本;任务仍在继续", + }; + } + } + } + + if ( + event.type === "message.part.updated" && + event.properties.sessionID === sessionId + ) { + const { part } = event.properties; + if (part.type === "reasoning") { + reasoningPartIds.add(part.id); + } else if (part.type === "tool") { + const callId = part.callID || part.id; + if (!callId || callId.length > 256) { + throw new Error("OpenCode 工具调用 ID 格式无效"); + } + const toolName = part.tool.slice(0, 200); + const state = + part.state.status === "error" ? "failed" : part.state.status; + if (state === "failed") { + hasResponseTextAfterFailure = false; + } + const error = + part.state.status === "error" + ? safeToolErrorDetail(part.state.error) + : undefined; + const input = isRecord(part.state.input) + ? boundedToolDetail(part.state.input, 4_000) + : undefined; + const output = + part.state.status === "completed" && + typeof part.state.output === "string" + ? part.state.output.slice(0, 16_000) + : undefined; + const subagent = + toolName.trim().toLowerCase() === "task" + ? toOpenCodeSubagentEvent({ + requestId: request.requestId, + callId, + state: part.state.status, + input: part.state.input, + output, + error, + }) + : undefined; + toolStates.set(callId, { + name: toolName, + state, + ...(input ? { input } : {}), + ...(output ? { output } : {}), + ...(error ? { error } : {}), + ...(subagent ? { subagent } : {}), + }); + if (subagent) { + const retained = subagentProgress.retain(subagent); + toolStates.get(callId)!.subagent = retained; + yield retained; + } else { + yield { + requestId: request.requestId, + type: "tool", + callId, + name: toolName, + state, + summary: `OpenCode 工具:${toolName}`, + ...(input ? { input } : {}), + ...(output ? { output } : {}), + ...(error ? { error } : {}), + }; + } + } + } + + if ( + event.type === "session.next.reasoning.delta" && + event.properties.sessionID === sessionId && + event.properties.delta + ) { + const retained = retainOutputDelta(event.properties.delta); if (retained.delta) { yield { requestId: request.requestId, - type: reasoning ? 'reasoning' : 'text', - delta: retained.delta - } + type: "reasoning", + delta: retained.delta, + }; } if (retained.truncated) { yield { requestId: request.requestId, - type: 'status', - message: - 'OpenCode 输出较长,已停止继续展示文本;任务仍在继续' - } + type: "status", + message: "OpenCode 输出较长,已停止继续展示文本;任务仍在继续", + }; } } + + if ( + event.type === "question.asked" && + event.properties.sessionID === sessionId + ) { + const questionRequest = parseQuestionRequest( + event.properties, + sessionId, + ); + if ( + questionRequest && + !reportedQuestionIds.has(questionRequest.id) + ) { + const publicQuestionId = createPublicQuestionId( + request.requestId, + sessionId, + questionRequest.id, + ); + if (this.pendingQuestions.has(publicQuestionId)) { + throw new Error("OpenCode 提问公开 ID 与另一活动请求冲突"); + } + reportedQuestionIds.set(questionRequest.id, publicQuestionId); + this.pendingQuestions.set(publicQuestionId, { + client, + directory, + questionCount: questionRequest.questions.length, + upstreamQuestionId: questionRequest.id, + }); + yield { + requestId: request.requestId, + type: "question", + questionId: publicQuestionId, + questions: questionRequest.questions.map((question) => ({ + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + multiple: question.multiple ?? false, + custom: question.custom ?? true, + })), + }; + } + } + + if ( + (event.type === "question.replied" || + event.type === "question.rejected") && + event.properties.sessionID === sessionId + ) { + const publicQuestionId = reportedQuestionIds.get( + event.properties.requestID, + ); + if (publicQuestionId) { + this.pendingQuestions.delete(publicQuestionId); + } + } + + if ( + this.usesEmbeddedPermissionMediation() && + event.type === "permission.asked" + ) { + const properties = event.properties as unknown; + if ( + isRecord(properties) && + typeof properties.sessionID === "string" && + properties.sessionID !== sessionId + ) { + continue; + } + + let permissionRequest: PermissionRequest; + try { + const parsed = parsePermissionRequest(properties, sessionId); + if (!parsed) { + throw new Error("OpenCode 权限请求格式无效"); + } + permissionRequest = parsed; + } catch (error) { + if ( + isRecord(properties) && + typeof properties.id === "string" && + properties.id.length > 0 && + properties.id.length <= MAX_PERMISSION_NAME_LENGTH && + !repliedPermissionIds.has(properties.id) + ) { + const permissionId = properties.id; + repliedPermissionIds.add(properties.id); + const rejection = await this.controlRequest( + "回复权限请求", + (controlSignal) => + client.permission.reply( + { + requestID: permissionId, + directory, + reply: "reject", + }, + { signal: controlSignal }, + ), + signal, + ); + if (rejection.error || rejection.data !== true) { + throw new Error("OpenCode 权限拒绝回复失败", { + cause: error, + }); + } + continue; + } + throw error; + } + + if (repliedPermissionIds.has(permissionRequest.id)) { + continue; + } + repliedPermissionIds.add(permissionRequest.id); + + const callId = + permissionRequest.tool?.callID ?? permissionRequest.id; + const toolName = permissionRequest.permission.slice(0, 200); + toolStates.set(callId, { name: toolName, state: "pending" }); + yield { + requestId: request.requestId, + type: "tool", + callId, + name: toolName, + state: "pending", + summary: `OpenCode 工具:${toolName}`, + }; + const allowKnowledge = + request.workMode === "ask" && + knowledgeToolIds.includes(permissionRequest.permission); + const response = await this.controlRequest( + "回复权限请求", + (controlSignal) => + client.permission.reply( + { + requestID: permissionRequest.id, + directory, + reply: + request.workMode === "execute" || allowKnowledge + ? "once" + : "reject", + }, + { signal: controlSignal }, + ), + signal, + ); + if (response.error || response.data !== true) { + throw new Error("OpenCode 权限回复失败"); + } + } + + if ( + event.type === "session.error" && + event.properties.sessionID === sessionId + ) { + const error = event.properties.error; + throw new Error(opencodeErrorMessage(error, "OpenCode 执行失败")); + } + + if ( + event.type === "session.idle" && + event.properties.sessionID === sessionId + ) { + const promptResult = await prompt; + if (promptResult.error) { + throw new Error( + opencodeErrorMessage( + promptResult.error, + "OpenCode 提交请求失败", + ), + ); + } + const incompleteTool = [...toolStates.entries()].find( + ([, tool]) => + tool.state === "pending" || tool.state === "running", + ); + if (incompleteTool) { + const [callId] = incompleteTool; + throw new Error(`OpenCode 工具未完成(${callId.slice(0, 128)})`); + } + const failedTools = [...toolStates.entries()].filter( + ([, tool]) => tool.state === "failed", + ); + if (failedTools.length > 0 && !hasResponseTextAfterFailure) { + const [callId, tool] = failedTools[0]!; + throw new Error( + `OpenCode 工具执行失败(${callId.slice(0, 128)})${tool.error ? `:${tool.error}` : ""}`, + ); + } + for (const [callId, tool] of failedTools) { + if (tool.subagent) { + continue; + } + yield { + requestId: request.requestId, + type: "tool", + callId, + name: tool.name, + state: "recoverable", + summary: `OpenCode 已在后续响应中处理工具失败:${tool.name}`, + ...(tool.input ? { input: tool.input } : {}), + ...(tool.output ? { output: tool.output } : {}), + ...(tool.error ? { error: tool.error } : {}), + }; + } + yield { + requestId: request.requestId, + type: "done", + sessionId, + }; + return; + } } - if ( - event.type === 'message.part.updated' && - event.properties.sessionID === sessionId - ) { - const { part } = event.properties - if (part.type === 'reasoning') { - reasoningPartIds.add(part.id) - } else if (part.type === 'tool') { - const callId = part.callID || part.id - if (!callId || callId.length > 256) { - throw new Error('OpenCode 工具调用 ID 格式无效') - } - const toolName = part.tool.slice(0, 200) - const state = - part.state.status === 'error' ? 'failed' : part.state.status - if (state === 'failed') { - hasResponseTextAfterFailure = false - } - const error = - part.state.status === 'error' - ? safeToolErrorDetail(part.state.error) - : undefined - const input = isRecord(part.state.input) - ? boundedToolDetail(part.state.input, 4_000) - : undefined - const output = - part.state.status === 'completed' && - typeof part.state.output === 'string' - ? part.state.output.slice(0, 16_000) - : undefined - const subagent = - toolName.trim().toLowerCase() === 'task' - ? toOpenCodeSubagentEvent({ - requestId: request.requestId, - callId, - state: part.state.status, - input: part.state.input, - output, - error - }) - : undefined - toolStates.set(callId, { - name: toolName, - state, - ...(input ? { input } : {}), - ...(output ? { output } : {}), - ...(error ? { error } : {}), - ...(subagent ? { subagent } : {}) - }) - if (subagent) { - const retained = subagentProgress.retain(subagent) - toolStates.get(callId)!.subagent = retained - yield retained + const promptResult = await prompt; + if (promptResult.error) { + throw new Error( + opencodeErrorMessage(promptResult.error, "OpenCode 提交请求失败"), + ); + } + signal.throwIfAborted(); + throw new Error("OpenCode 事件流意外结束"); + } catch (error) { + abortSession(); + for (const [callId, tool] of toolStates) { + if (tool.state === "pending" || tool.state === "running") { + if (tool.subagent) { + yield { + ...tool.subagent, + state: signal.aborted ? "cancelled" : "failed", + ...(signal.aborted + ? { reason: tool.subagent.reason ?? "父请求已取消" } + : { + error: + tool.subagent.error ?? + "OpenCode 子 Agent 未返回完成状态", + }), + }; } else { yield { requestId: request.requestId, - type: 'tool', + type: "tool", callId, - name: toolName, - state, - summary: `OpenCode 工具:${toolName}`, - ...(input ? { input } : {}), - ...(output ? { output } : {}), - ...(error ? { error } : {}) - } + name: tool.name, + state: "failed", + summary: `OpenCode 工具:${tool.name}`, + ...(tool.input ? { input: tool.input } : {}), + ...(tool.output ? { output: tool.output } : {}), + ...(tool.error ? { error: tool.error } : {}), + }; } } } - - if ( - event.type === 'session.next.reasoning.delta' && - event.properties.sessionID === sessionId && - event.properties.delta - ) { - const retained = retainOutputDelta(event.properties.delta) - if (retained.delta) { - yield { - requestId: request.requestId, - type: 'reasoning', - delta: retained.delta - } - } - if (retained.truncated) { - yield { - requestId: request.requestId, - type: 'status', - message: - 'OpenCode 输出较长,已停止继续展示文本;任务仍在继续' - } - } - } - - if ( - event.type === 'question.asked' && - event.properties.sessionID === sessionId - ) { - const questionRequest = parseQuestionRequest( - event.properties, - sessionId - ) - if ( - questionRequest && - !reportedQuestionIds.has(questionRequest.id) - ) { - const publicQuestionId = createPublicQuestionId( - request.requestId, - sessionId, - questionRequest.id - ) - if (this.pendingQuestions.has(publicQuestionId)) { - throw new Error('OpenCode 提问公开 ID 与另一活动请求冲突') - } - reportedQuestionIds.set( - questionRequest.id, - publicQuestionId - ) - this.pendingQuestions.set(publicQuestionId, { - client, - directory, - questionCount: questionRequest.questions.length, - upstreamQuestionId: questionRequest.id - }) - yield { - requestId: request.requestId, - type: 'question', - questionId: publicQuestionId, - questions: questionRequest.questions.map((question) => ({ - header: question.header, - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description - })), - multiple: question.multiple ?? false, - custom: question.custom ?? true - })) - } - } - } - - if ( - (event.type === 'question.replied' || - event.type === 'question.rejected') && - event.properties.sessionID === sessionId - ) { - const publicQuestionId = reportedQuestionIds.get( - event.properties.requestID - ) - if (publicQuestionId) { - this.pendingQuestions.delete(publicQuestionId) - } - } - - if ( - this.usesEmbeddedPermissionMediation() && - event.type === 'permission.asked' - ) { - const properties = event.properties as unknown - if ( - isRecord(properties) && - typeof properties.sessionID === 'string' && - properties.sessionID !== sessionId - ) { - continue - } - - let permissionRequest: PermissionRequest - try { - const parsed = parsePermissionRequest(properties, sessionId) - if (!parsed) { - throw new Error('OpenCode 权限请求格式无效') - } - permissionRequest = parsed - } catch (error) { - if ( - isRecord(properties) && - typeof properties.id === 'string' && - properties.id.length > 0 && - properties.id.length <= MAX_PERMISSION_NAME_LENGTH && - !repliedPermissionIds.has(properties.id) - ) { - const permissionId = properties.id - repliedPermissionIds.add(properties.id) - const rejection = await this.controlRequest( - '回复权限请求', - (controlSignal) => - client.permission.reply( - { - requestID: permissionId, - directory, - reply: 'reject' - }, - { signal: controlSignal } - ), - signal - ) - if (rejection.error || rejection.data !== true) { - throw new Error('OpenCode 权限拒绝回复失败', { - cause: error - }) - } - continue - } - throw error - } - - if (repliedPermissionIds.has(permissionRequest.id)) { - continue - } - repliedPermissionIds.add(permissionRequest.id) - - const callId = ( - permissionRequest.tool?.callID ?? permissionRequest.id - ) - const toolName = permissionRequest.permission.slice(0, 200) - toolStates.set(callId, { name: toolName, state: 'pending' }) - yield { - requestId: request.requestId, - type: 'tool', - callId, - name: toolName, - state: 'pending', - summary: `OpenCode 工具:${toolName}` - } - const allowKnowledge = - request.workMode === 'ask' && - knowledgeToolIds.includes(permissionRequest.permission) - const response = await this.controlRequest( - '回复权限请求', - (controlSignal) => - client.permission.reply( - { - requestID: permissionRequest.id, - directory, - reply: - request.workMode === 'execute' || - allowKnowledge - ? 'once' - : 'reject' - }, - { signal: controlSignal } - ), - signal - ) - if (response.error || response.data !== true) { - throw new Error('OpenCode 权限回复失败') - } - } - - if ( - event.type === 'session.error' && - event.properties.sessionID === sessionId - ) { - const error = event.properties.error - throw new Error( - opencodeErrorMessage(error, 'OpenCode 执行失败') - ) - } - - if ( - event.type === 'session.idle' && - event.properties.sessionID === sessionId - ) { - const promptResult = await prompt - if (promptResult.error) { - throw new Error( - opencodeErrorMessage( - promptResult.error, - 'OpenCode 提交请求失败' - ) - ) - } - const incompleteTool = [...toolStates.entries()].find( - ([, tool]) => - tool.state === 'pending' || tool.state === 'running' - ) - if (incompleteTool) { - const [callId] = incompleteTool - throw new Error( - `OpenCode 工具未完成(${callId.slice(0, 128)})` - ) - } - const failedTools = [...toolStates.entries()].filter( - ([, tool]) => tool.state === 'failed' - ) - if ( - failedTools.length > 0 && - !hasResponseTextAfterFailure - ) { - const [callId, tool] = failedTools[0]! - throw new Error( - `OpenCode 工具执行失败(${callId.slice(0, 128)})${tool.error ? `:${tool.error}` : ''}` - ) - } - for (const [callId, tool] of failedTools) { - if (tool.subagent) { - continue - } - yield { - requestId: request.requestId, - type: 'tool', - callId, - name: tool.name, - state: 'recoverable', - summary: `OpenCode 已在后续响应中处理工具失败:${tool.name}`, - ...(tool.input ? { input: tool.input } : {}), - ...(tool.output ? { output: tool.output } : {}), - ...(tool.error ? { error: tool.error } : {}) - } - } - yield { - requestId: request.requestId, - type: 'done', - sessionId - } - return + throw error; + } finally { + signal.removeEventListener("abort", abortSession); + for (const questionId of reportedQuestionIds.values()) { + this.pendingQuestions.delete(questionId); } } - - const promptResult = await prompt - if (promptResult.error) { - throw new Error( - opencodeErrorMessage( - promptResult.error, - 'OpenCode 提交请求失败' - ) - ) - } - signal.throwIfAborted() - throw new Error('OpenCode 事件流意外结束') - } catch (error) { - abortSession() - for (const [callId, tool] of toolStates) { - if (tool.state === 'pending' || tool.state === 'running') { - if (tool.subagent) { - yield { - ...tool.subagent, - state: signal.aborted ? 'cancelled' : 'failed', - ...(signal.aborted - ? { reason: tool.subagent.reason ?? '父请求已取消' } - : { - error: - tool.subagent.error ?? - 'OpenCode 子 Agent 未返回完成状态' - }) - } - } else { - yield { - requestId: request.requestId, - type: 'tool', - callId, - name: tool.name, - state: 'failed', - summary: `OpenCode 工具:${tool.name}`, - ...(tool.input ? { input: tool.input } : {}), - ...(tool.output ? { output: tool.output } : {}), - ...(tool.error ? { error: tool.error } : {}) - } - } - } - } - throw error } finally { - signal.removeEventListener('abort', abortSession) - for (const questionId of reportedQuestionIds.values()) { - this.pendingQuestions.delete(questionId) - } - } - } finally { - subscriptionController.abort() - const cleanupSignal = AbortSignal.timeout(1_000) - if (knowledgeMcpName) { - await awaitWithAbort( - client.mcp.disconnect( - { name: knowledgeMcpName, directory }, - { signal: cleanupSignal } - ), - cleanupSignal - ).catch(() => undefined) - } - if (customMcpName) { - await awaitWithAbort( - client.mcp.disconnect( - { name: customMcpName, directory }, - { signal: cleanupSignal } - ), - cleanupSignal - ).catch(() => undefined) + subscriptionController.abort(); + for (const name of attemptedMcpNames) { + await this.mutateMcp(new AbortController().signal, () => { + // Queue wait must not consume the disconnect timeout. + const cleanupSignal = AbortSignal.timeout(1_000); + return awaitWithAbort( + client.mcp.disconnect( + { name, directory }, + { signal: cleanupSignal }, + ), + cleanupSignal, + ); + }).catch(() => undefined); } if (customMcpToken) { - this.options.knowledgeGateway?.revoke(customMcpToken) + this.options.knowledgeGateway?.revoke(customMcpToken); } } } async respondToQuestion( questionId: string, - answers?: AgentQuestionAnswer[] + answers?: AgentQuestionAnswer[], ): Promise { - const pending = this.pendingQuestions.get(questionId) + const pending = this.pendingQuestions.get(questionId); if (!pending) { - throw new Error('OpenCode 提问已失效或不存在') + throw new Error("OpenCode 提问已失效或不存在"); } const response = answers ? answers.length === pending.questionCount - ? await this.controlRequest( - '提交提问回答', - (signal) => - pending.client.question.reply( - { - requestID: pending.upstreamQuestionId, - directory: pending.directory, - answers - }, - { signal } - ) - ) - : undefined - : await this.controlRequest( - '取消提问', - (signal) => - pending.client.question.reject( + ? await this.controlRequest("提交提问回答", (signal) => + pending.client.question.reply( { requestID: pending.upstreamQuestionId, - directory: pending.directory + directory: pending.directory, + answers, }, - { signal } - ) - ) + { signal }, + ), + ) + : undefined + : await this.controlRequest("取消提问", (signal) => + pending.client.question.reject( + { + requestID: pending.upstreamQuestionId, + directory: pending.directory, + }, + { signal }, + ), + ); if (!response) { - throw new Error('OpenCode 提问回答数量不匹配') + throw new Error("OpenCode 提问回答数量不匹配"); } if (response.error || response.data !== true) { throw new Error( - answers ? 'OpenCode 提交回答失败' : 'OpenCode 取消提问失败' - ) + answers ? "OpenCode 提交回答失败" : "OpenCode 取消提问失败", + ); } - this.pendingQuestions.delete(questionId) + this.pendingQuestions.delete(questionId); } async compactConversation( request: RuntimeConversationCompactInput, - signal: AbortSignal + signal: AbortSignal, ): Promise { - signal.throwIfAborted() + signal.throwIfAborted(); if (!this.supportsNativeCustomization()) { throw new Error( - '外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact' - ) + "外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact", + ); } - const configuredTimeout = this.dependencies.executionTimeoutMs + const configuredTimeout = this.dependencies.executionTimeoutMs; const deadline = - configuredTimeout === undefined - ? undefined - : new AbortController() + configuredTimeout === undefined ? undefined : new AbortController(); const deadlineTimer = configuredTimeout === undefined || deadline === undefined ? undefined : setTimeout( - () => - deadline.abort( - new Error('OpenCode 原生 Compact 执行超时') - ), - configuredTimeout - ) - deadlineTimer?.unref?.() + () => deadline.abort(new Error("OpenCode 原生 Compact 执行超时")), + configuredTimeout, + ); + deadlineTimer?.unref?.(); const executionSignal = deadline ? AbortSignal.any([signal, deadline.signal]) - : signal - let releaseConversation: (() => void) | undefined + : signal; + let releaseConversation: (() => void) | undefined; try { releaseConversation = await this.acquireConversationRun( request.conversationId, - executionSignal - ) - const sessionId = this.sessions.get(request.conversationId) - if (!sessionId) { + executionSignal, + ); + const client = this.sessions.has(request.conversationId) + ? await this.getClient(executionSignal) + : undefined; + const sessionId = this.sessions.get(request.conversationId); + if (!sessionId || !client) { return { result: { - provider: 'opencode', - strategy: 'native', + provider: "opencode", + strategy: "native", compacted: false, - detail: '当前 GoodBuddy 对话尚无可压缩的 OpenCode 会话' - } - } + detail: "当前 GoodBuddy 对话尚无可压缩的 OpenCode 会话", + }, + }; } - const client = await this.getClient(executionSignal) const context = await this.controlRequest( - '读取原生上下文', + "读取原生上下文", (controlSignal) => client.v2.session.context( { sessionID: sessionId }, - { signal: controlSignal } + { signal: controlSignal }, ), - executionSignal - ) + executionSignal, + ); if (context.error || !context.data) { - throw new Error('OpenCode 原生上下文不可用,无法执行 Compact') + throw new Error("OpenCode 原生上下文不可用,无法执行 Compact"); } const latestAssistant = [...context.data.data] .reverse() - .find((message) => message.type === 'assistant') + .find((message) => message.type === "assistant"); const configuredModel = this.options.modelProfile ? { - providerID: resolveOpenCodeProvider( - this.options.modelProfile - ).id, - modelID: this.options.modelProfile.modelName + providerID: resolveOpenCodeProvider(this.options.modelProfile).id, + modelID: this.options.modelProfile.modelName, } - : latestAssistant?.type === 'assistant' + : latestAssistant?.type === "assistant" ? { providerID: latestAssistant.model.providerID, - modelID: latestAssistant.model.id + modelID: latestAssistant.model.id, } - : undefined + : undefined; if (!configuredModel) { return { result: { - provider: 'opencode', - strategy: 'native', + provider: "opencode", + strategy: "native", compacted: false, - detail: '当前 OpenCode 会话尚无可用于压缩的模型记录' - } - } + detail: "当前 OpenCode 会话尚无可用于压缩的模型记录", + }, + }; } - executionSignal.throwIfAborted() - const subscriptionController = new AbortController() + executionSignal.throwIfAborted(); + const subscriptionController = new AbortController(); const subscriptionSignal = AbortSignal.any([ executionSignal, - subscriptionController.signal - ]) + subscriptionController.signal, + ]); const subscription = await this.controlRequest( - '订阅 Compact 事件流', + "订阅 Compact 事件流", (controlSignal) => client.event.subscribe( { directory: this.options.defaultWorkspace }, - { signal: controlSignal } + { signal: controlSignal }, ), - subscriptionSignal - ) - const usageEvents: RuntimeModelUsageEvent[] = [] - const reportedMessageIds = new Set() + subscriptionSignal, + ); + const usageEvents: RuntimeModelUsageEvent[] = []; + const reportedMessageIds = new Set(); const usageCapture = (async () => { for await (const event of subscription.stream) { if ( - event.type === 'message.updated' && + event.type === "message.updated" && event.properties.sessionID === sessionId && event.properties.info.sessionID === sessionId && - event.properties.info.role === 'assistant' && + event.properties.info.role === "assistant" && !reportedMessageIds.has(event.properties.info.id) ) { const usage = createUsageEvent( request.requestId, - event.properties.info - ) + event.properties.info, + ); if (usage) { - reportedMessageIds.add(event.properties.info.id) - usageEvents.push(usage) + reportedMessageIds.add(event.properties.info.id); + usageEvents.push(usage); } } if ( - event.type === 'session.idle' && + event.type === "session.idle" && event.properties.sessionID === sessionId ) { - return + return; } } - })() + })(); try { const compact = await client.session.summarize( { @@ -3107,96 +3014,90 @@ export class OpenCodeRuntime implements AgentRuntime { directory: this.options.defaultWorkspace, providerID: configuredModel.providerID, modelID: configuredModel.modelID, - auto: false + auto: false, }, - { signal: executionSignal } - ) + { signal: executionSignal }, + ); if (compact.error || compact.data !== true) { throw new Error( - opencodeErrorMessage( - compact.error, - 'OpenCode 原生 Compact 失败' - ) - ) + opencodeErrorMessage(compact.error, "OpenCode 原生 Compact 失败"), + ); } - let graceTimer: ReturnType | undefined + let graceTimer: ReturnType | undefined; try { await Promise.race([ usageCapture, new Promise((resolveGrace) => { graceTimer = setTimeout( resolveGrace, - COMPACTION_USAGE_EVENT_GRACE_MS - ) - graceTimer.unref?.() - }) - ]) + COMPACTION_USAGE_EVENT_GRACE_MS, + ); + graceTimer.unref?.(); + }), + ]); } finally { if (graceTimer) { - clearTimeout(graceTimer) + clearTimeout(graceTimer); } } return { result: { - provider: 'opencode', - strategy: 'native', + provider: "opencode", + strategy: "native", compacted: true, - detail: 'OpenCode 已完成原生上下文压缩' + detail: "OpenCode 已完成原生上下文压缩", }, - ...(usageEvents.length > 0 ? { usageEvents } : {}) - } + ...(usageEvents.length > 0 ? { usageEvents } : {}), + }; } finally { - subscriptionController.abort() - await usageCapture.catch(() => undefined) + subscriptionController.abort(); + await usageCapture.catch(() => undefined); } } catch (error) { if (deadline?.signal.aborted && !signal.aborted) { - throw deadline.signal.reason + throw deadline.signal.reason; } - throw error + throw error; } finally { if (deadlineTimer) { - clearTimeout(deadlineTimer) + clearTimeout(deadlineTimer); } - releaseConversation?.() + releaseConversation?.(); } } async dispose(): Promise { - this.pendingQuestions.clear() - const startingChild = this.startingChild - this.startingChild = undefined + this.pendingQuestions.clear(); + const startingChild = this.startingChild; + this.startingChild = undefined; if (startingChild) { - this.terminate(startingChild) - await this.waitForExit(startingChild) + this.terminate(startingChild); + await this.waitForExit(startingChild); } - const server = this.server - this.server = undefined - this.client = undefined - this.clientInitialization = undefined - this.sessions.clear() - this.sessionInitializations.clear() - this.conversationRunTails.clear() - await server?.close() + const server = this.server; + this.server = undefined; + this.client = undefined; + this.clientInitialization = undefined; + this.sessions.clear(); + this.sessionInitializations.clear(); + this.conversationRunTails.clear(); + await server?.close(); } async releaseConversation(conversationId: string): Promise { - const sessionId = this.sessions.get(conversationId) - this.sessions.delete(conversationId) + const sessionId = this.sessions.get(conversationId); + this.sessions.delete(conversationId); if (!sessionId || !this.client) { - return + return; } - await this.controlRequest( - '释放会话', - (signal) => - this.client!.session.delete( - { - sessionID: sessionId, - directory: this.options.defaultWorkspace - }, - { signal } - ) - ) - .catch(() => undefined) + await this.controlRequest("释放会话", (signal) => + this.client!.session.delete( + { + sessionID: sessionId, + directory: this.options.defaultWorkspace, + }, + { signal }, + ), + ).catch(() => undefined); } } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 2ad1c575..20f4a3b8 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -5,203 +5,184 @@ import { render, screen, within, - waitFor -} from '@testing-library/react' -import { StrictMode } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + waitFor, +} from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentEvent, BrowserLiveState, ContextAttachment, ConversationQueueDispatch, - DesktopApi -} from '../../shared/contracts' + DesktopApi, +} from "../../shared/contracts"; import { defaultLocalToolEnvironmentSettings, - type ApplicationSettings -} from '../../shared/application-settings-contracts' -import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut' -import type { - TerminalSnapshot -} from '../../shared/terminal-contracts' -import { - loadBrandingPreferences, - saveBrandingPreferences -} from './branding' + type ApplicationSettings, +} from "../../shared/application-settings-contracts"; +import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut"; +import type { TerminalSnapshot } from "../../shared/terminal-contracts"; +import { loadBrandingPreferences, saveBrandingPreferences } from "./branding"; import type { AssistantProject, AssistantSchedule, AssistantTask, - ConversationSnapshot -} from '../../shared/assistant-contracts' + ConversationSnapshot, +} from "../../shared/assistant-contracts"; import { builtInDefaultProjectSeedDescription, - builtInDefaultProjectSeedName -} from '../../shared/assistant-contracts' -import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' + builtInDefaultProjectSeedName, +} from "../../shared/assistant-contracts"; +import { agentRuntimeSelectionKey } from "../../shared/runtime-selection-contracts"; const speechRecognitionMocks = vi.hoisted(() => ({ - startPcmRecording: vi.fn() -})) + startPcmRecording: vi.fn(), +})); -const messageRenderProbe = vi.hoisted(() => vi.fn()) +const messageRenderProbe = vi.hoisted(() => vi.fn()); -vi.mock('./MarkdownRenderer', async (importOriginal) => { - const original = await importOriginal() +vi.mock("./MarkdownRenderer", async (importOriginal) => { + const original = await importOriginal(); return { ...original, MarkdownRenderer: (props: { children: string; renderHtml?: boolean }) => { - messageRenderProbe(props.children) - return - } - } -}) + messageRenderProbe(props.children); + return ; + }, + }; +}); const lazyRouteMocks = vi.hoisted(() => { - let pending: Promise | undefined - let releasePending: (() => void) | undefined + let pending: Promise | undefined; + let releasePending: (() => void) | undefined; return { suspendKnowledgeRoute(): void { pending = new Promise((resolve) => { - releasePending = resolve - }) + releasePending = resolve; + }); }, releaseKnowledgeRoute(): void { - releasePending?.() - releasePending = undefined - pending = undefined + releasePending?.(); + releasePending = undefined; + pending = undefined; }, async waitForKnowledgeRoute(): Promise { - await pending - } - } -}) + await pending; + }, + }; +}); const routeModuleLoads = vi.hoisted(() => ({ activity: 0, heartbeat: 0, knowledge: 0, magicNotes: 0, - settings: 0 -})) + settings: 0, +})); -vi.mock('./speech-recognition', async (importOriginal) => ({ - ...(await importOriginal< - typeof import('./speech-recognition') - >()), - startPcmRecording: speechRecognitionMocks.startPcmRecording -})) +vi.mock("./speech-recognition", async (importOriginal) => ({ + ...(await importOriginal()), + startPcmRecording: speechRecognitionMocks.startPcmRecording, +})); -vi.mock('./KnowledgeWorkspace', async (importOriginal) => { - await lazyRouteMocks.waitForKnowledgeRoute() - routeModuleLoads.knowledge += 1 - return importOriginal() -}) +vi.mock("./KnowledgeWorkspace", async (importOriginal) => { + await lazyRouteMocks.waitForKnowledgeRoute(); + routeModuleLoads.knowledge += 1; + return importOriginal(); +}); -vi.mock('./HeartbeatCenter', async (importOriginal) => { - routeModuleLoads.heartbeat += 1 - return importOriginal() -}) +vi.mock("./HeartbeatCenter", async (importOriginal) => { + routeModuleLoads.heartbeat += 1; + return importOriginal(); +}); -vi.mock('./MagicNotesWorkspace', async (importOriginal) => { - routeModuleLoads.magicNotes += 1 - return importOriginal() -}) +vi.mock("./MagicNotesWorkspace", async (importOriginal) => { + routeModuleLoads.magicNotes += 1; + return importOriginal(); +}); -vi.mock('./SettingsPanel', async (importOriginal) => { - routeModuleLoads.settings += 1 - return importOriginal() -}) +vi.mock("./SettingsPanel", async (importOriginal) => { + routeModuleLoads.settings += 1; + return importOriginal(); +}); -vi.mock('./ActivityPanel', async (importOriginal) => { - routeModuleLoads.activity += 1 - return importOriginal() -}) +vi.mock("./ActivityPanel", async (importOriginal) => { + routeModuleLoads.activity += 1; + return importOriginal(); +}); -vi.mock('./TerminalPanel', () => ({ +vi.mock("./TerminalPanel", () => ({ TerminalPanel: ({ title }: { title?: string }) => ( -
- ) -})) +
+ ), +})); -import App from './App' -import { ACTIVITY_STORAGE_KEY } from './activity-store' -import { changeUiLocale } from './i18n' -import { UiLocaleProvider } from './i18n/UiLocaleProvider' +import App from "./App"; +import { ACTIVITY_STORAGE_KEY } from "./activity-store"; +import { changeUiLocale } from "./i18n"; +import { UiLocaleProvider } from "./i18n/UiLocaleProvider"; -let agentListener: ((event: AgentEvent) => void) | undefined -let browserListener: ((state: BrowserLiveState) => void) | undefined +let agentListener: ((event: AgentEvent) => void) | undefined; +let browserListener: ((state: BrowserLiveState) => void) | undefined; let fileSelectionProgressListener: - | Parameters< - DesktopApi['context']['onFileSelectionProgress'] - >[0] - | undefined -let newConversationListener: (() => void) | undefined -let magicTodoStatusChangedListener: (() => void) | undefined -let maximizedChangedListener: ((maximized: boolean) => void) | undefined -let beforeQuitListener: (() => Promise) | undefined + Parameters[0] | undefined; +let newConversationListener: (() => void) | undefined; +let magicTodoStatusChangedListener: (() => void) | undefined; +let maximizedChangedListener: ((maximized: boolean) => void) | undefined; +let beforeQuitListener: (() => Promise) | undefined; let conversationQueueDispatchListener: - | ((dispatch: ConversationQueueDispatch) => void) - | undefined + ((dispatch: ConversationQueueDispatch) => void) | undefined; let conversationQueueChangeListener: - | Parameters< - DesktopApi['conversationQueue']['onChanged'] - >[0] - | undefined + Parameters[0] | undefined; let remoteProjectSaveProgressListener: - | Parameters< - DesktopApi['projects']['remote']['onSaveProgress'] - >[0] - | undefined + Parameters[0] | undefined; let remoteProjectRecoveryProgressListener: - | Parameters< - DesktopApi['projects']['remote']['onRecoveryProgress'] - >[0] - | undefined -const removeMaximizedChangedListener = vi.fn() -const removeProjectRecoveryListener = vi.fn() -const run = vi.fn() -const modelProfileId = '00000000-0000-4000-8000-000000000001' -const projectId = '00000000-0000-4000-8000-000000000101' + | Parameters[0] + | undefined; +const removeMaximizedChangedListener = vi.fn(); +const removeProjectRecoveryListener = vi.fn(); +const run = vi.fn(); +const modelProfileId = "00000000-0000-4000-8000-000000000001"; +const projectId = "00000000-0000-4000-8000-000000000101"; const project = { id: projectId, name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, - rootPath: 'C:\\Users\\test', + rootPath: "C:\\Users\\test", executionSpace: { - kind: 'local' as const, - rootPath: 'C:\\Users\\test' + kind: "local" as const, + rootPath: "C:\\Users\\test", }, - defaultWorkMode: 'ask' as const, - kind: 'user' as const, + defaultWorkMode: "ask" as const, + kind: "user" as const, builtInDefault: true, - status: 'active' as const, - createdAt: '2026-07-31T00:00:00.000Z', - updatedAt: '2026-07-31T00:00:00.000Z' -} + status: "active" as const, + createdAt: "2026-07-31T00:00:00.000Z", + updatedAt: "2026-07-31T00:00:00.000Z", +}; const terminalSnapshot: TerminalSnapshot = { - sessionId: '00000000-0000-4000-8000-000000000901', - target: { type: 'local' }, - targetLabel: '本机', - title: '终端 1', - state: 'running', - shell: 'PowerShell 7', - workingDirectory: 'C:\\Users\\test', + sessionId: "00000000-0000-4000-8000-000000000901", + target: { type: "local" }, + targetLabel: "本机", + title: "终端 1", + state: "running", + shell: "PowerShell 7", + workingDirectory: "C:\\Users\\test", size: { cols: 80, rows: 24 }, lastSequence: 0, exit: null, - error: null -} + error: null, +}; const api: DesktopApi = { app: { getInfo: vi.fn(async () => ({ - name: 'GoodBuddy', - version: '0.1.0', - platform: 'win32', - arch: 'x64', - shortcut: 'Ctrl+Shift+Space' + name: "GoodBuddy", + version: "0.1.0", + platform: "win32", + arch: "x64", + shortcut: "Ctrl+Shift+Space", })), show: vi.fn(async () => {}), hide: vi.fn(async () => {}), @@ -210,59 +191,59 @@ const api: DesktopApi = { close: vi.fn(async () => {}), isMaximized: vi.fn(async () => false), onMaximizedChanged: vi.fn((listener) => { - maximizedChangedListener = listener - return removeMaximizedChangedListener + maximizedChangedListener = listener; + return removeMaximizedChangedListener; }), onBeforeQuit: vi.fn((listener) => { - beforeQuitListener = listener + beforeQuitListener = listener; return () => { - beforeQuitListener = undefined - } + beforeQuitListener = undefined; + }; }), clearLocalData: vi.fn(async () => {}), onNewConversation: vi.fn((listener) => { - newConversationListener = listener + newConversationListener = listener; return () => { - newConversationListener = undefined - } + newConversationListener = undefined; + }; }), - onOpenSettings: vi.fn(() => () => {}) + onOpenSettings: vi.fn(() => () => {}), }, clipboard: { - readText: vi.fn(async () => ''), - writeText: vi.fn(async () => {}) + readText: vi.fn(async () => ""), + writeText: vi.fn(async () => {}), }, feedback: { - submit: vi.fn(async () => ({ + submit: vi.fn(async () => ({ ok: true, - reference: 'GOODBUDDY-000001', - duplicate: false - })) + reference: "GOODBUDDY-000001", + duplicate: false, + })), }, agent: { - getStatus: vi.fn(async () => ({ - id: 'model' as const, - label: 'sonnet-5', + getStatus: vi.fn(async () => ({ + id: "model" as const, + label: "sonnet-5", available: true, supportsToolExecution: true, - detail: 'Ready' + detail: "Ready", })), run, cancel: vi.fn(async () => {}), respondApproval: vi.fn(async () => {}), respondQuestion: vi.fn(async () => {}), compactConversation: vi.fn(async () => ({ - provider: 'continue' as const, - strategy: 'goodbuddy-summary' as const, + provider: "continue" as const, + strategy: "goodbuddy-summary" as const, compacted: false, - detail: 'No context to compact' + detail: "No context to compact", })), onEvent: vi.fn((listener) => { - agentListener = listener + agentListener = listener; return () => { - agentListener = undefined - } - }) + agentListener = undefined; + }; + }), }, browser: { navigate: vi.fn(async () => {}), @@ -272,84 +253,83 @@ const api: DesktopApi = { interact: vi.fn(async () => {}), stop: vi.fn(async () => {}), onState: vi.fn((listener) => { - browserListener = listener + browserListener = listener; return () => { - browserListener = undefined - } - }) + browserListener = undefined; + }; + }), }, terminal: { create: vi.fn(async (request) => ({ ...terminalSnapshot, target: request.target, - size: { cols: request.cols, rows: request.rows } + size: { cols: request.cols, rows: request.rows }, })), write: vi.fn(async () => undefined), resize: vi.fn(async () => undefined), close: vi.fn(async () => ({ ...terminalSnapshot, - state: 'exited' as const, - exit: { exitCode: 0, signal: null } + state: "exited" as const, + exit: { exitCode: 0, signal: null }, })), getSnapshot: vi.fn(async () => terminalSnapshot), ack: vi.fn(async () => undefined), - onEvent: vi.fn(() => () => {}) + onEvent: vi.fn(() => () => {}), }, speech: { - transcribe: vi.fn(async () => ({ text: '本地语音结果' })), - cancel: vi.fn(async () => true) + transcribe: vi.fn(async () => ({ text: "本地语音结果" })), + cancel: vi.fn(async () => true), }, settings: { - getRuntime: vi.fn(async () => ({ - provider: 'auto', - modelBaseUrl: 'https://bigtoken.ai', - modelName: 'sonnet-5', - modelProtocol: 'anthropic-messages', - modelAuthentication: 'api-key', - imageGenerationQuality: 'auto', - opencodeBaseUrl: '', + getRuntime: vi.fn(async () => ({ + provider: "auto", + modelBaseUrl: "https://bigtoken.ai", + modelName: "sonnet-5", + modelProtocol: "anthropic-messages", + modelAuthentication: "api-key", + imageGenerationQuality: "auto", + opencodeBaseUrl: "", opencodeEmbedded: false, - opencodeBinaryPath: '', - opencodeConfigPath: '', - continueBinaryPath: '', - continueConfigPath: '', - continueMode: 'chat', + opencodeBinaryPath: "", + opencodeConfigPath: "", + continueBinaryPath: "", + continueConfigPath: "", + continueMode: "chat", subagentSmartRoutingEnabled: false, knowledgeEmbeddingEnabled: false, - knowledgeEmbeddingBaseUrl: - 'http://127.0.0.1:11434/v1/embeddings', - knowledgeEmbeddingModel: 'nomic-embed-text', + knowledgeEmbeddingBaseUrl: "http://127.0.0.1:11434/v1/embeddings", + knowledgeEmbeddingModel: "nomic-embed-text", knowledgeEmbeddingApiKeyConfigured: false, - knowledgeEmbeddingCredentialSource: 'none', - workspacePath: 'C:\\Users\\test', + knowledgeEmbeddingCredentialSource: "none", + workspacePath: "C:\\Users\\test", apiKeyConfigured: false, - credentialSource: 'none', + credentialSource: "none", modelProfiles: [ { id: modelProfileId, - name: '默认模型', - baseUrl: 'https://bigtoken.ai', - modelName: 'sonnet-5', - protocol: 'anthropic-messages', - authentication: 'api-key', - imageGenerationQuality: 'auto', + name: "默认模型", + baseUrl: "https://bigtoken.ai", + modelName: "sonnet-5", + protocol: "anthropic-messages", + authentication: "api-key", + imageGenerationQuality: "auto", apiKeyConfigured: false, - credentialSource: 'none' - } + credentialSource: "none", + }, ], defaultModelProfileId: modelProfileId, opencodeModelSource: { - kind: 'profile', - profileId: modelProfileId + kind: "profile", + profileId: modelProfileId, }, continueModelSource: { - kind: 'profile', - profileId: modelProfileId + kind: "profile", + profileId: modelProfileId, }, secureStorageAvailable: true, - toolApproval: 'always' + toolApproval: "always", })), - updateRuntime: vi.fn( + updateRuntime: vi.fn( async (input) => ({ provider: input.provider, modelBaseUrl: input.modelBaseUrl, @@ -364,127 +344,119 @@ const api: DesktopApi = { continueBinaryPath: input.continueBinaryPath, continueConfigPath: input.continueConfigPath, continueMode: input.continueMode, - subagentSmartRoutingEnabled: - input.subagentSmartRoutingEnabled ?? false, + subagentSmartRoutingEnabled: input.subagentSmartRoutingEnabled ?? false, knowledgeEmbeddingEnabled: input.knowledgeEmbeddingEnabled, knowledgeEmbeddingBaseUrl: input.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: input.knowledgeEmbeddingModel, knowledgeEmbeddingApiKeyConfigured: - input.knowledgeEmbeddingApiKey?.action === 'replace', + input.knowledgeEmbeddingApiKey?.action === "replace", knowledgeEmbeddingCredentialSource: - input.knowledgeEmbeddingApiKey?.action === 'replace' - ? 'encrypted' - : 'none', + input.knowledgeEmbeddingApiKey?.action === "replace" + ? "encrypted" + : "none", workspacePath: input.workspacePath, - apiKeyConfigured: input.apiKey.action === 'replace', + apiKeyConfigured: input.apiKey.action === "replace", credentialSource: - input.apiKey.action === 'replace' ? 'encrypted' : 'none', + input.apiKey.action === "replace" ? "encrypted" : "none", modelProfiles: ( input.modelProfiles ?? [ { id: modelProfileId, - name: '默认模型', + name: "默认模型", baseUrl: input.modelBaseUrl, modelName: input.modelName, protocol: input.modelProtocol, authentication: input.modelAuthentication, - imageGenerationQuality: - input.imageGenerationQuality, - apiKey: input.apiKey - } + imageGenerationQuality: input.imageGenerationQuality, + apiKey: input.apiKey, + }, ] ).map(({ apiKey, ...profile }) => ({ ...profile, - apiKeyConfigured: apiKey.action === 'replace', + apiKeyConfigured: apiKey.action === "replace", credentialSource: - apiKey.action === 'replace' - ? ('encrypted' as const) - : ('none' as const) + apiKey.action === "replace" + ? ("encrypted" as const) + : ("none" as const), })), - defaultModelProfileId: - input.defaultModelProfileId ?? modelProfileId, - opencodeModelSource: - input.opencodeModelSource ?? { kind: 'platform' }, - continueModelSource: - input.continueModelSource ?? { kind: 'platform' }, + defaultModelProfileId: input.defaultModelProfileId ?? modelProfileId, + opencodeModelSource: input.opencodeModelSource ?? { kind: "platform" }, + continueModelSource: input.continueModelSource ?? { kind: "platform" }, secureStorageAvailable: true, - toolApproval: input.toolApproval - }) + toolApproval: input.toolApproval, + }), ), selectWorkspace: vi.fn(async () => undefined), - detectAgentRuntimes: vi.fn< - DesktopApi['settings']['detectAgentRuntimes'] - >(async () => ({ - opencode: { - available: false, - detail: '未检测到 OpenCode' - }, - continue: { - available: false, - detail: '未检测到 Continue' - }, - deepseekHarness: { - available: true, - path: 'bundled://deepseek-harness', - detail: 'Bundled Harness Adapter ready' - } - })), + detectAgentRuntimes: vi.fn( + async () => ({ + opencode: { + available: false, + detail: "未检测到 OpenCode", + }, + continue: { + available: false, + detail: "未检测到 Continue", + }, + deepseekHarness: { + available: true, + path: "bundled://deepseek-harness", + detail: "Bundled Harness Adapter ready", + }, + }), + ), selectRuntimeFile: vi.fn(async () => undefined), openRuntimeConfig: vi.fn(async () => {}), - testModelConnection: vi.fn< - DesktopApi['settings']['testModelConnection'] - >(async () => ({ - id: 'model', - label: 'sonnet-5', - available: true, - supportsToolExecution: true, - detail: 'Ready' - })), - testRuntime: vi.fn( + testModelConnection: vi.fn( async () => ({ - id: 'model', - label: 'sonnet-5', + id: "model", + label: "sonnet-5", available: true, supportsToolExecution: true, - detail: 'Ready' - }) - ) + detail: "Ready", + }), + ), + testRuntime: vi.fn(async () => ({ + id: "model", + label: "sonnet-5", + available: true, + supportsToolExecution: true, + detail: "Ready", + })), }, projects: { list: vi.fn(async () => [project]), create: vi.fn(async (input) => ({ ...project, ...input, - id: crypto.randomUUID() + id: crypto.randomUUID(), })), update: vi.fn(async (_projectId, input) => ({ ...project, ...input, - id: _projectId + id: _projectId, })), setArchived: vi.fn(async () => {}), delete: vi.fn(async () => {}), remote: { - save: vi.fn(), - cancelCurrent: - vi.fn(), + save: vi.fn(), + cancelCurrent: vi.fn(), onSaveProgress: vi.fn((listener) => { - remoteProjectSaveProgressListener = listener + remoteProjectSaveProgressListener = listener; return () => { if (remoteProjectSaveProgressListener === listener) { - remoteProjectSaveProgressListener = undefined + remoteProjectSaveProgressListener = undefined; } - } + }; }), getRecoverySnapshot: vi.fn(async () => ({ - recoveries: [] + recoveries: [], })), retryRecovery: vi.fn(), onRecoveryProgress: vi.fn((listener) => { - remoteProjectRecoveryProgressListener = listener - return removeProjectRecoveryListener - }) - } + remoteProjectRecoveryProgressListener = listener; + return removeProjectRecoveryListener; + }), + }, }, conversations: { list: vi.fn(async () => []), @@ -494,14 +466,14 @@ const api: DesktopApi = { id: crypto.randomUUID(), branch: { sourceConversationId: input.sourceConversationId, - sourceTitle: '新对话' + sourceTitle: "新对话", }, title: input.title, updatedAt: Date.now(), - messages: [] + messages: [], })), deleteLocal: vi.fn(async () => true), - onChanged: vi.fn(() => () => undefined) + onChanged: vi.fn(() => () => undefined), }, conversationQueue: { list: vi.fn(async () => []), @@ -509,69 +481,69 @@ const api: DesktopApi = { const item = { id: crypto.randomUUID(), conversationId: input.conversationId, - source: 'user' as const, + source: "user" as const, label: input.prompt, - createdAt: '2026-07-31T00:00:00.000Z' - } + createdAt: "2026-07-31T00:00:00.000Z", + }; queueMicrotask(() => - conversationQueueDispatchListener?.({ item, input }) - ) - return item + conversationQueueDispatchListener?.({ item, input }), + ); + return item; }), remove: vi.fn(async () => {}), interruptAndRun: vi.fn(async () => {}), releaseUser: vi.fn(async () => {}), ready: vi.fn(async () => {}), onChanged: vi.fn((listener) => { - conversationQueueChangeListener = listener + conversationQueueChangeListener = listener; return () => { - conversationQueueChangeListener = undefined - } + conversationQueueChangeListener = undefined; + }; }), onDispatch: vi.fn((listener) => { - conversationQueueDispatchListener = listener + conversationQueueDispatchListener = listener; return () => { - conversationQueueDispatchListener = undefined - } - }) + conversationQueueDispatchListener = undefined; + }; + }), }, workspace: { getFileDiff: vi.fn(), getChanges: vi.fn(async () => ({ - rootPath: 'C:\\Workspace', + rootPath: "C:\\Workspace", available: true, - status: '', - patch: '', + status: "", + patch: "", files: [], - truncated: false + truncated: false, })), listDirectory: vi.fn(async (path: string) => ({ path, entries: [], - truncated: false + truncated: false, })), readFile: vi.fn(async (path: string) => ({ path, - name: path.split('/').at(-1) ?? path, - content: '', - mimeType: 'text/plain' as const, + name: path.split("/").at(-1) ?? path, + content: "", + mimeType: "text/plain" as const, size: 0, offsetBytes: 0, nextOffsetBytes: 0, - truncated: false + truncated: false, })), - openPath: vi.fn(async () => {}) + openPath: vi.fn(async () => {}), }, tasks: { list: vi.fn(async () => []), - setStatus: vi.fn(async () => {}) + setStatus: vi.fn(async () => {}), }, activityHistory: { get: vi.fn(async () => ({ records: [], - legacyHistoryMayBeIncomplete: false + legacyHistoryMayBeIncomplete: false, })), - replace: vi.fn(async () => {}) + replace: vi.fn(async () => {}), }, usage: { getTokenSummary: vi.fn(async () => ({ @@ -581,17 +553,17 @@ const api: DesktopApi = { output: 0, cacheRead: 0, cacheWrite: 0, - totalTokens: 0 + totalTokens: 0, }, - records: [] - })) + records: [], + })), }, artifacts: { list: vi.fn(async () => []), get: vi.fn(async () => { - throw new Error('Artifact not found') + throw new Error("Artifact not found"); }), - importFiles: vi.fn(async () => []) + importFiles: vi.fn(async () => []), }, memory: { list: vi.fn(async () => []), @@ -600,12 +572,12 @@ const api: DesktopApi = { id: crypto.randomUUID(), confidence: 1, salience: 1, - status: 'confirmed' as const, - createdAt: '2026-07-31T00:00:00.000Z', - updatedAt: '2026-07-31T00:00:00.000Z' + status: "confirmed" as const, + createdAt: "2026-07-31T00:00:00.000Z", + updatedAt: "2026-07-31T00:00:00.000Z", })), setStatus: vi.fn(async () => {}), - remove: vi.fn(async () => {}) + remove: vi.fn(async () => {}), }, schedules: { list: vi.fn(async () => []), @@ -615,42 +587,42 @@ const api: DesktopApi = { taskId: crypto.randomUUID(), conversationId: input.conversationId ?? crypto.randomUUID(), enabled: true, - createdAt: '2026-07-31T00:00:00.000Z', - updatedAt: '2026-07-31T00:00:00.000Z' + createdAt: "2026-07-31T00:00:00.000Z", + updatedAt: "2026-07-31T00:00:00.000Z", })), setEnabled: vi.fn(async () => {}), remove: vi.fn(async () => {}), - runNow: vi.fn(async () => {}) + runNow: vi.fn(async () => {}), }, heartbeats: { list: vi.fn(async () => []), create: vi.fn(async (input) => ({ ...input, id: crypto.randomUUID(), - nextRunAt: '2026-08-01T09:00:00.000Z', - createdAt: '2026-08-01T00:00:00.000Z', - updatedAt: '2026-08-01T00:00:00.000Z' + nextRunAt: "2026-08-01T09:00:00.000Z", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", })), update: vi.fn(async (heartbeatId, input) => ({ ...input, id: heartbeatId, - nextRunAt: '2026-08-01T09:00:00.000Z', - createdAt: '2026-08-01T00:00:00.000Z', - updatedAt: '2026-08-01T00:00:00.000Z' + nextRunAt: "2026-08-01T09:00:00.000Z", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", })), setPaused: vi.fn(async () => {}), remove: vi.fn(async () => {}), runNow: vi.fn(async (heartbeatId) => ({ id: crypto.randomUUID(), configId: heartbeatId, - trigger: 'manual' as const, - scheduledFor: '2026-08-01T00:00:00.000Z', - status: 'completed' as const, + trigger: "manual" as const, + scheduledFor: "2026-08-01T00:00:00.000Z", + status: "completed" as const, attemptCount: 1, - createdAt: '2026-08-01T00:00:00.000Z', - updatedAt: '2026-08-01T00:00:00.000Z' + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", })), - history: vi.fn(async () => ({ runs: [], entries: [] })) + history: vi.fn(async () => ({ runs: [], entries: [] })), }, experts: { list: vi.fn(async () => []), @@ -658,87 +630,87 @@ const api: DesktopApi = { ...input, id: crypto.randomUUID(), enabled: true, - createdAt: '2026-07-31T00:00:00.000Z', - updatedAt: '2026-07-31T00:00:00.000Z' + createdAt: "2026-07-31T00:00:00.000Z", + updatedAt: "2026-07-31T00:00:00.000Z", })), update: vi.fn(async (expertId, input) => ({ ...input, id: expertId, enabled: true, - createdAt: '2026-07-31T00:00:00.000Z', - updatedAt: '2026-08-04T00:00:00.000Z' + createdAt: "2026-07-31T00:00:00.000Z", + updatedAt: "2026-08-04T00:00:00.000Z", })), - remove: vi.fn(async () => {}) + remove: vi.fn(async () => {}), }, capabilities: { getSnapshot: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), importSkill: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), removeSkill: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), setSkillEnabled: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), setSkillAssignments: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), setBuiltinMcpServerEnabled: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), setBuiltinMcpServerAssignments: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), saveMcpServer: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), removeMcpServer: vi.fn(async () => ({ skills: [], - mcpServers: [] + mcpServers: [], })), testMcpServer: vi.fn(async () => ({ dynamicToolsSupported: false, toolCount: 0, - tools: [] - })) + tools: [], + })), }, runtimeExtensions: { getSnapshot: vi.fn(async () => ({ marketplaceEnabled: false, catalog: [], - installed: [] + installed: [], })), apply: vi.fn(async () => ({ marketplaceEnabled: false, catalog: [], - installed: [] - })) + installed: [], + })), }, runtimeCustomization: { getSettings: vi.fn(async () => ({ opencode: {}, - continue: { presets: [] } + continue: { presets: [] }, })), updateSettings: vi.fn(async (settings) => settings), getNativeSnapshot: vi.fn(async (input) => ({ provider: input.provider, available: true, - inventoryStatus: 'available' as const, - detail: 'Ready', + inventoryStatus: "available" as const, + detail: "Ready", agents: [], tools: [], - toolsSupported: input.provider !== 'continue', + toolsSupported: input.provider !== "continue", commands: [], lsp: [], formatters: [], @@ -749,77 +721,77 @@ const api: DesktopApi = { resources: [], resourcesSupported: false, context: { - strategy: 'unsupported' as const, + strategy: "unsupported" as const, manualCompact: false, - detail: 'Unsupported' - } - })) + detail: "Unsupported", + }, + })), }, context: { selectFiles: vi.fn(async () => []), onFileSelectionProgress: vi.fn((listener) => { - fileSelectionProgressListener = listener + fileSelectionProgressListener = listener; return () => { - fileSelectionProgressListener = undefined - } + fileSelectionProgressListener = undefined; + }; }), addPastedImage: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), captureScreen: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), listWindows: vi.fn(async () => []), captureWindow: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), readClipboard: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), - remove: vi.fn(async () => {}) + remove: vi.fn(async () => {}), }, magicNotes: { list: vi.fn(async () => ({ notes: [] })), get: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), create: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), update: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), remove: vi.fn(async () => {}), createEntry: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), updateEntry: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), removeEntry: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), analyze: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), analyzeDraft: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), listTodos: vi.fn(async () => ({ todos: [] })), getTodoStatus: vi.fn(async () => ({ incompleteCount: 0 })), updateTodo: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), analyzeTodo: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), onAnalysisEvent: vi.fn(() => vi.fn()), onTodoStatusChanged: vi.fn((listener) => { - magicTodoStatusChangedListener = listener + magicTodoStatusChangedListener = listener; return () => { - magicTodoStatusChangedListener = undefined - } - }) + magicTodoStatusChangedListener = undefined; + }; + }), }, knowledge: { getSnapshot: vi.fn(async () => ({ @@ -828,14 +800,14 @@ const api: DesktopApi = { documents: [], graphNodes: [], graphRelations: [], - evidence: [] + evidence: [], })), createLibrary: vi.fn(async (input) => ({ ...input, id: crypto.randomUUID(), sourceCount: 0, documentCount: 0, - indexedDocumentCount: 0 + indexedDocumentCount: 0, })), updateLibrary: vi.fn(async () => {}), deleteLibrary: vi.fn(async () => {}), @@ -863,7 +835,7 @@ const api: DesktopApi = { contextMaxCharacters: 16_000, adjacentChunkCount: 0, localRerankEnabled: false, - rerankMode: 'none' + rerankMode: "none", }, diagnostics: { requestedChannels: [], @@ -875,28 +847,28 @@ const api: DesktopApi = { filteredByThresholdCount: 0, filteredByBudgetCount: 0, rerank: { - requested: 'none' as const, - used: 'none' as const, - status: 'skipped' as const, + requested: "none" as const, + used: "none" as const, + status: "skipped" as const, candidateCount: 0, - durationMs: 0 - } + durationMs: 0, + }, }, results: [], context: { characterCount: 0, truncated: false, - groups: [] - } + groups: [], + }, })), updateSettings: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), listChunks: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, - totalItems: 0 + totalItems: 0, })), updateChunk: vi.fn(async () => {}), deleteChunk: vi.fn(async () => {}), @@ -906,31 +878,31 @@ const api: DesktopApi = { documents: [], graphNodes: [], graphRelations: [], - evidence: [] + evidence: [], })), openDocumentSource: vi.fn(async () => {}), rebuildLibrary: vi.fn(async () => ({ rebuilt: 0, - failed: 0 + failed: 0, })), cancelRebuild: vi.fn(async () => true), getEmbeddingIndex: vi.fn(async (knowledgeBaseId: string) => ({ knowledgeBaseId, enabled: false, coverage: { total: 0, indexed: 0, missing: 0, error: 0 }, - indexStatus: { job: null } + indexStatus: { job: null }, })), rebuildEmbeddingIndex: vi.fn(async (knowledgeBaseId: string) => ({ knowledgeBaseId, enabled: false, coverage: { total: 0, indexed: 0, missing: 0, error: 0 }, - indexStatus: { job: null } + indexStatus: { job: null }, })), cancelEmbeddingIndex: vi.fn(async () => false), cancelTask: vi.fn(async () => false), retryTask: vi.fn(async () => {}), getReferenceContext: vi.fn(async () => { - throw new Error('not used') + throw new Error("not used"); }), openReferenceSource: vi.fn(async () => {}), createEntity: vi.fn(async () => {}), @@ -940,753 +912,707 @@ const api: DesktopApi = { mergeEntities: vi.fn(async () => {}), createRelation: vi.fn(async () => {}), updateRelation: vi.fn(async () => {}), - deleteRelation: vi.fn(async () => {}) - } -} + deleteRelation: vi.fn(async () => {}), + }, +}; function composerMenuTrigger( - label: '专家角色' | '工作模式' + label: "专家角色" | "工作模式", ): HTMLButtonElement { - return screen.getByRole('button', { - name: new RegExp(`^${label}:`, 'u') - }) + return screen.getByRole("button", { + name: new RegExp(`^${label}:`, "u"), + }); } -function openComposerMenu( - label: '专家角色' | '工作模式' -): HTMLElement { - fireEvent.click(composerMenuTrigger(label)) - return screen.getByRole('menu', { name: label }) +function openComposerMenu(label: "专家角色" | "工作模式"): HTMLElement { + fireEvent.click(composerMenuTrigger(label)); + return screen.getByRole("menu", { name: label }); } function selectComposerOption( - label: '专家角色' | '工作模式', - optionLabel: string + label: "专家角色" | "工作模式", + optionLabel: string, ): void { - const menu = openComposerMenu(label) + const menu = openComposerMenu(label); const option = within(menu) - .getByText(optionLabel, { selector: 'span' }) - .closest('button') + .getByText(optionLabel, { selector: "span" }) + .closest("button"); if (!option) { - throw new Error(`Missing ${label} option: ${optionLabel}`) + throw new Error(`Missing ${label} option: ${optionLabel}`); } - fireEvent.click(option) + fireEvent.click(option); } function selectProjectOption(projectName: string): void { - fireEvent.click(screen.getByRole('button', { name: '当前项目' })) - const menu = screen.getByRole('menu', { name: '当前项目' }) + fireEvent.click(screen.getByRole("button", { name: "当前项目" })); + const menu = screen.getByRole("menu", { name: "当前项目" }); const option = within(menu) - .getByText(projectName, { selector: 'b' }) - .closest('[role="menuitemradio"]') + .getByText(projectName, { selector: "b" }) + .closest('[role="menuitemradio"]'); if (!option) { - throw new Error(`Missing project option: ${projectName}`) + throw new Error(`Missing project option: ${projectName}`); } - fireEvent.click(option) + fireEvent.click(option); } function deferred(): { - promise: Promise - resolve: (value: T) => void - reject: (reason: unknown) => void + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; } { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; } function createBrowserState( conversationId: string, - overrides: Partial = {} + overrides: Partial = {}, ): BrowserLiveState { return { conversationId, - status: 'ready', + status: "ready", sessionActive: true, isLoading: false, canGoBack: false, updatedAt: Date.now(), - ...overrides - } + ...overrides, + }; } function installRemoteProjectsSetting(enabled: boolean): { - updateSettings: NonNullable['updateSettings'] + updateSettings: NonNullable["updateSettings"]; } { let settings: ApplicationSettings = { checkUpdatesOnStartup: false, - updateSource: 'github', - modelDownloadSource: 'modelscope', + updateSource: "github", + modelDownloadSource: "modelscope", localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: enabled, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate', - magicNoteCommentFormat: 'combined' - } + magicNoteCommentMode: "immediate", + magicNoteCommentFormat: "combined", + }; const updateSettings = vi.fn< - NonNullable['updateSettings'] + NonNullable["updateSettings"] >(async (input) => { - settings = { ...settings, ...input } - return { ...settings } - }) + settings = { ...settings, ...input }; + return { ...settings }; + }); api.updates = { getSettings: vi.fn(async () => ({ ...settings })), updateSettings, check: vi.fn(), openReleasePage: vi.fn(), - onResult: vi.fn(() => () => {}) - } - return { updateSettings } + onResult: vi.fn(() => () => {}), + }; + return { updateSettings }; } -describe('App', () => { +describe("App", () => { beforeEach(() => { - localStorage.clear() - delete document.documentElement.dataset.theme - document.documentElement.style.colorScheme = '' - vi.clearAllMocks() - magicTodoStatusChangedListener = undefined + localStorage.clear(); + delete document.documentElement.dataset.theme; + document.documentElement.style.colorScheme = ""; + vi.clearAllMocks(); + magicTodoStatusChangedListener = undefined; vi.mocked(api.magicNotes.getTodoStatus) .mockReset() - .mockResolvedValue({ incompleteCount: 0 }) + .mockResolvedValue({ incompleteCount: 0 }); vi.mocked(api.magicNotes.onTodoStatusChanged) .mockReset() .mockImplementation((listener) => { - magicTodoStatusChangedListener = listener + magicTodoStatusChangedListener = listener; return () => { - magicTodoStatusChangedListener = undefined - } - }) - vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]) - vi.mocked(api.conversations.replace) - .mockReset() - .mockResolvedValue() - vi.mocked(api.conversations.saveLocal) - .mockReset() - .mockResolvedValue() + magicTodoStatusChangedListener = undefined; + }; + }); + vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]); + vi.mocked(api.conversations.replace).mockReset().mockResolvedValue(); + vi.mocked(api.conversations.saveLocal).mockReset().mockResolvedValue(); vi.mocked(api.conversations.branchLocal) .mockReset() .mockImplementation(async (input) => ({ id: crypto.randomUUID(), branch: { sourceConversationId: input.sourceConversationId, - sourceTitle: '新对话' + sourceTitle: "新对话", }, title: input.title, updatedAt: Date.now(), - messages: [] - })) + messages: [], + })); vi.mocked(api.conversations.deleteLocal) .mockReset() - .mockResolvedValue(true) + .mockResolvedValue(true); vi.mocked(api.conversations.onChanged) .mockReset() - .mockReturnValue(() => undefined) + .mockReturnValue(() => undefined); vi.mocked(api.projects.remote.cancelCurrent) .mockReset() - .mockResolvedValue() - remoteProjectSaveProgressListener = undefined + .mockResolvedValue(); + remoteProjectSaveProgressListener = undefined; vi.mocked(api.projects.remote.onSaveProgress) .mockReset() .mockImplementation((listener) => { - remoteProjectSaveProgressListener = listener + remoteProjectSaveProgressListener = listener; return () => { if (remoteProjectSaveProgressListener === listener) { - remoteProjectSaveProgressListener = undefined + remoteProjectSaveProgressListener = undefined; } - } - }) - remoteProjectRecoveryProgressListener = undefined - removeProjectRecoveryListener.mockReset() + }; + }); + remoteProjectRecoveryProgressListener = undefined; + removeProjectRecoveryListener.mockReset(); vi.mocked(api.projects.remote.getRecoverySnapshot) .mockReset() - .mockResolvedValue({ recoveries: [] }) - vi.mocked(api.projects.remote.retryRecovery).mockReset() + .mockResolvedValue({ recoveries: [] }); + vi.mocked(api.projects.remote.retryRecovery).mockReset(); vi.mocked(api.projects.remote.onRecoveryProgress) .mockReset() .mockImplementation((listener) => { - remoteProjectRecoveryProgressListener = listener - return removeProjectRecoveryListener - }) - conversationQueueChangeListener = undefined - conversationQueueDispatchListener = undefined - vi.mocked(api.conversationQueue.list) - .mockReset() - .mockResolvedValue([]) + remoteProjectRecoveryProgressListener = listener; + return removeProjectRecoveryListener; + }); + conversationQueueChangeListener = undefined; + conversationQueueDispatchListener = undefined; + vi.mocked(api.conversationQueue.list).mockReset().mockResolvedValue([]); vi.mocked(api.conversationQueue.enqueueUser) .mockReset() .mockImplementation(async (input) => { const item = { id: crypto.randomUUID(), conversationId: input.conversationId, - source: 'user' as const, + source: "user" as const, label: input.prompt, - createdAt: '2026-07-31T00:00:00.000Z' - } + createdAt: "2026-07-31T00:00:00.000Z", + }; queueMicrotask(() => - conversationQueueDispatchListener?.({ item, input }) - ) - return item - }) - vi.mocked(api.conversationQueue.remove) - .mockReset() - .mockResolvedValue() + conversationQueueDispatchListener?.({ item, input }), + ); + return item; + }); + vi.mocked(api.conversationQueue.remove).mockReset().mockResolvedValue(); vi.mocked(api.conversationQueue.interruptAndRun) .mockReset() - .mockResolvedValue() + .mockResolvedValue(); vi.mocked(api.conversationQueue.releaseUser) .mockReset() - .mockResolvedValue() - vi.mocked(api.conversationQueue.ready) - .mockReset() - .mockResolvedValue() + .mockResolvedValue(); + vi.mocked(api.conversationQueue.ready).mockReset().mockResolvedValue(); vi.mocked(api.conversationQueue.onChanged) .mockReset() .mockImplementation((listener) => { - conversationQueueChangeListener = listener + conversationQueueChangeListener = listener; return () => { - conversationQueueChangeListener = undefined - } - }) + conversationQueueChangeListener = undefined; + }; + }); vi.mocked(api.conversationQueue.onDispatch) .mockReset() .mockImplementation((listener) => { - conversationQueueDispatchListener = listener + conversationQueueDispatchListener = listener; return () => { - conversationQueueDispatchListener = undefined - } - }) - vi.mocked(api.tasks.list).mockReset().mockResolvedValue([]) - vi.mocked(api.activityHistory.get) - .mockReset() - .mockResolvedValue({ - records: [], - legacyHistoryMayBeIncomplete: false - }) - vi.mocked(api.activityHistory.replace) - .mockReset() - .mockResolvedValue() - vi.mocked(api.schedules.list).mockReset().mockResolvedValue([]) - api.channels = undefined - api.updates = undefined - newConversationListener = undefined - beforeQuitListener = undefined - browserListener = undefined - fileSelectionProgressListener = undefined - maximizedChangedListener = undefined + conversationQueueDispatchListener = undefined; + }; + }); + vi.mocked(api.tasks.list).mockReset().mockResolvedValue([]); + vi.mocked(api.activityHistory.get).mockReset().mockResolvedValue({ + records: [], + legacyHistoryMayBeIncomplete: false, + }); + vi.mocked(api.activityHistory.replace).mockReset().mockResolvedValue(); + vi.mocked(api.schedules.list).mockReset().mockResolvedValue([]); + api.channels = undefined; + api.updates = undefined; + newConversationListener = undefined; + beforeQuitListener = undefined; + browserListener = undefined; + fileSelectionProgressListener = undefined; + maximizedChangedListener = undefined; speechRecognitionMocks.startPcmRecording.mockResolvedValue({ result: Promise.resolve({ audio: new Float32Array([0, 0.25, -0.25]).buffer, - sampleRate: 16_000 + sampleRate: 16_000, }), stop: vi.fn(), - cancel: vi.fn() - }) - Object.defineProperty(navigator, 'mediaDevices', { + cancel: vi.fn(), + }); + Object.defineProperty(navigator, "mediaDevices", { configurable: true, - value: { getUserMedia: vi.fn() } - }) - Object.defineProperty(window, 'AudioContext', { + value: { getUserMedia: vi.fn() }, + }); + Object.defineProperty(window, "AudioContext", { configurable: true, - value: class AudioContextMock {} - }) + value: class AudioContextMock {}, + }); vi.mocked(api.speech!.transcribe).mockResolvedValue({ - text: '本地语音结果' - }) - vi.mocked(api.speech!.cancel).mockResolvedValue(true) + text: "本地语音结果", + }); + vi.mocked(api.speech!.cancel).mockResolvedValue(true); vi.mocked(api.agent.getStatus).mockResolvedValue({ - id: 'model', - label: 'sonnet-5', + id: "model", + label: "sonnet-5", available: true, supportsToolExecution: true, - detail: 'Ready' - }) + detail: "Ready", + }); vi.stubGlobal( - 'requestIdleCallback', - vi.fn(() => 1) - ) - vi.stubGlobal('cancelIdleCallback', vi.fn()) - Object.defineProperty(window, 'goodbuddy', { + "requestIdleCallback", + vi.fn(() => 1), + ); + vi.stubGlobal("cancelIdleCallback", vi.fn()); + Object.defineProperty(window, "goodbuddy", { configurable: true, - value: api - }) - }) + value: api, + }); + }); afterEach(() => { - cleanup() - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); - it('provides custom minimize, maximize, and close controls', async () => { - const { unmount } = render() + it("provides custom minimize, maximize, and close controls", async () => { + const { unmount } = render(); - fireEvent.click(screen.getByLabelText('最小化窗口')) - fireEvent.click(screen.getByLabelText('最大化窗口')) - fireEvent.click(screen.getByLabelText('关闭窗口')) + fireEvent.click(screen.getByLabelText("最小化窗口")); + fireEvent.click(screen.getByLabelText("最大化窗口")); + fireEvent.click(screen.getByLabelText("关闭窗口")); await waitFor(() => { - expect(api.app.minimize).toHaveBeenCalledOnce() - expect(api.app.toggleMaximize).toHaveBeenCalledOnce() - expect(api.app.close).toHaveBeenCalledOnce() - }) - act(() => maximizedChangedListener?.(true)) - expect(await screen.findByLabelText('还原窗口')).toBeInTheDocument() - act(() => maximizedChangedListener?.(false)) - expect(screen.getByLabelText('最大化窗口')).toBeInTheDocument() + expect(api.app.minimize).toHaveBeenCalledOnce(); + expect(api.app.toggleMaximize).toHaveBeenCalledOnce(); + expect(api.app.close).toHaveBeenCalledOnce(); + }); + act(() => maximizedChangedListener?.(true)); + expect(await screen.findByLabelText("还原窗口")).toBeInTheDocument(); + act(() => maximizedChangedListener?.(false)); + expect(screen.getByLabelText("最大化窗口")).toBeInTheDocument(); - unmount() - expect(removeMaximizedChangedListener).toHaveBeenCalledOnce() - }) + unmount(); + expect(removeMaximizedChangedListener).toHaveBeenCalledOnce(); + }); - it('renders the core app shell in English', async () => { - await changeUiLocale('en-US') + it("renders the core app shell in English", async () => { + await changeUiLocale("en-US"); try { - render() + render(); expect( - await screen.findByText('New conversation', { - selector: '.new-chat span' - }) - ).toBeInTheDocument() + await screen.findByText("New conversation", { + selector: ".new-chat span", + }), + ).toBeInTheDocument(); expect( - screen.getByRole('navigation', { - name: 'Main navigation' - }) - ).toBeInTheDocument() + screen.getByRole("navigation", { + name: "Main navigation", + }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); + expect(screen.queryByText("Desktop workspace")).not.toBeInTheDocument(); + expect(screen.queryByText("GOODBUDDY WORKSPACE")).not.toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Chat' }) - ).toBeInTheDocument() - expect(screen.queryByText('Desktop workspace')).not.toBeInTheDocument() - expect(screen.queryByText('GOODBUDDY WORKSPACE')).not.toBeInTheDocument() - expect( - screen.getByRole('heading', { + screen.getByRole("heading", { level: 1, - name: 'Conversation' - }) - ).toBeInTheDocument() + name: "Conversation", + }), + ).toBeInTheDocument(); expect( - screen.getByRole('heading', { + screen.getByRole("heading", { level: 2, - name: 'What would you like to accomplish today?' - }) - ).toBeInTheDocument() + name: "What would you like to accomplish today?", + }), + ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Current project' }) - ).toHaveTextContent('Default project') - expect(screen.getByText('Project: Default project')).toHaveClass( - 'scope-badge' - ) + screen.getByRole("button", { name: "Current project" }), + ).toHaveTextContent("Default project"); + expect(screen.getByText("Project: Default project")).toHaveClass( + "scope-badge", + ); expect( screen.getByText( - /Hi, I’m GoodBuddy\. Ask me a question, add local files/u - ) - ).toBeInTheDocument() - expect( - screen.getByLabelText('Message GoodBuddy') - ).toHaveAttribute( - 'placeholder', - 'Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text' - ) - expect( - screen.getByLabelText('Message GoodBuddy') - ).toHaveAttribute( - 'title', - 'Enter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text' - ) + /Hi, I’m GoodBuddy\. Ask me a question, add local files/u, + ), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute( + "placeholder", + "Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text", + ); + expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute( + "title", + "Enter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text", + ); } finally { - cleanup() - await changeUiLocale('zh-CN') + cleanup(); + await changeUiLocale("zh-CN"); } - }) + }); - it('localizes untouched project labels but keeps settings and destructive values raw', async () => { + it("localizes untouched project labels but keeps settings and destructive values raw", async () => { const secondProject = { ...project, - id: '00000000-0000-4000-8000-000000000102', - name: 'Second project', - description: 'Another workspace', - rootPath: 'C:\\Second', + id: "00000000-0000-4000-8000-000000000102", + name: "Second project", + description: "Another workspace", + rootPath: "C:\\Second", executionSpace: { - kind: 'local' as const, - rootPath: 'C:\\Second' - } - } + kind: "local" as const, + rootPath: "C:\\Second", + }, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - secondProject - ]) + secondProject, + ]); vi.mocked(api.tasks.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000901', + id: "00000000-0000-4000-8000-000000000901", projectId, - title: 'Seed project task', - instructions: 'Verify the project label', - origin: 'schedule', - status: 'queued', - workMode: 'ask', - createdAt: '2026-07-31T01:00:00.000Z' - } - ]) - await changeUiLocale('en-US') + title: "Seed project task", + instructions: "Verify the project label", + origin: "schedule", + status: "queued", + workMode: "ask", + createdAt: "2026-07-31T01:00:00.000Z", + }, + ]); + await changeUiLocale("en-US"); try { - render() + render(); - const projectTrigger = await screen.findByRole('button', { - name: 'Current project' - }) - expect(projectTrigger).toHaveTextContent('Default project') - fireEvent.click(projectTrigger) + const projectTrigger = await screen.findByRole("button", { + name: "Current project", + }); + expect(projectTrigger).toHaveTextContent("Default project"); + fireEvent.click(projectTrigger); expect( - within( - screen.getByRole('menu', { name: 'Current project' }) - ).getByText('Default project', { selector: 'b' }) - ).toBeInTheDocument() - fireEvent.click(projectTrigger) + within(screen.getByRole("menu", { name: "Current project" })).getByText( + "Default project", + { selector: "b" }, + ), + ).toBeInTheDocument(); + fireEvent.click(projectTrigger); + + fireEvent.click(screen.getByLabelText("Toggle assistant workspace")); + fireEvent.click(await screen.findByRole("tab", { name: "Task center" })); + const assistantSidebar = screen.getByLabelText("Assistant workspace"); + expect( + within(assistantSidebar).getByText("Project: Default project"), + ).toBeInTheDocument(); + const taskIndexHeading = within(assistantSidebar).getByRole("heading", { + name: "Task index", + }); + const newTaskButton = within(assistantSidebar).getByRole("button", { + name: "New task", + }); + expect(taskIndexHeading.parentElement).toContainElement(newTaskButton); + fireEvent.click(newTaskButton); + const taskDialog = screen.getByRole("dialog", { + name: "New custom task", + }); + expect( + within(taskDialog).getByText("Default project"), + ).toBeInTheDocument(); + expect( + within(taskDialog).getByRole("button", { + name: "Current conversation", + }), + ).toHaveAttribute("aria-pressed", "true"); + fireEvent.click( + within(taskDialog).getByRole("button", { + name: "Close new custom task", + }), + ); + fireEvent.click(screen.getByLabelText("Toggle assistant workspace")); + + fireEvent.click(screen.getByLabelText("Project settings")); + const settingsDialog = screen.getByRole("dialog", { + name: "Project settings", + }); + expect(within(settingsDialog).getByLabelText("Name")).toHaveValue( + builtInDefaultProjectSeedName, + ); + expect(within(settingsDialog).getByLabelText("Description")).toHaveValue( + builtInDefaultProjectSeedDescription, + ); fireEvent.click( - screen.getByLabelText('Toggle assistant workspace') - ) - fireEvent.click( - await screen.findByRole('tab', { name: 'Task center' }) - ) - const assistantSidebar = screen.getByLabelText( - 'Assistant workspace' - ) - expect( - within(assistantSidebar).getByText('Project: Default project') - ).toBeInTheDocument() - const taskIndexHeading = within(assistantSidebar).getByRole( - 'heading', - { name: 'Task index' } - ) - const newTaskButton = within(assistantSidebar).getByRole( - 'button', - { name: 'New task' } - ) - expect(taskIndexHeading.parentElement).toContainElement( - newTaskButton - ) - fireEvent.click(newTaskButton) - const taskDialog = screen.getByRole('dialog', { - name: 'New custom task' - }) - expect( - within(taskDialog).getByText('Default project') - ).toBeInTheDocument() - expect( - within(taskDialog).getByRole('button', { - name: 'Current conversation' - }) - ).toHaveAttribute('aria-pressed', 'true') - fireEvent.click( - within(taskDialog).getByRole('button', { - name: 'Close new custom task' - }) - ) - fireEvent.click( - screen.getByLabelText('Toggle assistant workspace') - ) - - fireEvent.click(screen.getByLabelText('Project settings')) - const settingsDialog = screen.getByRole('dialog', { - name: 'Project settings' - }) - expect( - within(settingsDialog).getByLabelText('Name') - ).toHaveValue(builtInDefaultProjectSeedName) - expect( - within(settingsDialog).getByLabelText('Description') - ).toHaveValue(builtInDefaultProjectSeedDescription) - - fireEvent.click( - within(settingsDialog).getByRole('button', { - name: 'Delete project' - }) - ) + within(settingsDialog).getByRole("button", { + name: "Delete project", + }), + ); const confirmation = within(settingsDialog).getByLabelText( - `Enter “${builtInDefaultProjectSeedName}” to confirm deletion` - ) - const deleteButton = within(settingsDialog).getByRole('button', { - name: 'Permanently delete project' - }) + `Enter “${builtInDefaultProjectSeedName}” to confirm deletion`, + ); + const deleteButton = within(settingsDialog).getByRole("button", { + name: "Permanently delete project", + }); fireEvent.change(confirmation, { - target: { value: 'Default project' } - }) - expect(deleteButton).toBeDisabled() + target: { value: "Default project" }, + }); + expect(deleteButton).toBeDisabled(); fireEvent.change(confirmation, { - target: { value: builtInDefaultProjectSeedName } - }) - expect(deleteButton).toBeEnabled() + target: { value: builtInDefaultProjectSeedName }, + }); + expect(deleteButton).toBeEnabled(); fireEvent.click( - within(settingsDialog).getByRole('button', { - name: 'Cancel deletion' - }) - ) + within(settingsDialog).getByRole("button", { + name: "Cancel deletion", + }), + ); fireEvent.click( - within(settingsDialog).getByRole('button', { - name: 'Save project' - }) - ) + within(settingsDialog).getByRole("button", { + name: "Save project", + }), + ); await waitFor(() => expect(api.projects.update).toHaveBeenCalledWith( projectId, expect.objectContaining({ name: builtInDefaultProjectSeedName, - description: builtInDefaultProjectSeedDescription - }) - ) - ) + description: builtInDefaultProjectSeedDescription, + }), + ), + ); expect( - screen.getByRole('button', { name: 'Current project' }) - ).toHaveTextContent('Default project') - expect(api.projects.delete).not.toHaveBeenCalled() + screen.getByRole("button", { name: "Current project" }), + ).toHaveTextContent("Default project"); + expect(api.projects.delete).not.toHaveBeenCalled(); } finally { - cleanup() - await changeUiLocale('zh-CN') + cleanup(); + await changeUiLocale("zh-CN"); } - }) + }); - it('renders localized workspace branding in Chinese', async () => { - render() + it("renders localized workspace branding in Chinese", async () => { + render(); - await screen.findByRole('heading', { - name: '今天想一起完成什么?' - }) - const runtimeStatus = document.querySelector( - '.runtime-status' - ) - expect(runtimeStatus).not.toBeNull() + await screen.findByRole("heading", { + name: "今天想一起完成什么?", + }); + const runtimeStatus = + document.querySelector(".runtime-status"); + expect(runtimeStatus).not.toBeNull(); + expect(within(runtimeStatus!).queryByText("就绪")).not.toBeInTheDocument(); + expect(runtimeStatus).toHaveClass("runtime-status--ready"); expect( - within(runtimeStatus!).queryByText('就绪') - ).not.toBeInTheDocument() - expect(runtimeStatus).toHaveClass('runtime-status--ready') - expect( - runtimeStatus?.querySelector('.runtime-status__dot') - ).toBeInTheDocument() + runtimeStatus?.querySelector(".runtime-status__dot"), + ).toBeInTheDocument(); expect(runtimeStatus).toHaveAttribute( - 'aria-describedby', - 'topbar-runtime-detail' - ) - expect(screen.getByText('Ready', { selector: '.sr-only' })) - .toHaveAttribute('id', 'topbar-runtime-detail') + "aria-describedby", + "topbar-runtime-detail", + ); + expect(screen.getByText("Ready", { selector: ".sr-only" })).toHaveAttribute( + "id", + "topbar-runtime-detail", + ); expect( screen.getByText( - /你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件/u - ) - ).toBeInTheDocument() - expect(screen.getByText('智能工作台')).toBeInTheDocument() - expect(screen.queryByText('GOODBUDDY 工作台')).not.toBeInTheDocument() + /你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件/u, + ), + ).toBeInTheDocument(); + expect(screen.getByText("智能工作台")).toBeInTheDocument(); + expect(screen.queryByText("GOODBUDDY 工作台")).not.toBeInTheDocument(); expect( - document.querySelector('.composer__runtime-toolbar') - ).not.toBeInTheDocument() - }) + document.querySelector(".composer__runtime-toolbar"), + ).not.toBeInTheDocument(); + }); - it('uses a compact color indicator while connecting to a Runtime', () => { - render() - const connectingStatus = document.querySelector( - '.runtime-status' - ) - expect(connectingStatus).toHaveClass('runtime-status--connecting') - expect(within(connectingStatus!).queryByText('连接中')) - .not.toBeInTheDocument() - }) + it("uses a compact color indicator while connecting to a Runtime", () => { + render(); + const connectingStatus = + document.querySelector(".runtime-status"); + expect(connectingStatus).toHaveClass("runtime-status--connecting"); + expect( + within(connectingStatus!).queryByText("连接中"), + ).not.toBeInTheDocument(); + }); - it('loads customized branding into the primary sidebar', async () => { + it("loads customized branding into the primary sidebar", async () => { expect( saveBrandingPreferences({ - name: '研发伙伴', - subtitleZhCN: '团队工作台', - subtitleEnUS: 'Team Workspace', - logoDataUrl: 'data:image/png;base64,iVBORw0KGgo=' - }) - ).toBe(true) + name: "研发伙伴", + subtitleZhCN: "团队工作台", + subtitleEnUS: "Team Workspace", + logoDataUrl: "data:image/png;base64,iVBORw0KGgo=", + }), + ).toBe(true); - render() + render(); - await screen.findByRole('heading', { - name: '今天想一起完成什么?' - }) - const brand = document.querySelector('.brand') - expect(brand).not.toBeNull() - expect(within(brand as HTMLElement).getByText('研发伙伴')) - .toBeInTheDocument() - expect(within(brand as HTMLElement).getByText('团队工作台')) - .toBeInTheDocument() - expect(brand?.querySelector('img')).toHaveAttribute( - 'src', - 'data:image/png;base64,iVBORw0KGgo=' - ) - }) + await screen.findByRole("heading", { + name: "今天想一起完成什么?", + }); + const brand = document.querySelector(".brand"); + expect(brand).not.toBeNull(); + expect( + within(brand as HTMLElement).getByText("研发伙伴"), + ).toBeInTheDocument(); + expect( + within(brand as HTMLElement).getByText("团队工作台"), + ).toBeInTheDocument(); + expect(brand?.querySelector("img")).toHaveAttribute( + "src", + "data:image/png;base64,iVBORw0KGgo=", + ); + }); - it('renders the unified user and Scheduled Task queue above the Composer', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000951' + it("renders the unified user and Scheduled Task queue above the Composer", async () => { + const conversationId = "00000000-0000-4000-8000-000000000951"; vi.mocked(api.conversations.list).mockResolvedValue([ { id: conversationId, projectId, - title: '排队对话', + title: "排队对话", updatedAt: Date.now(), - messages: [] - } - ]) + messages: [], + }, + ]); vi.mocked(api.conversationQueue.list).mockResolvedValue([ { - id: '00000000-0000-4000-8000-000000000952', + id: "00000000-0000-4000-8000-000000000952", conversationId, - source: 'schedule', - label: '每日汇总', - createdAt: '2026-08-20T09:00:00.000Z' + source: "schedule", + label: "每日汇总", + createdAt: "2026-08-20T09:00:00.000Z", }, { - id: '00000000-0000-4000-8000-000000000953', + id: "00000000-0000-4000-8000-000000000953", conversationId, - source: 'user', - label: '补充说明', - createdAt: '2026-08-20T09:01:00.000Z' - } - ]) + source: "user", + label: "补充说明", + createdAt: "2026-08-20T09:01:00.000Z", + }, + ]); - render() + render(); - const queue = await screen.findByRole('region', { - name: '对话待发送队列' - }) - expect(queue.parentElement).toHaveClass('composer-wrap') - expect(queue.closest('.composer')).toBeNull() - expect(queue.nextElementSibling).toHaveClass('composer') - expect(within(queue).queryByText('待发送(2)')).not.toBeInTheDocument() - expect(within(queue).getByText('每日汇总')).toBeInTheDocument() - expect(within(queue).getByText('补充说明')).toBeInTheDocument() + const queue = await screen.findByRole("region", { + name: "对话待发送队列", + }); + expect(queue.parentElement).toHaveClass("composer-wrap"); + expect(queue.closest(".composer")).toBeNull(); + expect(queue.nextElementSibling).toHaveClass("composer"); + expect(within(queue).queryByText("待发送(2)")).not.toBeInTheDocument(); + expect(within(queue).getByText("每日汇总")).toBeInTheDocument(); + expect(within(queue).getByText("补充说明")).toBeInTheDocument(); fireEvent.click( - within(queue).getByRole('button', { - name: '立即运行“每日汇总”' - }) - ) + within(queue).getByRole("button", { + name: "立即运行“每日汇总”", + }), + ); await waitFor(() => - expect( - api.conversationQueue.interruptAndRun - ).toHaveBeenCalledWith( - '00000000-0000-4000-8000-000000000952' - ) - ) + expect(api.conversationQueue.interruptAndRun).toHaveBeenCalledWith( + "00000000-0000-4000-8000-000000000952", + ), + ); fireEvent.click( - within(queue).getByRole('button', { - name: '从待发送队列删除“补充说明”' - }) - ) + within(queue).getByRole("button", { + name: "从待发送队列删除“补充说明”", + }), + ); await waitFor(() => expect(api.conversationQueue.remove).toHaveBeenCalledWith( - '00000000-0000-4000-8000-000000000953' - ) - ) - }) + "00000000-0000-4000-8000-000000000953", + ), + ); + }); - it('preserves scoped queue updates that arrive during another refresh', async () => { - const firstConversationId = - '00000000-0000-4000-8000-000000000954' - const secondConversationId = - '00000000-0000-4000-8000-000000000955' + it("preserves scoped queue updates that arrive during another refresh", async () => { + const firstConversationId = "00000000-0000-4000-8000-000000000954"; + const secondConversationId = "00000000-0000-4000-8000-000000000955"; vi.mocked(api.conversations.list).mockResolvedValue([ { id: firstConversationId, projectId, - title: '第一条排队对话', + title: "第一条排队对话", updatedAt: Date.now(), - messages: [] + messages: [], }, { id: secondConversationId, projectId, - title: '第二条排队对话', + title: "第二条排队对话", updatedAt: Date.now() - 1, - messages: [] - } - ]) - const firstRefresh = deferred< - Awaited< - ReturnType - > - >() + messages: [], + }, + ]); + const firstRefresh = + deferred>>(); vi.mocked(api.conversationQueue.list).mockImplementation( async (conversationId) => { if (conversationId === firstConversationId) { - return firstRefresh.promise + return firstRefresh.promise; } - return [] - } - ) + return []; + }, + ); - render() - await waitFor(() => - expect(conversationQueueChangeListener).toBeDefined() - ) + render(); + await waitFor(() => expect(conversationQueueChangeListener).toBeDefined()); act(() => { - conversationQueueChangeListener?.(firstConversationId) - }) + conversationQueueChangeListener?.(firstConversationId); + }); await waitFor(() => expect(api.conversationQueue.list).toHaveBeenCalledWith( - firstConversationId - ) - ) + firstConversationId, + ), + ); await act(async () => { - conversationQueueChangeListener?.(secondConversationId) - await new Promise((resolve) => window.setTimeout(resolve, 0)) - }) + conversationQueueChangeListener?.(secondConversationId); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); act(() => { firstRefresh.resolve([ { - id: '00000000-0000-4000-8000-000000000956', + id: "00000000-0000-4000-8000-000000000956", conversationId: firstConversationId, - source: 'user', - label: '不能被后续刷新丢弃', - createdAt: '2026-08-20T09:03:00.000Z' - } - ]) - }) + source: "user", + label: "不能被后续刷新丢弃", + createdAt: "2026-08-20T09:03:00.000Z", + }, + ]); + }); - expect( - await screen.findByText('不能被后续刷新丢弃') - ).toBeInTheDocument() + expect(await screen.findByText("不能被后续刷新丢弃")).toBeInTheDocument(); await waitFor(() => expect(api.conversationQueue.list).toHaveBeenCalledWith( - secondConversationId - ) - ) - }) + secondConversationId, + ), + ); + }); - it('discovers product Tasks through their Conversation without exposing Runs', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000821' - const taskId = '00000000-0000-4000-8000-000000000822' - const scheduleId = - '00000000-0000-4000-8000-000000000823' + it("discovers product Tasks through their Conversation without exposing Runs", async () => { + const conversationId = "00000000-0000-4000-8000-000000000821"; + const taskId = "00000000-0000-4000-8000-000000000822"; + const scheduleId = "00000000-0000-4000-8000-000000000823"; const conversation: ConversationSnapshot = { id: conversationId, projectId, - title: '产品发布讨论', + title: "产品发布讨论", updatedAt: Date.now(), - messages: [] - } + messages: [], + }; const task: AssistantTask = { id: taskId, projectId, conversationId, scheduleId, - title: '每周发布总结', - instructions: '总结本周发布进度', - origin: 'schedule', - status: 'idle', - createdAt: '2026-08-19T00:00:00.000Z' - } + title: "每周发布总结", + instructions: "总结本周发布进度", + origin: "schedule", + status: "idle", + createdAt: "2026-08-19T00:00:00.000Z", + }; const schedule: AssistantSchedule = { id: scheduleId, projectId, @@ -1694,96 +1620,95 @@ describe('App', () => { conversationId, title: task.title, prompt: task.instructions, - workMode: 'execute', - recurrence: 'weekly', - nextRunAt: '2026-08-21T09:00:00.000Z', + workMode: "execute", + recurrence: "weekly", + nextRunAt: "2026-08-21T09:00:00.000Z", enabled: true, createdAt: task.createdAt, - updatedAt: task.createdAt - } - vi.mocked(api.conversations.list).mockResolvedValue([conversation]) - vi.mocked(api.tasks.list).mockResolvedValue([task]) - vi.mocked(api.schedules.list).mockResolvedValue([schedule]) + updatedAt: task.createdAt, + }; + vi.mocked(api.conversations.list).mockResolvedValue([conversation]); + vi.mocked(api.tasks.list).mockResolvedValue([task]); + vi.mocked(api.schedules.list).mockResolvedValue([schedule]); - render() + render(); const toggle = await screen.findByLabelText( - '展开或折叠“产品发布讨论”中的 1 个任务' - ) + "展开或折叠“产品发布讨论”中的 1 个任务", + ); const conversationButton = - toggle.parentElement?.querySelector('.conversation-item') - expect(toggle.nextElementSibling).toBe(conversationButton) + toggle.parentElement?.querySelector(".conversation-item"); + expect(toggle.nextElementSibling).toBe(conversationButton); expect( - conversationButton?.querySelector('.conversation-item__title') - ).toHaveAttribute('title', '产品发布讨论') - expect(screen.queryByText('任务 1')).not.toBeInTheDocument() - fireEvent.click(toggle) - const taskTitle = await screen.findByText('每周发布总结', { - selector: '.conversation-task-child__title' - }) - const taskChild = taskTitle.closest('button') - expect(taskChild).not.toBeNull() + conversationButton?.querySelector(".conversation-item__title"), + ).toHaveAttribute("title", "产品发布讨论"); + expect(screen.queryByText("任务 1")).not.toBeInTheDocument(); + fireEvent.click(toggle); + const taskTitle = await screen.findByText("每周发布总结", { + selector: ".conversation-task-child__title", + }); + const taskChild = taskTitle.closest("button"); + expect(taskChild).not.toBeNull(); + expect(taskChild?.querySelector(".task-status-dot")).toHaveClass( + "task-status-dot--idle", + ); expect( - taskChild?.querySelector('.task-status-dot') - ).toHaveClass('task-status-dot--idle') - expect(taskChild?.querySelector('.lucide-clock-fading')) - .not.toBeInTheDocument() + taskChild?.querySelector(".lucide-clock-fading"), + ).not.toBeInTheDocument(); expect( - taskChild?.querySelector('.conversation-task-child__meta') - ).toHaveTextContent('Execute · 每周 · 空闲') - expect(screen.queryByText('Run')).not.toBeInTheDocument() - fireEvent.click(taskChild!) - const taskRegion = await screen.findByRole('region', { - name: '当前会话的任务' - }) - expect(within(taskRegion).getByText('Execute')).toBeInTheDocument() - }) + taskChild?.querySelector(".conversation-task-child__meta"), + ).toHaveTextContent("Execute · 每周 · 空闲"); + expect(screen.queryByText("Run")).not.toBeInTheDocument(); + fireEvent.click(taskChild!); + const taskRegion = await screen.findByRole("region", { + name: "当前会话的任务", + }); + expect(within(taskRegion).getByText("Execute")).toBeInTheDocument(); + }); - it('keeps completed Conversation Task metadata compact and accessible', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000824' - const scheduleId = - '00000000-0000-4000-8000-000000000828' + it("keeps completed Conversation Task metadata compact and accessible", async () => { + const conversationId = "00000000-0000-4000-8000-000000000824"; + const scheduleId = "00000000-0000-4000-8000-000000000828"; const conversation: ConversationSnapshot = { id: conversationId, projectId, - title: '任务状态会话', + title: "任务状态会话", updatedAt: Date.now(), - messages: [] - } + messages: [], + }; const tasks: AssistantTask[] = [ { - id: '00000000-0000-4000-8000-000000000825', + id: "00000000-0000-4000-8000-000000000825", projectId, conversationId, scheduleId, - title: '归档任务', - instructions: '检查完成状态', - origin: 'schedule', - status: 'completed', - createdAt: '2026-08-19T03:00:00.000Z' + title: "归档任务", + instructions: "检查完成状态", + origin: "schedule", + status: "completed", + createdAt: "2026-08-19T03:00:00.000Z", }, { - id: '00000000-0000-4000-8000-000000000826', + id: "00000000-0000-4000-8000-000000000826", projectId, conversationId, - title: '运行中任务', - instructions: '检查运行状态', - origin: 'schedule', - status: 'running', - createdAt: '2026-08-19T02:00:00.000Z' + title: "运行中任务", + instructions: "检查运行状态", + origin: "schedule", + status: "running", + createdAt: "2026-08-19T02:00:00.000Z", }, { - id: '00000000-0000-4000-8000-000000000827', + id: "00000000-0000-4000-8000-000000000827", projectId, conversationId, - title: '失败任务', - instructions: '检查失败状态', - origin: 'schedule', - status: 'failed', - createdAt: '2026-08-19T01:00:00.000Z' - } - ] + title: "失败任务", + instructions: "检查失败状态", + origin: "schedule", + status: "failed", + createdAt: "2026-08-19T01:00:00.000Z", + }, + ]; const schedule: AssistantSchedule = { id: scheduleId, projectId, @@ -1791,970 +1716,909 @@ describe('App', () => { conversationId, title: tasks[0]!.title, prompt: tasks[0]!.instructions, - workMode: 'ask', - recurrence: 'once', - nextRunAt: '2026-08-19T03:00:00.000Z', + workMode: "ask", + recurrence: "once", + nextRunAt: "2026-08-19T03:00:00.000Z", enabled: false, createdAt: tasks[0]!.createdAt, - updatedAt: tasks[0]!.createdAt - } - vi.mocked(api.conversations.list).mockResolvedValue([ - conversation - ]) - vi.mocked(api.tasks.list).mockResolvedValue(tasks) - vi.mocked(api.schedules.list).mockResolvedValue([schedule]) + updatedAt: tasks[0]!.createdAt, + }; + vi.mocked(api.conversations.list).mockResolvedValue([conversation]); + vi.mocked(api.tasks.list).mockResolvedValue(tasks); + vi.mocked(api.schedules.list).mockResolvedValue([schedule]); - render() + render(); fireEvent.click( - await screen.findByLabelText( - '展开或折叠“任务状态会话”中的 3 个任务' - ) - ) - const completedTitle = await screen.findByText('归档任务', { - selector: '.conversation-task-child__title' - }) - const completedTask = completedTitle.closest('button') + await screen.findByLabelText("展开或折叠“任务状态会话”中的 3 个任务"), + ); + const completedTitle = await screen.findByText("归档任务", { + selector: ".conversation-task-child__title", + }); + const completedTask = completedTitle.closest("button"); const completedStatus = completedTask?.querySelector( - '.conversation-task-child__completed-status' - ) + ".conversation-task-child__completed-status", + ); expect(completedStatus).toHaveClass( - 'task-status-dot', - 'task-status-dot--completed' - ) - expect(completedStatus).toHaveAttribute('title', '已完成') + "task-status-dot", + "task-status-dot--completed", + ); + expect(completedStatus).toHaveAttribute("title", "已完成"); + expect(completedStatus?.querySelector(".lucide-check")).toBeInTheDocument(); + expect(completedTask?.firstElementChild).toBe(completedTitle); + expect(completedTask?.lastElementChild).toBe(completedStatus); + expect(completedTask).toHaveAccessibleName(/已完成/u); expect( - completedStatus?.querySelector('.lucide-check') - ).toBeInTheDocument() - expect(completedTask?.firstElementChild).toBe(completedTitle) - expect(completedTask?.lastElementChild).toBe(completedStatus) - expect(completedTask).toHaveAccessibleName(/已完成/u) + completedTask?.querySelector(".conversation-task-child__meta"), + ).toHaveTextContent("Ask · 仅一次"); expect( - completedTask?.querySelector('.conversation-task-child__meta') - ).toHaveTextContent('Ask · 仅一次') - expect( - completedTask?.querySelector('.conversation-task-child__meta') - ).not.toHaveTextContent('已完成') + completedTask?.querySelector(".conversation-task-child__meta"), + ).not.toHaveTextContent("已完成"); for (const [title, status, label] of [ - ['运行中任务', 'running', '运行中'], - ['失败任务', 'failed', '失败'] + ["运行中任务", "running", "运行中"], + ["失败任务", "failed", "失败"], ] as const) { const taskTitle = await screen.findByText(title, { - selector: '.conversation-task-child__title' - }) - const taskChild = taskTitle.closest('button') - expect( - taskChild?.querySelector('.task-status-dot') - ).toHaveClass(`task-status-dot--${status}`) - expect(taskChild?.firstElementChild).toBe(taskTitle) + selector: ".conversation-task-child__title", + }); + const taskChild = taskTitle.closest("button"); + expect(taskChild?.querySelector(".task-status-dot")).toHaveClass( + `task-status-dot--${status}`, + ); + expect(taskChild?.firstElementChild).toBe(taskTitle); expect(taskChild?.lastElementChild).toBe( - taskChild?.querySelector('.task-status-dot') - ) + taskChild?.querySelector(".task-status-dot"), + ); expect( - taskChild?.querySelector('.conversation-task-child__meta') - ).toHaveTextContent(label) + taskChild?.querySelector(".conversation-task-child__meta"), + ).toHaveTextContent(label); } expect( - document.querySelector('.conversation-task-child__icon') - ).not.toBeInTheDocument() - }) + document.querySelector(".conversation-task-child__icon"), + ).not.toBeInTheDocument(); + }); - it('routes scheduled Task approvals to the associated Conversation', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000831' - const taskId = '00000000-0000-4000-8000-000000000832' + it("routes scheduled Task approvals to the associated Conversation", async () => { + const conversationId = "00000000-0000-4000-8000-000000000831"; + const taskId = "00000000-0000-4000-8000-000000000832"; vi.mocked(api.conversations.list).mockResolvedValue([ { id: conversationId, projectId, - title: '发布审批会话', + title: "发布审批会话", updatedAt: Date.now(), - messages: [] - } - ]) + messages: [], + }, + ]); vi.mocked(api.tasks.list).mockResolvedValue([ { id: taskId, projectId, conversationId, - scheduleId: '00000000-0000-4000-8000-000000000833', - title: '发布任务', - instructions: '更新发布文件', - origin: 'schedule', - status: 'running', - createdAt: '2026-08-19T00:00:00.000Z' - } - ]) - render() - await screen.findAllByText('发布审批会话') - await waitFor(() => expect(api.tasks.list).toHaveBeenCalled()) + scheduleId: "00000000-0000-4000-8000-000000000833", + title: "发布任务", + instructions: "更新发布文件", + origin: "schedule", + status: "running", + createdAt: "2026-08-19T00:00:00.000Z", + }, + ]); + render(); + await screen.findAllByText("发布审批会话"); + await waitFor(() => expect(api.tasks.list).toHaveBeenCalled()); act(() => { agentListener?.({ requestId: taskId, - type: 'approval', - approvalId: '00000000-0000-4000-8000-000000000834', - title: '请求写入工作区', - description: '更新发布文件', - toolName: 'write_file', - argumentSummary: 'release.md', - allowPermanent: false - }) - }) + type: "approval", + approvalId: "00000000-0000-4000-8000-000000000834", + title: "请求写入工作区", + description: "更新发布文件", + toolName: "write_file", + argumentSummary: "release.md", + allowPermanent: false, + }); + }); - expect(await screen.findAllByText('请求写入工作区')) - .not.toHaveLength(0) - expect(screen.getByText('仅此次')).toBeInTheDocument() - expect(screen.getByLabelText('任务结果:发布任务')) - .toBeInTheDocument() - }) + expect(await screen.findAllByText("请求写入工作区")).not.toHaveLength(0); + expect(screen.getByText("仅此次")).toBeInTheDocument(); + expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument(); + }); - it('idle-preloads only the small Heartbeat route at startup', async () => { - render() + it("idle-preloads only the small Heartbeat route at startup", async () => { + render(); expect(window.requestIdleCallback).toHaveBeenCalledWith( expect.any(Function), - { timeout: 2000 } - ) - const idleCallback = vi.mocked(window.requestIdleCallback) - .mock.calls[0]?.[0] + { timeout: 2000 }, + ); + const idleCallback = vi.mocked(window.requestIdleCallback).mock + .calls[0]?.[0]; await act(async () => { idleCallback?.({ didTimeout: false, - timeRemaining: () => 50 - }) - }) - await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1)) + timeRemaining: () => 50, + }); + }); + await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1)); expect(routeModuleLoads).toMatchObject({ activity: 0, knowledge: 0, magicNotes: 0, - settings: 0 - }) - }) + settings: 0, + }); + }); - it('saves branding from Appearance and updates the sidebar immediately', async () => { - render() + it("saves branding from Appearance and updates the sidebar immediately", async () => { + render(); fireEvent.click( - await screen.findByRole('button', { - name: /本地工作区/u - }) - ) - await screen.findByRole( - 'heading', - { name: '设置中心' }, - { timeout: 3000 } - ) - fireEvent.click( - screen.getByRole('tab', { name: '外观' }) - ) - fireEvent.change(screen.getByLabelText('品牌名称'), { - target: { value: '研发伙伴' } - }) - fireEvent.change(screen.getByLabelText('中文副标题'), { - target: { value: '团队工作台' } - }) - fireEvent.click( - screen.getByRole('button', { name: '保存品牌设置' }) - ) + await screen.findByRole("button", { + name: /本地工作区/u, + }), + ); + await screen.findByRole("heading", { name: "设置中心" }, { timeout: 3000 }); + fireEvent.click(screen.getByRole("tab", { name: "外观" })); + fireEvent.change(screen.getByLabelText("品牌名称"), { + target: { value: "研发伙伴" }, + }); + fireEvent.change(screen.getByLabelText("中文副标题"), { + target: { value: "团队工作台" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存品牌设置" })); - expect(await screen.findByText('品牌设置已保存')) - .toBeInTheDocument() - const brand = document.querySelector('.brand') - expect(brand).not.toBeNull() - expect(within(brand as HTMLElement).getByText('研发伙伴')) - .toBeInTheDocument() - expect(within(brand as HTMLElement).getByText('团队工作台')) - .toBeInTheDocument() + expect(await screen.findByText("品牌设置已保存")).toBeInTheDocument(); + const brand = document.querySelector(".brand"); + expect(brand).not.toBeNull(); + expect( + within(brand as HTMLElement).getByText("研发伙伴"), + ).toBeInTheDocument(); + expect( + within(brand as HTMLElement).getByText("团队工作台"), + ).toBeInTheDocument(); expect(loadBrandingPreferences()).toMatchObject({ - name: '研发伙伴', - subtitleZhCN: '团队工作台' - }) - }) + name: "研发伙伴", + subtitleZhCN: "团队工作台", + }); + }); - it('preloads a heavy route once across repeated pointer and focus intent', async () => { - render() - const activity = await screen.findByRole('button', { - name: '运行记录' - }) + it("preloads a heavy route once across repeated pointer and focus intent", async () => { + render(); + const activity = await screen.findByRole("button", { + name: "运行记录", + }); - fireEvent.pointerEnter(activity) - fireEvent.focus(activity) - fireEvent.pointerEnter(activity) + fireEvent.pointerEnter(activity); + fireEvent.focus(activity); + fireEvent.pointerEnter(activity); - await waitFor(() => expect(routeModuleLoads.activity).toBe(1)) - }) + await waitFor(() => expect(routeModuleLoads.activity).toBe(1)); + }); - it('waits for project bootstrap before project-scoped startup loads', async () => { - const projects = deferred<(typeof project)[]>() - vi.mocked(api.projects.list).mockImplementationOnce( - () => projects.promise - ) + it("waits for project bootstrap before project-scoped startup loads", async () => { + const projects = deferred<(typeof project)[]>(); + vi.mocked(api.projects.list).mockImplementationOnce(() => projects.promise); - render() + render(); - expect(api.projects.list).toHaveBeenCalledOnce() - expect(api.memory.list).not.toHaveBeenCalled() - expect(api.schedules.list).not.toHaveBeenCalled() - expect(api.heartbeats.list).not.toHaveBeenCalled() + expect(api.projects.list).toHaveBeenCalledOnce(); + expect(api.memory.list).not.toHaveBeenCalled(); + expect(api.schedules.list).not.toHaveBeenCalled(); + expect(api.heartbeats.list).not.toHaveBeenCalled(); - await act(async () => projects.resolve([project])) + await act(async () => projects.resolve([project])); await waitFor(() => { - expect(api.memory.list).toHaveBeenCalledTimes(2) - expect(api.memory.list).toHaveBeenCalledWith(projectId) - expect(api.memory.list).toHaveBeenCalledWith() - expect(api.schedules.list).toHaveBeenCalledOnce() - expect(api.schedules.list).toHaveBeenCalledWith() - expect(api.heartbeats.list).toHaveBeenCalledOnce() - }) - }) + expect(api.memory.list).toHaveBeenCalledTimes(2); + expect(api.memory.list).toHaveBeenCalledWith(projectId); + expect(api.memory.list).toHaveBeenCalledWith(); + expect(api.schedules.list).toHaveBeenCalledOnce(); + expect(api.schedules.list).toHaveBeenCalledWith(); + expect(api.heartbeats.list).toHaveBeenCalledOnce(); + }); + }); - it('shows an accessible fallback while a lazy route loads', async () => { - lazyRouteMocks.suspendKnowledgeRoute() + it("shows an accessible fallback while a lazy route loads", async () => { + lazyRouteMocks.suspendKnowledgeRoute(); try { - render() + render(); - fireEvent.click( - await screen.findByRole('button', { name: '知识库' }) - ) + fireEvent.click(await screen.findByRole("button", { name: "知识库" })); - const loading = screen.getByRole('status', { - name: '正在加载页面…' - }) - expect(loading).toHaveAttribute('aria-live', 'polite') - expect(loading).toHaveAttribute('aria-busy', 'true') - await act(async () => lazyRouteMocks.releaseKnowledgeRoute()) + const loading = screen.getByRole("status", { + name: "正在加载页面…", + }); + expect(loading).toHaveAttribute("aria-live", "polite"); + expect(loading).toHaveAttribute("aria-busy", "true"); + await act(async () => lazyRouteMocks.releaseKnowledgeRoute()); expect( await screen.findByRole( - 'heading', + "heading", { level: 1, - name: '知识库' + name: "知识库", }, - { timeout: 3000 } - ) - ).toBeInTheDocument() + { timeout: 3000 }, + ), + ).toBeInTheDocument(); expect( - screen.queryByRole('status', { name: '正在加载页面…' }) - ).not.toBeInTheDocument() + screen.queryByRole("status", { name: "正在加载页面…" }), + ).not.toBeInTheDocument(); } finally { - lazyRouteMocks.releaseKnowledgeRoute() + lazyRouteMocks.releaseKnowledgeRoute(); } - }) + }); - it('keeps a recently visited workspace page mounted', async () => { - render() - fireEvent.click( - await screen.findByRole('button', { name: '知识库' }) - ) - const heading = await screen.findByRole('heading', { + it("keeps a recently visited workspace page mounted", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "知识库" })); + const heading = await screen.findByRole("heading", { level: 1, - name: '知识库' - }) - const route = heading.closest('[data-route="knowledge"]') - expect(route).not.toHaveAttribute('hidden') + name: "知识库", + }); + const route = heading.closest('[data-route="knowledge"]'); + expect(route).not.toHaveAttribute("hidden"); - fireEvent.click(screen.getByRole('button', { name: '对话' })) - expect(heading).toBeInTheDocument() - expect(route).toHaveAttribute('hidden') + fireEvent.click(screen.getByRole("button", { name: "对话" })); + expect(heading).toBeInTheDocument(); + expect(route).toHaveAttribute("hidden"); - fireEvent.click(screen.getByRole('button', { name: '知识库' })) + fireEvent.click(screen.getByRole("button", { name: "知识库" })); expect( - await screen.findByRole('heading', { + await screen.findByRole("heading", { level: 1, - name: '知识库' - }) - ).toBe(heading) - expect(route).not.toHaveAttribute('hidden') - }) + name: "知识库", + }), + ).toBe(heading); + expect(route).not.toHaveAttribute("hidden"); + }); - it('enforces the workspace KeepAlive cap during rapid visits', async () => { - let now = 2_000_000 - vi.spyOn(Date, 'now').mockImplementation(() => ++now) - render() + it("enforces the workspace KeepAlive cap during rapid visits", async () => { + let now = 2_000_000; + vi.spyOn(Date, "now").mockImplementation(() => ++now); + render(); - fireEvent.click( - await screen.findByRole('button', { name: '知识库' }) - ) - await screen.findByRole('heading', { name: '知识库' }) - fireEvent.click(screen.getByRole('button', { name: '智能心跳' })) - await screen.findByRole('heading', { name: '智能心跳' }) - fireEvent.click(screen.getByRole('button', { name: '运行记录' })) - await screen.findByRole('heading', { name: '运行记录' }) - fireEvent.click(screen.getByRole('button', { name: '对话' })) - fireEvent.click( - screen.getByRole('button', { name: /本地工作区/u }) - ) - await screen.findByRole('heading', { name: '设置中心' }) + fireEvent.click(await screen.findByRole("button", { name: "知识库" })); + await screen.findByRole("heading", { name: "知识库" }); + fireEvent.click(screen.getByRole("button", { name: "智能心跳" })); + await screen.findByRole("heading", { name: "智能心跳" }); + fireEvent.click(screen.getByRole("button", { name: "运行记录" })); + await screen.findByRole("heading", { name: "运行记录" }); + fireEvent.click(screen.getByRole("button", { name: "对话" })); + fireEvent.click(screen.getByRole("button", { name: /本地工作区/u })); + await screen.findByRole("heading", { name: "设置中心" }); + expect(document.querySelectorAll(".workspace-route-cache")).toHaveLength(4); expect( - document.querySelectorAll('.workspace-route-cache') - ).toHaveLength(4) - expect( - document.querySelector('[data-route="knowledge"]') - ).not.toBeInTheDocument() - }) + document.querySelector('[data-route="knowledge"]'), + ).not.toBeInTheDocument(); + }); - it('preserves title, message, and project filtering with deferred search', async () => { + it("preserves title, message, and project filtering with deferred search", async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000411', + id: "00000000-0000-4000-8000-000000000411", projectId, - title: '标题里的 Alpha', + title: "标题里的 Alpha", updatedAt: 1_775_000_000_003, messages: [ { - id: '00000000-0000-4000-8000-000000000412', - role: 'assistant', - content: '普通正文', + id: "00000000-0000-4000-8000-000000000412", + role: "assistant", + content: "普通正文", createdAt: 1_775_000_000_003, - state: 'complete' - } - ] + state: "complete", + }, + ], }, { - id: '00000000-0000-4000-8000-000000000413', + id: "00000000-0000-4000-8000-000000000413", projectId, - title: '正文命中的会话', + title: "正文命中的会话", updatedAt: 1_775_000_000_002, messages: [ { - id: '00000000-0000-4000-8000-000000000414', - role: 'user', - content: '这里包含 Beta Needle', + id: "00000000-0000-4000-8000-000000000414", + role: "user", + content: "这里包含 Beta Needle", createdAt: 1_775_000_000_002, - state: 'complete' - } - ] + state: "complete", + }, + ], }, { - id: '00000000-0000-4000-8000-000000000415', - projectId: '00000000-0000-4000-8000-000000000999', - title: '其他项目里的 Alpha', + id: "00000000-0000-4000-8000-000000000415", + projectId: "00000000-0000-4000-8000-000000000999", + title: "其他项目里的 Alpha", updatedAt: 1_775_000_000_001, messages: [ { - id: '00000000-0000-4000-8000-000000000416', - role: 'user', - content: 'Beta Needle', + id: "00000000-0000-4000-8000-000000000416", + role: "user", + content: "Beta Needle", createdAt: 1_775_000_000_001, - state: 'complete' - } - ] - } - ]) - const { container } = render() - const search = await screen.findByLabelText('搜索对话') + state: "complete", + }, + ], + }, + ]); + const { container } = render(); + const search = await screen.findByLabelText("搜索对话"); const conversationList = - container.querySelector('.conversation-list') + container.querySelector(".conversation-list"); if (!conversationList) { - throw new Error('Missing conversation list') + throw new Error("Missing conversation list"); } expect( - await within(conversationList).findByText('标题里的 Alpha') - ).toBeInTheDocument() + await within(conversationList).findByText("标题里的 Alpha"), + ).toBeInTheDocument(); expect( - within(conversationList).queryByText('其他项目里的 Alpha') - ).not.toBeInTheDocument() + within(conversationList).queryByText("其他项目里的 Alpha"), + ).not.toBeInTheDocument(); - fireEvent.change(search, { target: { value: 'beta needle' } }) - expect(search).toHaveValue('beta needle') + fireEvent.change(search, { target: { value: "beta needle" } }); + expect(search).toHaveValue("beta needle"); await waitFor(() => { expect( - within(conversationList).getByText('正文命中的会话') - ).toBeInTheDocument() + within(conversationList).getByText("正文命中的会话"), + ).toBeInTheDocument(); expect( - within(conversationList).queryByText('标题里的 Alpha') - ).not.toBeInTheDocument() - }) + within(conversationList).queryByText("标题里的 Alpha"), + ).not.toBeInTheDocument(); + }); - fireEvent.change(search, { target: { value: 'ALPHA' } }) + fireEvent.change(search, { target: { value: "ALPHA" } }); await waitFor(() => { expect( - within(conversationList).getByText('标题里的 Alpha') - ).toBeInTheDocument() + within(conversationList).getByText("标题里的 Alpha"), + ).toBeInTheDocument(); expect( - within(conversationList).queryByText('正文命中的会话') - ).not.toBeInTheDocument() + within(conversationList).queryByText("正文命中的会话"), + ).not.toBeInTheDocument(); expect( - within(conversationList).queryByText('其他项目里的 Alpha') - ).not.toBeInTheDocument() - }) + within(conversationList).queryByText("其他项目里的 Alpha"), + ).not.toBeInTheDocument(); + }); - fireEvent.change(search, { target: { value: 'missing topic' } }) + fireEvent.change(search, { target: { value: "missing topic" } }); expect( - await within(conversationList).findByText('没有匹配的对话') - ).toBeInTheDocument() + await within(conversationList).findByText("没有匹配的对话"), + ).toBeInTheDocument(); expect( within(conversationList).getByText( - '请修改搜索关键词,或清除搜索后查看全部会话。' - ) - ).toBeInTheDocument() + "请修改搜索关键词,或清除搜索后查看全部会话。", + ), + ).toBeInTheDocument(); fireEvent.click( - within(conversationList).getByRole('button', { - name: '清除搜索' - }) - ) - expect(search).toHaveValue('') + within(conversationList).getByRole("button", { + name: "清除搜索", + }), + ); + expect(search).toHaveValue(""); expect( - await within(conversationList).findByText('标题里的 Alpha') - ).toBeInTheDocument() - }) + await within(conversationList).findByText("标题里的 Alpha"), + ).toBeInTheDocument(); + }); - it('keeps Settings open when the interface language changes', async () => { + it("keeps Settings open when the interface language changes", async () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), updateSettings: vi.fn(), check: vi.fn(), openReleasePage: vi.fn(), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; try { render( - - ) + , + ); fireEvent.click( - await screen.findByRole('button', { - name: /本地工作区/u - }) - ) + await screen.findByRole("button", { + name: /本地工作区/u, + }), + ); await screen.findByRole( - 'heading', - { name: '设置中心' }, - { timeout: 3000 } - ) - fireEvent.click( - screen.getByRole('tab', { name: '外观' }) - ) - const projectsList = vi.mocked(api.projects.list) - const expertsList = vi.mocked(api.experts.list) - const tasksList = vi.mocked(api.tasks.list) + "heading", + { name: "设置中心" }, + { timeout: 3000 }, + ); + fireEvent.click(screen.getByRole("tab", { name: "外观" })); + const projectsList = vi.mocked(api.projects.list); + const expertsList = vi.mocked(api.experts.list); + const tasksList = vi.mocked(api.tasks.list); await waitFor(() => { - expect(projectsList).toHaveBeenCalled() - expect(expertsList).toHaveBeenCalled() - expect(tasksList).toHaveBeenCalled() - }) + expect(projectsList).toHaveBeenCalled(); + expect(expertsList).toHaveBeenCalled(); + expect(tasksList).toHaveBeenCalled(); + }); const loadCounts = { projects: projectsList.mock.calls.length, experts: expertsList.mock.calls.length, - tasks: tasksList.mock.calls.length - } + tasks: tasksList.mock.calls.length, + }; fireEvent.click( - screen.getByRole('radio', { - name: /^English/u - }) - ) + screen.getByRole("radio", { + name: /^English/u, + }), + ); expect( - await screen.findByRole('region', { - name: 'Settings' - }) - ).toBeInTheDocument() + await screen.findByRole("region", { + name: "Settings", + }), + ).toBeInTheDocument(); expect( - screen.getByRole('heading', { + screen.getByRole("heading", { level: 1, - name: 'Settings' - }) - ).toBeInTheDocument() - expect(api.updates.getSettings).toHaveBeenCalledOnce() + name: "Settings", + }), + ).toBeInTheDocument(); + expect(api.updates.getSettings).toHaveBeenCalledOnce(); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) - }) - expect(projectsList).toHaveBeenCalledTimes(loadCounts.projects) - expect(expertsList).toHaveBeenCalledTimes(loadCounts.experts) - expect(tasksList).toHaveBeenCalledTimes(loadCounts.tasks) + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(projectsList).toHaveBeenCalledTimes(loadCounts.projects); + expect(expertsList).toHaveBeenCalledTimes(loadCounts.experts); + expect(tasksList).toHaveBeenCalledTimes(loadCounts.tasks); } finally { - delete api.updates - cleanup() - await changeUiLocale('zh-CN') + delete api.updates; + cleanup(); + await changeUiLocale("zh-CN"); } - }) + }); - it('checks for updates silently on startup and only reports a new version', async () => { + it("checks for updates silently on startup and only reports a new version", async () => { const check = vi.fn(async () => ({ updateAvailable: true, - currentVersion: '0.8.4', - latestVersion: '0.9.0', - releaseUrl: - 'https://github.com/mesalogo/goodbuddy/releases/tag/v0.9.0', + currentVersion: "0.8.4", + latestVersion: "0.9.0", + releaseUrl: "https://github.com/mesalogo/goodbuddy/releases/tag/v0.9.0", target: { - platform: 'windows' as const, - arch: 'x64' as const, - formats: ['nsis', 'portable'], + platform: "windows" as const, + arch: "x64" as const, + formats: ["nsis", "portable"], files: [ { - name: 'GoodBuddy-0.9.0-windows-x64-portable.zip', + name: "GoodBuddy-0.9.0-windows-x64-portable.zip", size: 1, - sha256: 'a'.repeat(64) - } - ] - } - })) + sha256: "a".repeat(64), + }, + ], + }, + })); api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), check, openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; try { - render() - await waitFor(() => expect(check).toHaveBeenCalledOnce()) + render(); + await waitFor(() => expect(check).toHaveBeenCalledOnce()); expect( - await screen.findByText( - '发现 GoodBuddy 0.9.0,可在“关于与更新”中查看' - ) - ).toBeInTheDocument() - expect(api.updates.onResult).not.toHaveBeenCalled() + await screen.findByText("发现 GoodBuddy 0.9.0,可在“关于与更新”中查看"), + ).toBeInTheDocument(); + expect(api.updates.onResult).not.toHaveBeenCalled(); } finally { - delete api.updates + delete api.updates; } - }) + }); - it('shows and acknowledges pending release notes on startup', async () => { - const acknowledge = vi.fn(async () => {}) + it("shows and acknowledges pending release notes on startup", async () => { + const acknowledge = vi.fn(async () => {}); api.releaseNotes = { getPending: vi.fn(async () => ({ - currentVersion: '0.8.18', + currentVersion: "0.8.18", releases: [ { - version: '0.8.18', - releasedAt: '2026-08-11', + version: "0.8.18", + releasedAt: "2026-08-11", notes: { - 'zh-CN': { - highlights: ['多 Runtime 工作流更加连贯'], - features: ['新增版本更新说明'], - fixes: ['修复重复显示'], - notices: ['Ask 模式保持只读'] + "zh-CN": { + highlights: ["多 Runtime 工作流更加连贯"], + features: ["新增版本更新说明"], + fixes: ["修复重复显示"], + notices: ["Ask 模式保持只读"], }, - 'en-US': { - highlights: ['Multi-Runtime workflows are more cohesive'], - features: ['Added release notes'], - fixes: ['Fixed repeated display'], - notices: ['Ask remains read-only'] - } - } - } - ] + "en-US": { + highlights: ["Multi-Runtime workflows are more cohesive"], + features: ["Added release notes"], + fixes: ["Fixed repeated display"], + notices: ["Ask remains read-only"], + }, + }, + }, + ], })), - acknowledge - } + acknowledge, + }; try { - render() + render(); expect( - await screen.findByRole('dialog', { - name: 'GoodBuddy 0.8.18 更新内容' - }) - ).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '开始使用' })) + await screen.findByRole("dialog", { + name: "GoodBuddy 0.8.18 更新内容", + }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "开始使用" })); + await waitFor(() => expect(acknowledge).toHaveBeenCalledWith("0.8.18")); await waitFor(() => - expect(acknowledge).toHaveBeenCalledWith('0.8.18') - ) - await waitFor(() => - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - ) + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); } finally { - delete api.releaseNotes + delete api.releaseNotes; } - }) + }); - it('keeps current-version checks quiet and reports startup check failures', async () => { + it("keeps current-version checks quiet and reports startup check failures", async () => { const currentResult = { updateAvailable: false, - currentVersion: '0.8.4', - latestVersion: '0.8.4', - releaseUrl: - 'https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.4', + currentVersion: "0.8.4", + latestVersion: "0.8.4", + releaseUrl: "https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.4", target: { - platform: 'windows' as const, - arch: 'x64' as const, - formats: ['nsis', 'portable'], - files: [] - } - } + platform: "windows" as const, + arch: "x64" as const, + formats: ["nsis", "portable"], + files: [], + }, + }; const check = vi .fn() .mockResolvedValueOnce(currentResult) .mockRejectedValueOnce( new Error( - "Error invoking remote method 'application:update:check': TypeError: fetch failed" - ) - ) + "Error invoking remote method 'application:update:check': TypeError: fetch failed", + ), + ); api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), check, openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; try { - const first = render() - await waitFor(() => expect(check).toHaveBeenCalledTimes(1)) + const first = render(); + await waitFor(() => expect(check).toHaveBeenCalledTimes(1)); expect( - screen.queryByText(/发现 GoodBuddy|版本检查失败/u) - ).not.toBeInTheDocument() + screen.queryByText(/发现 GoodBuddy|版本检查失败/u), + ).not.toBeInTheDocument(); - first.unmount() - render() - await waitFor(() => expect(check).toHaveBeenCalledTimes(2)) - expect( - await screen.findByRole('alert') - ).toHaveTextContent( - '启动更新检查失败(GitHub):无法连接更新源,请检查网络或代理后重试' - ) + first.unmount(); + render(); + await waitFor(() => expect(check).toHaveBeenCalledTimes(2)); + expect(await screen.findByRole("alert")).toHaveTextContent( + "启动更新检查失败(GitHub):无法连接更新源,请检查网络或代理后重试", + ); } finally { - delete api.updates + delete api.updates; } - }) + }); - it('shows a recoverable recent-conversation load failure', async () => { + it("shows a recoverable recent-conversation load failure", async () => { vi.mocked(api.projects.list) - .mockRejectedValueOnce(new Error('database busy')) - .mockResolvedValueOnce([project]) + .mockRejectedValueOnce(new Error("database busy")) + .mockResolvedValueOnce([project]); - render() + render(); - const loadError = await screen.findByRole('alert') - expect(loadError).toHaveTextContent('最近会话加载失败') - expect(loadError).toHaveTextContent('database busy') + const loadError = await screen.findByRole("alert"); + expect(loadError).toHaveTextContent("最近会话加载失败"); + expect(loadError).toHaveTextContent("database busy"); fireEvent.click( - within(loadError).getByRole('button', { - name: '重新读取会话' - }) - ) + within(loadError).getByRole("button", { + name: "重新读取会话", + }), + ); + await waitFor(() => expect(api.projects.list).toHaveBeenCalledTimes(2)); await waitFor(() => - expect(api.projects.list).toHaveBeenCalledTimes(2) - ) - await waitFor(() => - expect(screen.queryByText('最近会话加载失败')) - .not.toBeInTheDocument() - ) - }) + expect(screen.queryByText("最近会话加载失败")).not.toBeInTheDocument(), + ); + }); - it('keeps rendering when an older preload has no browser bridge', async () => { - Object.defineProperty(window, 'goodbuddy', { + it("keeps rendering when an older preload has no browser bridge", async () => { + Object.defineProperty(window, "goodbuddy", { configurable: true, value: { ...api, - browser: undefined - } - }) + browser: undefined, + }, + }); - render() + render(); expect( - await screen.findByLabelText('向 GoodBuddy 提问') - ).toBeInTheDocument() - }) + await screen.findByLabelText("向 GoodBuddy 提问"), + ).toBeInTheDocument(); + }); - it('uses local transcription when Electron has no Web Speech API', async () => { - Object.defineProperty(window, 'SpeechRecognition', { + it("uses local transcription when Electron has no Web Speech API", async () => { + Object.defineProperty(window, "SpeechRecognition", { configurable: true, - value: undefined - }) - Object.defineProperty(window, 'webkitSpeechRecognition', { + value: undefined, + }); + Object.defineProperty(window, "webkitSpeechRecognition", { configurable: true, - value: undefined - }) + value: undefined, + }); - render() - fireEvent.click(await screen.findByLabelText('语音输入')) + render(); + fireEvent.click(await screen.findByLabelText("语音输入")); await waitFor(() => expect(api.speech?.transcribe).toHaveBeenCalledWith( expect.objectContaining({ sampleRate: 16_000, - audio: expect.any(ArrayBuffer) - }) - ) - ) - expect(await screen.findByDisplayValue('本地语音结果')).toBeInTheDocument() - expect( - screen.getByText('快捷唤起:', { exact: false }) - ).toHaveTextContent('快捷唤起:Ctrl+Shift+Space') - expect( - screen.queryByText(/CommandOrControl/) - ).not.toBeInTheDocument() - }) + audio: expect.any(ArrayBuffer), + }), + ), + ); + expect(await screen.findByDisplayValue("本地语音结果")).toBeInTheDocument(); + expect(screen.getByText("快捷唤起:", { exact: false })).toHaveTextContent( + "快捷唤起:Ctrl+Shift+Space", + ); + expect(screen.queryByText(/CommandOrControl/)).not.toBeInTheDocument(); + }); - it('measures composer height once per input and resizes after sending', async () => { - render() - const input = await screen.findByLabelText('向 GoodBuddy 提问') + it("measures composer height once per input and resizes after sending", async () => { + render(); + const input = await screen.findByLabelText("向 GoodBuddy 提问"); const measureHeight = vi.fn(() => - (input as HTMLTextAreaElement).value ? 300 : 0 - ) - Object.defineProperty(input, 'scrollHeight', { + (input as HTMLTextAreaElement).value ? 300 : 0, + ); + Object.defineProperty(input, "scrollHeight", { configurable: true, - get: measureHeight - }) + get: measureHeight, + }); - fireEvent.input(input, { target: { value: 'First line\nSecond line' } }) - expect(measureHeight).toHaveBeenCalledTimes(1) - expect(input).toHaveStyle({ height: '220px' }) + fireEvent.input(input, { target: { value: "First line\nSecond line" } }); + expect(measureHeight).toHaveBeenCalledTimes(1); + expect(input).toHaveStyle({ height: "220px" }); - measureHeight.mockClear() - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(input).toHaveValue('')) - expect(measureHeight).toHaveBeenCalledTimes(1) - expect(input).toHaveStyle({ height: '72px' }) - }) + measureHeight.mockClear(); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(input).toHaveValue("")); + expect(measureHeight).toHaveBeenCalledTimes(1); + expect(input).toHaveStyle({ height: "72px" }); + }); - it('shows a red recording state until microphone capture stops', async () => { + it("shows a red recording state until microphone capture stops", async () => { let resolveRecording!: (value: { - audio: ArrayBuffer - sampleRate: 16_000 - }) => void - const stop = vi.fn() + audio: ArrayBuffer; + sampleRate: 16_000; + }) => void; + const stop = vi.fn(); speechRecognitionMocks.startPcmRecording.mockResolvedValueOnce({ result: new Promise((resolve) => { - resolveRecording = resolve + resolveRecording = resolve; }), stop, - cancel: vi.fn() - }) + cancel: vi.fn(), + }); - render() - const input = await screen.findByLabelText('向 GoodBuddy 提问') - expect(input).toHaveAttribute('rows', '3') - expect(input).toHaveStyle({ height: '72px' }) + render(); + const input = await screen.findByLabelText("向 GoodBuddy 提问"); + expect(input).toHaveAttribute("rows", "3"); + expect(input).toHaveStyle({ height: "72px" }); - fireEvent.click(screen.getByLabelText('语音输入')) - const recordingButton = await screen.findByRole('button', { - name: '停止录音' - }) - expect(recordingButton).toHaveAttribute('data-state', 'recording') - expect(recordingButton).toHaveAttribute('aria-pressed', 'true') - expect(recordingButton).toHaveClass( - 'composer__voice-button--recording' - ) + fireEvent.click(screen.getByLabelText("语音输入")); + const recordingButton = await screen.findByRole("button", { + name: "停止录音", + }); + expect(recordingButton).toHaveAttribute("data-state", "recording"); + expect(recordingButton).toHaveAttribute("aria-pressed", "true"); + expect(recordingButton).toHaveClass("composer__voice-button--recording"); - fireEvent.click(recordingButton) - expect(stop).toHaveBeenCalledOnce() + fireEvent.click(recordingButton); + expect(stop).toHaveBeenCalledOnce(); resolveRecording({ audio: new Float32Array([0, 0.25, -0.25]).buffer, - sampleRate: 16_000 - }) - expect( - await screen.findByDisplayValue('本地语音结果') - ).toBeInTheDocument() - }) + sampleRate: 16_000, + }); + expect(await screen.findByDisplayValue("本地语音结果")).toBeInTheDocument(); + }); - it('keeps conversation actions in the conversation list', async () => { - const { container } = render() - const topbar = container.querySelector('.topbar') + it("keeps conversation actions in the conversation list", async () => { + const { container } = render(); + const topbar = container.querySelector(".topbar"); const conversationList = - container.querySelector('.conversation-list') - expect(topbar).not.toBeNull() - expect(conversationList).not.toBeNull() + container.querySelector(".conversation-list"); + expect(topbar).not.toBeNull(); + expect(conversationList).not.toBeNull(); if (!topbar || !conversationList) { - return + return; } expect( - within(topbar).queryByRole('button', { - name: /^专家角色:/u - }) - ).not.toBeInTheDocument() - expect(composerMenuTrigger('专家角色').closest('.composer')).not.toBeNull() + within(topbar).queryByRole("button", { + name: /^专家角色:/u, + }), + ).not.toBeInTheDocument(); + expect(composerMenuTrigger("专家角色").closest(".composer")).not.toBeNull(); - const themeToggle = within(topbar).getByRole('button', { - name: '切换深色主题' - }) - fireEvent.click(themeToggle) + const themeToggle = within(topbar).getByRole("button", { + name: "切换深色主题", + }); + fireEvent.click(themeToggle); await waitFor(() => - expect(document.documentElement.dataset.theme).toBe('dark') - ) + expect(document.documentElement.dataset.theme).toBe("dark"), + ); expect( - screen.queryByRole('menuitem', { name: '重命名会话' }) - ).not.toBeInTheDocument() + screen.queryByRole("menuitem", { name: "重命名会话" }), + ).not.toBeInTheDocument(); expect( - within(topbar).getByRole('button', { - name: '切换浅色主题' - }) - ).toBe(themeToggle) - expect(screen.queryByRole('menu')).not.toBeInTheDocument() + within(topbar).getByRole("button", { + name: "切换浅色主题", + }), + ).toBe(themeToggle); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); - const conversationMenuTrigger = within( - conversationList - ).getByLabelText('更多会话操作 新对话') - fireEvent.click(conversationMenuTrigger) - const renameButton = within(conversationList).getByRole('button', { - name: '重命名会话' - }) - expect(renameButton).toBeVisible() + const conversationMenuTrigger = + within(conversationList).getByLabelText("更多会话操作 新对话"); + fireEvent.click(conversationMenuTrigger); + const renameButton = within(conversationList).getByRole("button", { + name: "重命名会话", + }); + expect(renameButton).toBeVisible(); expect( - within(conversationList).getByRole('button', { - name: '复制完整会话' - }) - ).toBeVisible() + within(conversationList).getByRole("button", { + name: "复制完整会话", + }), + ).toBeVisible(); expect( - within(conversationList).getByRole('button', { - name: '导出 Markdown' - }) - ).toBeVisible() + within(conversationList).getByRole("button", { + name: "导出 Markdown", + }), + ).toBeVisible(); - fireEvent.click(renameButton) - const renameInput = within(conversationList).getByLabelText( - '重命名会话 新对话' - ) + fireEvent.click(renameButton); + const renameInput = + within(conversationList).getByLabelText("重命名会话 新对话"); fireEvent.change(renameInput, { - target: { value: '重命名后的会话' } - }) - fireEvent.submit(renameInput.closest('form')!) + target: { value: "重命名后的会话" }, + }); + fireEvent.submit(renameInput.closest("form")!); expect( - within(conversationList).getByText('重命名后的会话') - ).toBeInTheDocument() - await waitFor(() => expect(conversationMenuTrigger).toHaveFocus()) + within(conversationList).getByText("重命名后的会话"), + ).toBeInTheDocument(); + await waitFor(() => expect(conversationMenuTrigger).toHaveFocus()); - fireEvent.click(screen.getByRole('button', { name: '知识库' })) + fireEvent.click(screen.getByRole("button", { name: "知识库" })); fireEvent.click( - within(conversationList).getByLabelText( - '更多会话操作 重命名后的会话' - ) - ) + within(conversationList).getByLabelText("更多会话操作 重命名后的会话"), + ); fireEvent.click( - within(conversationList).getByRole('button', { - name: '复制完整会话' - }) - ) - await waitFor(() => - expect(api.clipboard.writeText).toHaveBeenCalledOnce() - ) - expect(await screen.findByRole('status')).toBeVisible() - }) + within(conversationList).getByRole("button", { + name: "复制完整会话", + }), + ); + await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledOnce()); + expect(await screen.findByRole("status")).toBeVisible(); + }); - it('copies a completed assistant reply through the desktop bridge', async () => { - render() + it("copies a completed assistant reply through the desktop bridge", async () => { + render(); - fireEvent.click( - screen.getByRole('button', { name: '复制此回复' }) - ) + fireEvent.click(screen.getByRole("button", { name: "复制此回复" })); await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining('你好,我是 GoodBuddy') - ) - ) - expect( - await screen.findByText('回复已复制到剪贴板') - ).toBeVisible() - }) + expect.stringContaining("你好,我是 GoodBuddy"), + ), + ); + expect(await screen.findByText("回复已复制到剪贴板")).toBeVisible(); + }); - it('reports a desktop clipboard write failure', async () => { + it("reports a desktop clipboard write failure", async () => { vi.mocked(api.clipboard.writeText).mockRejectedValueOnce( - new Error('clipboard unavailable') - ) - render() + new Error("clipboard unavailable"), + ); + render(); - fireEvent.click( - screen.getByRole('button', { name: '复制此回复' }) - ) + fireEvent.click(screen.getByRole("button", { name: "复制此回复" })); - expect( - await screen.findByRole('alert') - ).toHaveTextContent('无法访问剪贴板,请检查系统权限') - }) + expect(await screen.findByRole("alert")).toHaveTextContent( + "无法访问剪贴板,请检查系统权限", + ); + }); - it('flushes and opens an independent conversation branch with provenance badges', async () => { - const sourceConversationId = - '00000000-0000-4000-8000-000000000421' - const branchConversationId = - '00000000-0000-4000-8000-000000000422' + it("flushes and opens an independent conversation branch with provenance badges", async () => { + const sourceConversationId = "00000000-0000-4000-8000-000000000421"; + const branchConversationId = "00000000-0000-4000-8000-000000000422"; const sourceConversation: ConversationSnapshot = { id: sourceConversationId, projectId, - title: '方案讨论', + title: "方案讨论", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000423', - role: 'user', - content: '探索不同发布方案', + id: "00000000-0000-4000-8000-000000000423", + role: "user", + content: "探索不同发布方案", createdAt: 1_775_000_000_000, - state: 'complete' + state: "complete", }, { - id: '00000000-0000-4000-8000-000000000424', - role: 'assistant', - content: '可以比较风险、成本和回滚路径。', + id: "00000000-0000-4000-8000-000000000424", + role: "assistant", + content: "可以比较风险、成本和回滚路径。", createdAt: 1_775_000_001_000, - state: 'complete' - } - ] - } + state: "complete", + }, + ], + }; vi.mocked(api.conversations.list).mockResolvedValueOnce([ - sourceConversation - ]) + sourceConversation, + ]); vi.mocked(api.conversations.branchLocal).mockImplementationOnce( async (input) => ({ ...sourceConversation, id: branchConversationId, branch: { sourceConversationId, - sourceTitle: '方案讨论更新' + sourceTitle: "方案讨论更新", }, contextMetrics: undefined, contextCompressionState: undefined, @@ -2762,712 +2626,649 @@ describe('App', () => { updatedAt: 1_775_000_002_000, messages: sourceConversation.messages.map((message, index) => ({ ...message, - id: `00000000-0000-4000-8000-${String( - 430 + index - ).padStart(12, '0')}` - })) - }) - ) - const { container } = render() - expect( - await screen.findByText('探索不同发布方案') - ).toBeInTheDocument() + id: `00000000-0000-4000-8000-${String(430 + index).padStart( + 12, + "0", + )}`, + })), + }), + ); + const { container } = render(); + expect(await screen.findByText("探索不同发布方案")).toBeInTheDocument(); - fireEvent.click( - await screen.findByLabelText('更多会话操作 方案讨论') - ) - fireEvent.click( - screen.getByRole('button', { name: '重命名会话' }) - ) - const renameInput = screen.getByLabelText('重命名会话 方案讨论') + fireEvent.click(await screen.findByLabelText("更多会话操作 方案讨论")); + fireEvent.click(screen.getByRole("button", { name: "重命名会话" })); + const renameInput = screen.getByLabelText("重命名会话 方案讨论"); fireEvent.change(renameInput, { - target: { value: '方案讨论更新' } - }) - fireEvent.submit(renameInput.closest('form')!) - expect( - await screen.findAllByText('方案讨论更新') - ).toHaveLength(2) - vi.mocked(api.conversations.saveLocal).mockClear() + target: { value: "方案讨论更新" }, + }); + fireEvent.submit(renameInput.closest("form")!); + expect(await screen.findAllByText("方案讨论更新")).toHaveLength(2); + vi.mocked(api.conversations.saveLocal).mockClear(); - fireEvent.click( - screen.getByLabelText('更多会话操作 方案讨论更新') - ) - const branchButton = screen.getByRole('button', { - name: '在新会话中继续' - }) - await waitFor(() => expect(branchButton).toBeEnabled()) - fireEvent.click(branchButton) + fireEvent.click(screen.getByLabelText("更多会话操作 方案讨论更新")); + const branchButton = screen.getByRole("button", { + name: "在新会话中继续", + }); + await waitFor(() => expect(branchButton).toBeEnabled()); + fireEvent.click(branchButton); await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith([ expect.objectContaining({ header: expect.objectContaining({ id: sourceConversationId, - title: '方案讨论更新' - }) - }) - ]) - ) + title: "方案讨论更新", + }), + }), + ]), + ); await waitFor(() => expect(api.conversations.branchLocal).toHaveBeenCalledWith({ sourceConversationId, - title: '方案讨论更新 · 分支' - }) - ) + title: "方案讨论更新 · 分支", + }), + ); expect( - vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder.at(-1) + vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder.at(-1), ).toBeLessThan( - vi.mocked(api.conversations.branchLocal).mock.invocationCallOrder[0]! - ) + vi.mocked(api.conversations.branchLocal).mock.invocationCallOrder[0]!, + ); expect( - await screen.findAllByLabelText( - '分支会话,来源:方案讨论更新' - ) - ).toHaveLength(2) - const sidebar = container.querySelector('.conversation-list') + await screen.findAllByLabelText("分支会话,来源:方案讨论更新"), + ).toHaveLength(2); + const sidebar = container.querySelector(".conversation-list"); const badge = within(sidebar as HTMLElement).getByLabelText( - '分支会话,来源:方案讨论更新' - ) - expect(badge).toHaveClass('conversation-branch-badge') - expect(badge.querySelector('.lucide-git-fork')).toBeInTheDocument() - expect( - container.querySelector('.conversation-title') - ).toHaveTextContent('方案讨论更新 · 分支') + "分支会话,来源:方案讨论更新", + ); + expect(badge).toHaveClass("conversation-branch-badge"); + expect(badge.querySelector(".lucide-git-fork")).toBeInTheDocument(); + expect(container.querySelector(".conversation-title")).toHaveTextContent( + "方案讨论更新 · 分支", + ); await waitFor(() => - expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveFocus() - ) + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveFocus(), + ); expect( - screen.getAllByText('可以比较风险、成本和回滚路径。').length - ).toBeGreaterThan(0) - expect( - await screen.findByText('已创建并打开分支会话') - ).toBeInTheDocument() - }) + screen.getAllByText("可以比较风险、成本和回滚路径。").length, + ).toBeGreaterThan(0); + expect(await screen.findByText("已创建并打开分支会话")).toBeInTheDocument(); + }); - it('keeps the source conversation open when branch persistence fails', async () => { - const sourceConversationId = - '00000000-0000-4000-8000-000000000441' + it("keeps the source conversation open when branch persistence fails", async () => { + const sourceConversationId = "00000000-0000-4000-8000-000000000441"; vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: sourceConversationId, projectId, - title: '保留的来源会话', + title: "保留的来源会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000442', - role: 'user', - content: '不能丢失的来源内容', + id: "00000000-0000-4000-8000-000000000442", + role: "user", + content: "不能丢失的来源内容", createdAt: 1_775_000_000_000, - state: 'complete' - } - ] - } - ]) + state: "complete", + }, + ], + }, + ]); vi.mocked(api.conversations.branchLocal).mockRejectedValueOnce( - new Error('分支数据写入失败') - ) - const { container } = render() - expect( - await screen.findByText('不能丢失的来源内容') - ).toBeInTheDocument() + new Error("分支数据写入失败"), + ); + const { container } = render(); + expect(await screen.findByText("不能丢失的来源内容")).toBeInTheDocument(); - const actionTrigger = await screen.findByLabelText( - '更多会话操作 保留的来源会话' - ) - fireEvent.click(actionTrigger) - const branchButton = screen.getByRole('button', { - name: '在新会话中继续' - }) - await waitFor(() => expect(branchButton).toBeEnabled()) - fireEvent.click(branchButton) + const actionTrigger = + await screen.findByLabelText("更多会话操作 保留的来源会话"); + fireEvent.click(actionTrigger); + const branchButton = screen.getByRole("button", { + name: "在新会话中继续", + }); + await waitFor(() => expect(branchButton).toBeEnabled()); + fireEvent.click(branchButton); + expect(await screen.findByText("分支数据写入失败")).toBeInTheDocument(); + expect(container.querySelector(".conversation-title")).toHaveTextContent( + "保留的来源会话", + ); + expect(screen.getByText("不能丢失的来源内容")).toBeInTheDocument(); expect( - await screen.findByText('分支数据写入失败') - ).toBeInTheDocument() - expect( - container.querySelector('.conversation-title') - ).toHaveTextContent('保留的来源会话') - expect( - screen.getByText('不能丢失的来源内容') - ).toBeInTheDocument() - expect( - screen.queryByLabelText(/^分支会话,来源:/u) - ).not.toBeInTheDocument() - await waitFor(() => expect(actionTrigger).toHaveFocus()) - }) + screen.queryByLabelText(/^分支会话,来源:/u), + ).not.toBeInTheDocument(); + await waitFor(() => expect(actionTrigger).toHaveFocus()); + }); - it('explains why a queued conversation cannot be branched', async () => { - const sourceConversationId = - '00000000-0000-4000-8000-000000000451' + it("explains why a queued conversation cannot be branched", async () => { + const sourceConversationId = "00000000-0000-4000-8000-000000000451"; vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: sourceConversationId, projectId, - title: '等待队列的会话', + title: "等待队列的会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000452', - role: 'user', - content: '还有待发送内容', + id: "00000000-0000-4000-8000-000000000452", + role: "user", + content: "还有待发送内容", createdAt: 1_775_000_000_000, - state: 'complete' - } - ] - } - ]) + state: "complete", + }, + ], + }, + ]); vi.mocked(api.conversationQueue.list).mockResolvedValue([ { - id: '00000000-0000-4000-8000-000000000453', + id: "00000000-0000-4000-8000-000000000453", conversationId: sourceConversationId, - source: 'user', - label: '待发送', - createdAt: '2026-08-20T08:00:00.000Z' - } - ]) - render() - expect( - await screen.findByText('还有待发送内容') - ).toBeInTheDocument() + source: "user", + label: "待发送", + createdAt: "2026-08-20T08:00:00.000Z", + }, + ]); + render(); + expect(await screen.findByText("还有待发送内容")).toBeInTheDocument(); fireEvent.click( - await screen.findByLabelText('更多会话操作 等待队列的会话') - ) - const branchButton = screen.getByRole('button', { - name: '在新会话中继续' - }) + await screen.findByLabelText("更多会话操作 等待队列的会话"), + ); + const branchButton = screen.getByRole("button", { + name: "在新会话中继续", + }); await waitFor(() => - expect(branchButton).toHaveAttribute('aria-disabled', 'true') - ) + expect(branchButton).toHaveAttribute("aria-disabled", "true"), + ); expect(branchButton).toHaveAccessibleDescription( - '会话运行或待发送内容处理完后才能创建分支' - ) - fireEvent.click(branchButton) + "会话运行或待发送内容处理完后才能创建分支", + ); + fireEvent.click(branchButton); await waitFor(() => expect( - screen.getAllByText( - '会话运行或待发送内容处理完后才能创建分支' - ) - ).toHaveLength(2) - ) - expect(api.conversations.branchLocal).not.toHaveBeenCalled() - }) + screen.getAllByText("会话运行或待发送内容处理完后才能创建分支"), + ).toHaveLength(2), + ); + expect(api.conversations.branchLocal).not.toHaveBeenCalled(); + }); - it('offers a floating control when more messages remain below', async () => { + it("offers a floating control when more messages remain below", async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000401', + id: "00000000-0000-4000-8000-000000000401", projectId, - title: '长会话', + title: "长会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000402', - role: 'assistant', - content: '较早的会话内容', + id: "00000000-0000-4000-8000-000000000402", + role: "assistant", + content: "较早的会话内容", createdAt: 1_775_000_000_000, - state: 'complete' - } - ] - } - ]) - const { container } = render() + state: "complete", + }, + ], + }, + ]); + const { container } = render(); - expect(await screen.findByText('较早的会话内容')).toBeInTheDocument() - const chat = container.querySelector('.chat') + expect(await screen.findByText("较早的会话内容")).toBeInTheDocument(); + const chat = container.querySelector(".chat"); if (!chat) { - throw new Error('Missing chat scroll container') + throw new Error("Missing chat scroll container"); } Object.defineProperties(chat, { clientHeight: { configurable: true, value: 400 }, scrollHeight: { configurable: true, value: 1_200 }, - scrollTop: { configurable: true, writable: true, value: 100 } - }) - const scrollTo = vi.fn() - chat.scrollTo = scrollTo + scrollTop: { configurable: true, writable: true, value: 100 }, + }); + const scrollTo = vi.fn(); + chat.scrollTo = scrollTo; - fireEvent.scroll(chat) - const scrollButton = screen.getByRole('button', { - name: '到底部' - }) - expect(scrollButton).toHaveAttribute( - 'aria-controls', - 'chat-message-list' - ) - expect(scrollButton).toHaveAttribute('title', '到底部') - expect(scrollButton).toHaveTextContent('') + fireEvent.scroll(chat); + const scrollButton = screen.getByRole("button", { + name: "到底部", + }); + expect(scrollButton).toHaveAttribute("aria-controls", "chat-message-list"); + expect(scrollButton).toHaveAttribute("title", "到底部"); + expect(scrollButton).toHaveTextContent(""); - fireEvent.click(scrollButton) + fireEvent.click(scrollButton); expect(scrollTo).toHaveBeenLastCalledWith({ top: 1_200, - behavior: 'smooth' - }) + behavior: "smooth", + }); - chat.scrollTop = 750 - fireEvent.scroll(chat) + chat.scrollTop = 750; + fireEvent.scroll(chat); expect( - screen.queryByRole('button', { name: '到底部' }) - ).not.toBeInTheDocument() - }) + screen.queryByRole("button", { name: "到底部" }), + ).not.toBeInTheDocument(); + }); - it('keeps a newly opened persisted conversation at the bottom while its content settles', async () => { - let resizeCallback: ResizeObserverCallback | undefined - const disconnect = vi.fn() + it("keeps a newly opened persisted conversation at the bottom while its content settles", async () => { + let resizeCallback: ResizeObserverCallback | undefined; + const disconnect = vi.fn(); vi.stubGlobal( - 'ResizeObserver', + "ResizeObserver", class { constructor(callback: ResizeObserverCallback) { - resizeCallback = callback + resizeCallback = callback; } observe(): void {} unobserve(): void {} disconnect(): void { - disconnect() + disconnect(); } - } - ) + }, + ); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000417', + id: "00000000-0000-4000-8000-000000000417", projectId, - title: '已有会话', + title: "已有会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000418', - role: 'user', - content: '最后一个用户问题', + id: "00000000-0000-4000-8000-000000000418", + role: "user", + content: "最后一个用户问题", createdAt: 1_775_000_000_000, - state: 'complete' + state: "complete", }, { - id: '00000000-0000-4000-8000-000000000419', - role: 'assistant', - content: '加载后会继续完成布局的回复', + id: "00000000-0000-4000-8000-000000000419", + role: "assistant", + content: "加载后会继续完成布局的回复", createdAt: 1_775_000_000_001, - state: 'complete' - } - ] - } - ]) - const { container } = render() + state: "complete", + }, + ], + }, + ]); + const { container } = render(); expect( - await screen.findByText('加载后会继续完成布局的回复') - ).toBeInTheDocument() - const chat = container.querySelector('.chat') + await screen.findByText("加载后会继续完成布局的回复"), + ).toBeInTheDocument(); + const chat = container.querySelector(".chat"); if (!chat || !resizeCallback) { - throw new Error('Missing chat resize observer') + throw new Error("Missing chat resize observer"); } - let scrollHeight = 1_200 + let scrollHeight = 1_200; Object.defineProperties(chat, { clientHeight: { configurable: true, value: 400 }, scrollHeight: { configurable: true, - get: () => scrollHeight + get: () => scrollHeight, }, - scrollTop: { configurable: true, writable: true, value: 800 } - }) - const scrollTo = vi.fn() - chat.scrollTo = scrollTo + scrollTop: { configurable: true, writable: true, value: 800 }, + }); + const scrollTo = vi.fn(); + chat.scrollTo = scrollTo; - scrollHeight = 1_800 - act(() => resizeCallback?.([], {} as ResizeObserver)) + scrollHeight = 1_800; + act(() => resizeCallback?.([], {} as ResizeObserver)); expect(scrollTo).toHaveBeenLastCalledWith({ top: 1_800, - behavior: 'auto' - }) + behavior: "auto", + }); - chat.scrollTop = 100 - fireEvent.scroll(chat) - scrollTo.mockClear() - scrollHeight = 2_200 - act(() => resizeCallback?.([], {} as ResizeObserver)) - expect(scrollTo).not.toHaveBeenCalled() - }) + chat.scrollTop = 100; + fireEvent.scroll(chat); + scrollTo.mockClear(); + scrollHeight = 2_200; + act(() => resizeCallback?.([], {} as ResizeObserver)); + expect(scrollTo).not.toHaveBeenCalled(); + }); - it('renders the latest 80 messages and preserves scroll when revealing earlier messages', async () => { + it("renders the latest 80 messages and preserves scroll when revealing earlier messages", async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000421', + id: "00000000-0000-4000-8000-000000000421", projectId, - title: '超长会话', + title: "超长会话", updatedAt: 1_775_000_000_000, messages: Array.from({ length: 161 }, (_, index) => ({ - id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, - role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), - content: `历史消息 ${String(index).padStart(3, '0')}`, + id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + content: `历史消息 ${String(index).padStart(3, "0")}`, createdAt: 1_775_000_000_000 + index, - state: 'complete' as const - })) - } - ]) - const { container } = render() + state: "complete" as const, + })), + }, + ]); + const { container } = render(); - expect(await screen.findByText('历史消息 160')).toBeInTheDocument() - expect(screen.queryByText('历史消息 080')).not.toBeInTheDocument() - expect(container.querySelectorAll('.message')).toHaveLength(80) - const chat = container.querySelector('.chat') + expect(await screen.findByText("历史消息 160")).toBeInTheDocument(); + expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument(); + expect(container.querySelectorAll(".message")).toHaveLength(80); + const chat = container.querySelector(".chat"); if (!chat) { - throw new Error('Missing chat scroll container') + throw new Error("Missing chat scroll container"); } Object.defineProperties(chat, { clientHeight: { configurable: true, value: 400 }, scrollHeight: { configurable: true, - get: () => container.querySelectorAll('.message').length * 10 + get: () => container.querySelectorAll(".message").length * 10, }, scrollTop: { configurable: true, writable: true, - value: 125 - } - }) + value: 125, + }, + }); fireEvent.click( - screen.getByRole('button', { - name: '加载更早的消息(还剩 81 条)' - }) - ) + screen.getByRole("button", { + name: "加载更早的消息(还剩 81 条)", + }), + ); - expect(container.querySelectorAll('.message')).toHaveLength(160) - expect(screen.getByText('历史消息 001')).toBeInTheDocument() - expect(screen.queryByText('历史消息 000')).not.toBeInTheDocument() - expect(chat.scrollTop).toBe(925) + expect(container.querySelectorAll(".message")).toHaveLength(160); + expect(screen.getByText("历史消息 001")).toBeInTheDocument(); + expect(screen.queryByText("历史消息 000")).not.toBeInTheDocument(); + expect(chat.scrollTop).toBe(925); fireEvent.click( - screen.getByRole('button', { - name: '加载更早的消息(还剩 1 条)' - }) - ) - expect(container.querySelectorAll('.message')).toHaveLength(161) - expect(screen.getByText('历史消息 000')).toBeInTheDocument() + screen.getByRole("button", { + name: "加载更早的消息(还剩 1 条)", + }), + ); + expect(container.querySelectorAll(".message")).toHaveLength(161); + expect(screen.getByText("历史消息 000")).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: /加载更早的消息/u }) - ).not.toBeInTheDocument() - expect( - screen.getByText('历史消息 000').closest('article') - ).toHaveFocus() - }) + screen.queryByRole("button", { name: /加载更早的消息/u }), + ).not.toBeInTheDocument(); + expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus(); + }); - it('keeps the reader position while a response continues below', async () => { - render() - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '继续生成较长回复' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + it("keeps the reader position while a response continues below", async () => { + render(); + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "继续生成较长回复" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } await act( () => - new Promise((resolve) => - requestAnimationFrame(() => resolve()) - ) - ) + new Promise((resolve) => requestAnimationFrame(() => resolve())), + ); - const chat = document.querySelector('.chat') + const chat = document.querySelector(".chat"); if (!chat) { - throw new Error('Missing chat scroll container') + throw new Error("Missing chat scroll container"); } Object.defineProperties(chat, { clientHeight: { configurable: true, value: 400 }, scrollHeight: { configurable: true, value: 1_200 }, - scrollTop: { configurable: true, writable: true, value: 100 } - }) - const scrollTo = vi.fn() - chat.scrollTo = scrollTo - fireEvent.scroll(chat) + scrollTop: { configurable: true, writable: true, value: 100 }, + }); + const scrollTo = vi.fn(); + chat.scrollTo = scrollTo; + fireEvent.scroll(chat); act(() => { agentListener?.({ requestId: request.requestId, - type: 'text', - delta: '新增的回复内容' - }) - }) - expect(await screen.findByText('新增的回复内容')).toBeInTheDocument() + type: "text", + delta: "新增的回复内容", + }); + }); + expect(await screen.findByText("新增的回复内容")).toBeInTheDocument(); await act( () => - new Promise((resolve) => - requestAnimationFrame(() => resolve()) - ) - ) + new Promise((resolve) => requestAnimationFrame(() => resolve())), + ); - expect(scrollTo).not.toHaveBeenCalled() - expect( - screen.getByRole('button', { name: '到底部' }) - ).toBeInTheDocument() - }) + expect(scrollTo).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument(); + }); - it('restores the reader position after activity continues on another page', async () => { - const { container } = render() - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '离开页面后继续生成' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + it("restores the reader position after activity continues on another page", async () => { + const { container } = render(); + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "离开页面后继续生成" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } const conversationList = - container.querySelector('.conversation-list') + container.querySelector(".conversation-list"); if (!conversationList) { - throw new Error('Missing conversation list') + throw new Error("Missing conversation list"); } - const activeIndicator = within(conversationList).getByRole('status', { - name: '会话正在活动' - }) - expect(activeIndicator).toHaveClass( - 'conversation-activity-indicator' - ) - expect(activeIndicator.closest('.conversation-row')).toHaveTextContent( - '离开页面后继续生成' - ) + const activeIndicator = within(conversationList).getByRole("status", { + name: "会话正在活动", + }); + expect(activeIndicator).toHaveClass("conversation-activity-indicator"); + expect(activeIndicator.closest(".conversation-row")).toHaveTextContent( + "离开页面后继续生成", + ); await act( () => - new Promise((resolve) => - requestAnimationFrame(() => resolve()) - ) - ) + new Promise((resolve) => requestAnimationFrame(() => resolve())), + ); - const chat = container.querySelector('.chat') + const chat = container.querySelector(".chat"); if (!chat) { - throw new Error('Missing chat scroll container') + throw new Error("Missing chat scroll container"); } Object.defineProperties(chat, { clientHeight: { configurable: true, value: 400 }, scrollHeight: { configurable: true, value: 1_200 }, - scrollTop: { configurable: true, writable: true, value: 175 } - }) - fireEvent.scroll(chat) + scrollTop: { configurable: true, writable: true, value: 175 }, + }); + fireEvent.scroll(chat); - fireEvent.click(screen.getByRole('button', { name: '知识库' })) - expect(chat).toBeInTheDocument() - expect(chat.closest('[data-route="chat"]')).toHaveAttribute('hidden') + fireEvent.click(screen.getByRole("button", { name: "知识库" })); + expect(chat).toBeInTheDocument(); + expect(chat.closest('[data-route="chat"]')).toHaveAttribute("hidden"); expect( - within(conversationList).getByRole('status', { - name: '会话正在活动' - }) - ).toBeInTheDocument() + within(conversationList).getByRole("status", { + name: "会话正在活动", + }), + ).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'text', - delta: '后台新增的回复内容' - }) - }) + type: "text", + delta: "后台新增的回复内容", + }); + }); act(() => { agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); await waitFor(() => expect( - within(conversationList).queryByRole('status', { - name: '会话正在活动' - }) - ).not.toBeInTheDocument() - ) + within(conversationList).queryByRole("status", { + name: "会话正在活动", + }), + ).not.toBeInTheDocument(), + ); - fireEvent.click(screen.getByRole('button', { name: '对话' })) - expect(await screen.findByText('后台新增的回复内容')).toBeInTheDocument() - const restoredChat = container.querySelector('.chat') - expect(restoredChat).toBe(chat) - expect(restoredChat?.scrollTop).toBe(175) - expect( - screen.getByRole('button', { name: '到底部' }) - ).toBeInTheDocument() - }) + fireEvent.click(screen.getByRole("button", { name: "对话" })); + expect(await screen.findByText("后台新增的回复内容")).toBeInTheDocument(); + const restoredChat = container.querySelector(".chat"); + expect(restoredChat).toBe(chat); + expect(restoredChat?.scrollTop).toBe(175); + expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument(); + }); - it('keeps each conversation history window and reader position', async () => { - const firstConversationId = - '00000000-0000-4000-8000-000000000461' - const secondConversationId = - '00000000-0000-4000-8000-000000000462' + it("keeps each conversation history window and reader position", async () => { + const firstConversationId = "00000000-0000-4000-8000-000000000461"; + const secondConversationId = "00000000-0000-4000-8000-000000000462"; const draftAttachment = { - id: '00000000-0000-4000-8000-000000000463', - name: '第一段草稿附件.md', + id: "00000000-0000-4000-8000-000000000463", + name: "第一段草稿附件.md", size: 1_024, - preview: '会话级草稿附件', - kind: 'text' as const - } - vi.mocked(api.context.selectFiles).mockResolvedValueOnce([ - draftAttachment - ]) + preview: "会话级草稿附件", + kind: "text" as const, + }; + vi.mocked(api.context.selectFiles).mockResolvedValueOnce([draftAttachment]); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: firstConversationId, projectId, - title: '第一段长会话', + title: "第一段长会话", updatedAt: 1_775_000_000_002, messages: Array.from({ length: 161 }, (_, index) => ({ - id: `00000000-0000-4000-8100-${String(index).padStart(12, '0')}`, - role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), - content: `第一段历史 ${String(index).padStart(3, '0')}`, - reasoning: index === 159 ? '需要保留展开状态' : undefined, + id: `00000000-0000-4000-8100-${String(index).padStart(12, "0")}`, + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + content: `第一段历史 ${String(index).padStart(3, "0")}`, + reasoning: index === 159 ? "需要保留展开状态" : undefined, createdAt: 1_775_000_000_000 + index, - state: 'complete' as const - })) + state: "complete" as const, + })), }, { id: secondConversationId, projectId, - title: '第二段会话', + title: "第二段会话", updatedAt: 1_775_000_000_001, messages: [ { - id: '00000000-0000-4000-8200-000000000001', - role: 'assistant', - content: '第二段会话内容', + id: "00000000-0000-4000-8200-000000000001", + role: "assistant", + content: "第二段会话内容", createdAt: 1_775_000_000_001, - state: 'complete' - } - ] - } - ]) - const { container } = render() + state: "complete", + }, + ], + }, + ]); + const { container } = render(); - expect(await screen.findByText('第一段历史 160')).toBeInTheDocument() + expect(await screen.findByText("第一段历史 160")).toBeInTheDocument(); fireEvent.click( - screen.getByRole('button', { - name: '加载更早的消息(还剩 81 条)' - }) - ) + screen.getByRole("button", { + name: "加载更早的消息(还剩 81 条)", + }), + ); const firstPane = container.querySelector( - `[data-conversation-id="${firstConversationId}"]` - ) - expect(firstPane?.querySelectorAll('.message')).toHaveLength(160) - const firstChat = firstPane?.querySelector('.chat') + `[data-conversation-id="${firstConversationId}"]`, + ); + expect(firstPane?.querySelectorAll(".message")).toHaveLength(160); + const firstChat = firstPane?.querySelector(".chat"); if (!firstChat) { - throw new Error('Missing first chat scroll container') + throw new Error("Missing first chat scroll container"); } - const reasoningDetails = firstPane?.querySelector( - '.message-reasoning' - ) + const reasoningDetails = + firstPane?.querySelector(".message-reasoning"); if (!reasoningDetails) { - throw new Error('Missing reasoning details') + throw new Error("Missing reasoning details"); } - reasoningDetails.open = true + reasoningDetails.open = true; Object.defineProperties(firstChat, { clientHeight: { configurable: true, value: 400 }, scrollHeight: { configurable: true, value: 1_600 }, - scrollTop: { configurable: true, writable: true, value: 225 } - }) - fireEvent.scroll(firstChat) - messageRenderProbe.mockClear() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '第一段会话草稿' } - }) - expect(messageRenderProbe).not.toHaveBeenCalled() - expect(firstPane?.querySelectorAll('.message')).toHaveLength(160) - fireEvent.click(screen.getByLabelText('添加附件')) - expect( - await screen.findByText(draftAttachment.name) - ).toBeInTheDocument() + scrollTop: { configurable: true, writable: true, value: 225 }, + }); + fireEvent.scroll(firstChat); + messageRenderProbe.mockClear(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "第一段会话草稿" }, + }); + expect(messageRenderProbe).not.toHaveBeenCalled(); + expect(firstPane?.querySelectorAll(".message")).toHaveLength(160); + fireEvent.click(screen.getByLabelText("添加附件")); + expect(await screen.findByText(draftAttachment.name)).toBeInTheDocument(); - fireEvent.click( - screen.getByText('第二段会话').closest('button')! - ) - expect(await screen.findByText('第二段会话内容')).toBeInTheDocument() - expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('') - expect( - screen.queryByText(draftAttachment.name) - ).not.toBeInTheDocument() - messageRenderProbe.mockClear() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '第二段会话草稿' } - }) - expect(messageRenderProbe).not.toHaveBeenCalled() - expect(firstPane?.querySelectorAll('.message')).toHaveLength(160) - fireEvent.click( - screen.getByText('第一段长会话').closest('button')! - ) + fireEvent.click(screen.getByText("第二段会话").closest("button")!); + expect(await screen.findByText("第二段会话内容")).toBeInTheDocument(); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue(""); + expect(screen.queryByText(draftAttachment.name)).not.toBeInTheDocument(); + messageRenderProbe.mockClear(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "第二段会话草稿" }, + }); + expect(messageRenderProbe).not.toHaveBeenCalled(); + expect(firstPane?.querySelectorAll(".message")).toHaveLength(160); + fireEvent.click(screen.getByText("第一段长会话").closest("button")!); - expect(await screen.findByText('第一段历史 001')).toBeInTheDocument() + expect(await screen.findByText("第一段历史 001")).toBeInTheDocument(); const restoredFirstPane = container.querySelector( - `[data-conversation-id="${firstConversationId}"]` - ) - expect(restoredFirstPane).toBe(firstPane) - expect(restoredFirstPane?.querySelectorAll('.message')).toHaveLength( - 160 - ) - expect(restoredFirstPane?.querySelector('.chat')).toBe(firstChat) - expect(firstChat.scrollTop).toBe(225) - expect(reasoningDetails).toHaveAttribute('open') - expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue( - '第一段会话草稿' - ) - expect(screen.getByText(draftAttachment.name)).toBeInTheDocument() - }) + `[data-conversation-id="${firstConversationId}"]`, + ); + expect(restoredFirstPane).toBe(firstPane); + expect(restoredFirstPane?.querySelectorAll(".message")).toHaveLength(160); + expect(restoredFirstPane?.querySelector(".chat")).toBe(firstChat); + expect(firstChat.scrollTop).toBe(225); + expect(reasoningDetails).toHaveAttribute("open"); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue( + "第一段会话草稿", + ); + expect(screen.getByText(draftAttachment.name)).toBeInTheDocument(); + }); - it('requires an accessible confirmation before permanently deleting a conversation', async () => { - render() - const menuTrigger = screen.getByLabelText( - '更多会话操作 新对话' - ) - fireEvent.click(menuTrigger) - const deleteTrigger = screen.getByRole('button', { - name: '删除对话 新对话' - }) - fireEvent.click(deleteTrigger) + it("requires an accessible confirmation before permanently deleting a conversation", async () => { + render(); + const menuTrigger = screen.getByLabelText("更多会话操作 新对话"); + fireEvent.click(menuTrigger); + const deleteTrigger = screen.getByRole("button", { + name: "删除对话 新对话", + }); + fireEvent.click(deleteTrigger); - const dialog = screen.getByRole('alertdialog', { - name: '确认永久删除对话 新对话' - }) - expect(dialog).toHaveTextContent('将永久删除此会话的全部内容') + const dialog = screen.getByRole("alertdialog", { + name: "确认永久删除对话 新对话", + }); + expect(dialog).toHaveTextContent("将永久删除此会话的全部内容"); expect(dialog).toHaveTextContent( - '如果此会话有正在运行的任务,也会同时停止' - ) - expect(dialog).toHaveTextContent('此操作不可恢复') - const cancel = screen.getByRole('button', { - name: '取消删除对话 新对话' - }) - expect(cancel).toHaveFocus() + "如果此会话有正在运行的任务,也会同时停止", + ); + expect(dialog).toHaveTextContent("此操作不可恢复"); + const cancel = screen.getByRole("button", { + name: "取消删除对话 新对话", + }); + expect(cancel).toHaveFocus(); - expect(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true) - expect(cancel).toHaveFocus() - fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(fireEvent.keyDown(dialog, { key: "Tab" })).toBe(true); + expect(cancel).toHaveFocus(); + fireEvent.keyDown(dialog, { key: "Escape" }); await waitFor(() => expect( - screen.getByRole('button', { name: '删除对话 新对话' }) - ).toHaveFocus() - ) + screen.getByRole("button", { name: "删除对话 新对话" }), + ).toHaveFocus(), + ); expect( - screen.queryByRole('alertdialog', { - name: '确认永久删除对话 新对话' - }) - ).not.toBeInTheDocument() + screen.queryByRole("alertdialog", { + name: "确认永久删除对话 新对话", + }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "删除对话 新对话" })); fireEvent.click( - screen.getByRole('button', { name: '删除对话 新对话' }) - ) - fireEvent.click( - screen.getByRole('button', { - name: '确认永久删除对话 新对话' - }) - ) + screen.getByRole("button", { + name: "确认永久删除对话 新对话", + }), + ); await waitFor(() => expect( - screen.queryByRole('alertdialog', { - name: '确认永久删除对话 新对话' - }) - ).not.toBeInTheDocument() - ) + screen.queryByRole("alertdialog", { + name: "确认永久删除对话 新对话", + }), + ).not.toBeInTheDocument(), + ); expect(api.conversations.deleteLocal).toHaveBeenCalledWith( - expect.any(String) - ) - }) + expect.any(String), + ); + }); - it('keeps a local conversation visible when deleting its persisted record fails', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000431' - const title = '删除失败时保留的本地会话' + it("keeps a local conversation visible when deleting its persisted record fails", async () => { + const conversationId = "00000000-0000-4000-8000-000000000431"; + const title = "删除失败时保留的本地会话"; vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: conversationId, @@ -3476,1066 +3277,991 @@ describe('App', () => { updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000432', - role: 'assistant', - content: '需要保留的消息', + id: "00000000-0000-4000-8000-000000000432", + role: "assistant", + content: "需要保留的消息", createdAt: 1_775_000_000_000, - state: 'complete' - } - ] - } - ]) + state: "complete", + }, + ], + }, + ]); vi.mocked(api.conversations.deleteLocal).mockRejectedValueOnce( - new Error('delete failed') - ) - render() + new Error("delete failed"), + ); + render(); + fireEvent.click(await screen.findByLabelText(`更多会话操作 ${title}`)); + fireEvent.click(screen.getByRole("button", { name: `删除对话 ${title}` })); fireEvent.click( - await screen.findByLabelText(`更多会话操作 ${title}`) - ) - fireEvent.click( - screen.getByRole('button', { name: `删除对话 ${title}` }) - ) - fireEvent.click( - screen.getByRole('button', { - name: `确认永久删除对话 ${title}` - }) - ) + screen.getByRole("button", { + name: `确认永久删除对话 ${title}`, + }), + ); await waitFor(() => expect(api.conversations.deleteLocal).toHaveBeenCalledWith( - conversationId - ) - ) + conversationId, + ), + ); expect( - await screen.findByText('删除本地会话失败,已保留当前对话') - ).toBeInTheDocument() - expect(screen.getByText('需要保留的消息')).toBeInTheDocument() - const dialog = screen.getByRole('alertdialog', { - name: `确认永久删除对话 ${title}` - }) + await screen.findByText("删除本地会话失败,已保留当前对话"), + ).toBeInTheDocument(); + expect(screen.getByText("需要保留的消息")).toBeInTheDocument(); + const dialog = screen.getByRole("alertdialog", { + name: `确认永久删除对话 ${title}`, + }); + expect(dialog).toBeInTheDocument(); + fireEvent.keyDown(dialog, { key: "Escape" }); expect( - dialog - ).toBeInTheDocument() - fireEvent.keyDown(dialog, { key: 'Escape' }) - expect( - await screen.findByLabelText(`更多会话操作 ${title}`) - ).toBeInTheDocument() - }) + await screen.findByLabelText(`更多会话操作 ${title}`), + ).toBeInTheDocument(); + }); - it('keeps a conversation when cancelling its active task fails', async () => { + it("keeps a conversation when cancelling its active task fails", async () => { vi.mocked(api.agent.cancel).mockRejectedValueOnce( - new Error('cancel failed') - ) - render() - const title = '取消失败时保留会话' + new Error("cancel failed"), + ); + render(); + const title = "取消失败时保留会话"; - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: title } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: title }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); - fireEvent.click( - await screen.findByLabelText(`更多会话操作 ${title}`) - ) - fireEvent.click( - screen.getByRole('button', { name: `删除对话 ${title}` }) - ) - const confirm = screen.getByRole('button', { - name: `确认永久删除对话 ${title}` - }) - fireEvent.click(confirm) + fireEvent.click(await screen.findByLabelText(`更多会话操作 ${title}`)); + fireEvent.click(screen.getByRole("button", { name: `删除对话 ${title}` })); + const confirm = screen.getByRole("button", { + name: `确认永久删除对话 ${title}`, + }); + fireEvent.click(confirm); - await waitFor(() => - expect(api.agent.cancel).toHaveBeenCalledOnce() - ) + await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledOnce()); expect( - await screen.findByText('停止会话中的运行任务失败,尚未删除对话') - ).toBeInTheDocument() + await screen.findByText("停止会话中的运行任务失败,尚未删除对话"), + ).toBeInTheDocument(); expect( - screen.getByRole('alertdialog', { - name: `确认永久删除对话 ${title}` - }) - ).toBeInTheDocument() - await waitFor(() => expect(confirm).toBeEnabled()) + screen.getByRole("alertdialog", { + name: `确认永久删除对话 ${title}`, + }), + ).toBeInTheDocument(); + await waitFor(() => expect(confirm).toBeEnabled()); - vi.mocked(api.agent.cancel).mockResolvedValueOnce() - fireEvent.click(confirm) + vi.mocked(api.agent.cancel).mockResolvedValueOnce(); + fireEvent.click(confirm); await waitFor(() => expect( - screen.queryByRole('alertdialog', { - name: `确认永久删除对话 ${title}` - }) - ).not.toBeInTheDocument() - ) - }) + screen.queryByRole("alertdialog", { + name: `确认永久删除对话 ${title}`, + }), + ).not.toBeInTheDocument(), + ); + }); - it('renders streamed reasoning, text, and tools in event order', async () => { - render() + it("renders streamed reasoning, text, and tools in event order", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '帮我分析项目' } - }) - await waitFor(() => expect(screen.getByLabelText('发送')).toBeEnabled()) - fireEvent.click(screen.getByLabelText('发送')) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "帮我分析项目" }, + }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.click(screen.getByLabelText("发送")); - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] - expect(request?.prompt).toBe('帮我分析项目') + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; + expect(request?.prompt).toBe("帮我分析项目"); expect(request?.runtimeSelection).toEqual({ - provider: 'model', - profileId: modelProfileId - }) + provider: "model", + profileId: modelProfileId, + }); expect( screen - .getByText('正在连接 Agent Runtime') - .querySelector('.message__status-dot') - ).toHaveClass('message__status-dot--active') + .getByText("正在连接 Agent Runtime") + .querySelector(".message__status-dot"), + ).toHaveClass("message__status-dot--active"); const userMessage = screen - .getAllByText('帮我分析项目') - .map((element) => element.closest('article')) - .find((element) => element?.classList.contains('message--user')) - expect(userMessage).toHaveClass('message--user') + .getAllByText("帮我分析项目") + .map((element) => element.closest("article")) + .find((element) => element?.classList.contains("message--user")); + expect(userMessage).toHaveClass("message--user"); act(() => { if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } agentListener?.({ requestId: request.requestId, - type: 'text', - delta: '这是' - }) + type: "text", + delta: "这是", + }); agentListener?.({ requestId: request.requestId, - type: 'text', - delta: '回答内容' - }) + type: "text", + delta: "回答内容", + }); agentListener?.({ requestId: request.requestId, - type: 'reasoning', - delta: '先检查项目结构' - }) - }) + type: "reasoning", + delta: "先检查项目结构", + }); + }); - expect(await screen.findByText('这是回答内容')).toBeInTheDocument() - const streamingReasoning = screen - .getByText('正在推理') - .closest('details') - expect(streamingReasoning).toHaveAttribute('open') - expect(screen.getByText('先检查项目结构')).toBeInTheDocument() - const reasoningContent = - streamingReasoning!.querySelector( - '.message-reasoning__content' - ) + expect(await screen.findByText("这是回答内容")).toBeInTheDocument(); + const streamingReasoning = screen.getByText("正在推理").closest("details"); + expect(streamingReasoning).toHaveAttribute("open"); + expect(screen.getByText("先检查项目结构")).toBeInTheDocument(); + const reasoningContent = streamingReasoning!.querySelector( + ".message-reasoning__content", + ); if (!reasoningContent) { - throw new Error('Missing reasoning content') + throw new Error("Missing reasoning content"); } - const reasoningScrollTo = vi.fn() - reasoningContent.scrollTo = reasoningScrollTo - Object.defineProperty(reasoningContent, 'scrollHeight', { + const reasoningScrollTo = vi.fn(); + reasoningContent.scrollTo = reasoningScrollTo; + Object.defineProperty(reasoningContent, "scrollHeight", { configurable: true, - value: 640 - }) + value: 640, + }); act(() => { if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } agentListener?.({ requestId: request.requestId, - type: 'reasoning', - delta: ',再确认依赖' - }) - }) + type: "reasoning", + delta: ",再确认依赖", + }); + }); expect(reasoningScrollTo).toHaveBeenLastCalledWith({ top: 640, - behavior: 'auto' - }) - expect(screen.getByText('先检查项目结构,再确认依赖')).toBeInTheDocument() + behavior: "auto", + }); + expect(screen.getByText("先检查项目结构,再确认依赖")).toBeInTheDocument(); act(() => { if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } agentListener?.({ requestId: request.requestId, - type: 'tool', - callId: 'call-1', - name: 'read', - state: 'running', - summary: 'OpenCode 工具:read', - input: '{"path":"README.md"}' - }) - agentListener?.({ - requestId: request.requestId, - type: 'tool', - callId: 'call-2', - name: 'grep', - state: 'completed', - summary: 'OpenCode 工具:grep', - input: '{"pattern":"runtime"}', - output: 'src/main/agent/runtime.ts' - }) - agentListener?.({ - requestId: request.requestId, - type: 'reasoning', - delta: '再检查关键文件' - }) - agentListener?.({ - requestId: request.requestId, - type: 'text', - delta: '最终结论' - }) - agentListener?.({ - requestId: request.requestId, - type: 'tool', - callId: 'call-1', - name: 'read', - state: 'completed', - summary: 'OpenCode 工具:read', + type: "tool", + callId: "call-1", + name: "read", + state: "running", + summary: "OpenCode 工具:read", input: '{"path":"README.md"}', - output: - 'README contents\nAuthorization: Bearer visible-token' - }) - }) + }); + agentListener?.({ + requestId: request.requestId, + type: "tool", + callId: "call-2", + name: "grep", + state: "completed", + summary: "OpenCode 工具:grep", + input: '{"pattern":"runtime"}', + output: "src/main/agent/runtime.ts", + }); + agentListener?.({ + requestId: request.requestId, + type: "reasoning", + delta: "再检查关键文件", + }); + agentListener?.({ + requestId: request.requestId, + type: "text", + delta: "最终结论", + }); + agentListener?.({ + requestId: request.requestId, + type: "tool", + callId: "call-1", + name: "read", + state: "completed", + summary: "OpenCode 工具:read", + input: '{"path":"README.md"}', + output: "README contents\nAuthorization: Bearer visible-token", + }); + }); - const assistantArticle = screen - .getByText('最终结论') - .closest('article') + const assistantArticle = screen.getByText("最终结论").closest("article"); const orderedBlocks = [ - ...assistantArticle!.querySelectorAll('.message-blocks > *') - ].map((element) => element.textContent) + ...assistantArticle!.querySelectorAll(".message-blocks > *"), + ].map((element) => element.textContent); expect(orderedBlocks).toEqual([ - expect.stringContaining('这是回答内容'), - expect.stringContaining('先检查项目结构'), - expect.stringContaining('OpenCode 工具:read'), - expect.stringContaining('再检查关键文件'), - expect.stringContaining('最终结论') - ]) + expect.stringContaining("这是回答内容"), + expect.stringContaining("先检查项目结构"), + expect.stringContaining("OpenCode 工具:read"), + expect.stringContaining("再检查关键文件"), + expect.stringContaining("最终结论"), + ]); + expect(screen.getAllByText("OpenCode 工具:read")).toHaveLength(1); expect( - screen.getAllByText('OpenCode 工具:read') - ).toHaveLength(1) - expect( - screen.getByRole('region', { name: '工具执行,共 2 项' }) - ).toBeInTheDocument() - const readTool = screen.getByText('read').closest('details') + screen.getByRole("region", { name: "工具执行,共 2 项" }), + ).toBeInTheDocument(); + const readTool = screen.getByText("read").closest("details"); const rawToolOutput = - 'README contents\nAuthorization: Bearer visible-token' - expect(readTool).not.toHaveAttribute('open') + "README contents\nAuthorization: Bearer visible-token"; + expect(readTool).not.toHaveAttribute("open"); expect( - screen.getByText( - (_, element) => element?.textContent === rawToolOutput - ) - ).not.toBeVisible() - fireEvent.click(screen.getByText('read').closest('summary')!) - expect(readTool).toHaveAttribute('open') - expect(within(readTool!).getByText('调用参数')).toBeVisible() + screen.getByText((_, element) => element?.textContent === rawToolOutput), + ).not.toBeVisible(); + fireEvent.click(screen.getByText("read").closest("summary")!); + expect(readTool).toHaveAttribute("open"); + expect(within(readTool!).getByText("调用参数")).toBeVisible(); expect( within(readTool!).getByText( - (_, element) => element?.textContent === rawToolOutput - ) - ).toBeVisible() - const activeReasoning = screen.getAllByText('正在推理') - expect(activeReasoning).toHaveLength(2) + (_, element) => element?.textContent === rawToolOutput, + ), + ).toBeVisible(); + const activeReasoning = screen.getAllByText("正在推理"); + expect(activeReasoning).toHaveLength(2); for (const reasoning of activeReasoning) { - expect(reasoning.closest('details')).toHaveAttribute('open') + expect(reasoning.closest("details")).toHaveAttribute("open"); } act(() => { if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); - const completedReasoning = await screen.findAllByText('推理过程') - expect(completedReasoning).toHaveLength(2) + const completedReasoning = await screen.findAllByText("推理过程"); + expect(completedReasoning).toHaveLength(2); for (const reasoning of completedReasoning) { - expect(reasoning.closest('details')).not.toHaveAttribute('open') + expect(reasoning.closest("details")).not.toHaveAttribute("open"); } - expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge') - }) + expect(screen.getByText("项目:默认项目")).toHaveClass("scope-badge"); + }); - it('keeps every activity detail in a long tool chain', async () => { - render() + it("keeps every activity detail in a long tool chain", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '执行长工具链' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "执行长工具链" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { for (let index = 0; index < 501; index += 1) { agentListener?.({ requestId: request.requestId, - type: 'tool', + type: "tool", callId: `call-${index + 1}`, - name: index === 500 ? `read${'长'.repeat(300)}` : 'read', - state: 'completed', + name: index === 500 ? `read${"长".repeat(300)}` : "read", + state: "completed", summary: index === 500 - ? `OpenCode 工具:read 501 ${'详情'.repeat(2_100)}` - : `OpenCode 工具:read ${index + 1}` - }) + ? `OpenCode 工具:read 501 ${"详情".repeat(2_100)}` + : `OpenCode 工具:read ${index + 1}`, + }); } - }) + }); expect( - screen.getByRole('region', { name: '工具执行,共 501 项' }) - ).toBeInTheDocument() + screen.getByRole("region", { name: "工具执行,共 501 项" }), + ).toBeInTheDocument(); expect( screen.queryByText( - '活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行' - ) - ).not.toBeInTheDocument() + "活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行", + ), + ).not.toBeInTheDocument(); await waitFor( () => { const saved = - vi.mocked(api.activityHistory.replace).mock.calls.at(-1)?.[0] ?? - [] - expect( - saved.filter((record) => record.kind === 'tool') - ).toHaveLength(501) - const lastTool = saved.find((record) => record.callId === 'call-501') - expect(lastTool?.detail.length).toBeGreaterThan(4_000) - expect(lastTool?.title).toHaveLength(240) + vi.mocked(api.activityHistory.replace).mock.calls.at(-1)?.[0] ?? []; + expect(saved.filter((record) => record.kind === "tool")).toHaveLength( + 501, + ); + const lastTool = saved.find((record) => record.callId === "call-501"); + expect(lastTool?.detail.length).toBeGreaterThan(4_000); + expect(lastTool?.title).toHaveLength(240); }, - { timeout: 3_000 } - ) - }) + { timeout: 3_000 }, + ); + }); - it('keeps adjacent streamed text blocks exact in StrictMode', async () => { + it("keeps adjacent streamed text blocks exact in StrictMode", async () => { render( - - ) + , + ); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '读取验收标记' } - }) - await waitFor(() => expect(screen.getByLabelText('发送')).toBeEnabled()) - fireEvent.click(screen.getByLabelText('发送')) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "读取验收标记" }, + }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.click(screen.getByLabelText("发送")); - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'text', - delta: 'GO' - }) + type: "text", + delta: "GO", + }); agentListener?.({ requestId: request.requestId, - type: 'text', - delta: 'ODBUDDY_REMOTE_TOOL_4C6B9D8A' - }) - }) + type: "text", + delta: "ODBUDDY_REMOTE_TOOL_4C6B9D8A", + }); + }); - const expected = 'GOODBUDDY_REMOTE_TOOL_4C6B9D8A' - const text = await screen.findByText(expected) - const assistantArticle = text.closest('article') - expect(assistantArticle).not.toBeNull() + const expected = "GOODBUDDY_REMOTE_TOOL_4C6B9D8A"; + const text = await screen.findByText(expected); + const assistantArticle = text.closest("article"); + expect(assistantArticle).not.toBeNull(); expect( - assistantArticle!.querySelectorAll( - '.message-blocks > .message__content' - ) - ).toHaveLength(1) + assistantArticle!.querySelectorAll(".message-blocks > .message__content"), + ).toHaveLength(1); expect( - assistantArticle!.querySelector('.message-blocks') - ).toHaveTextContent(expected) + assistantArticle!.querySelector(".message-blocks"), + ).toHaveTextContent(expected); act(() => { agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) - }) + type: "done", + }); + }); + }); - it('persists only the changed conversation and streamed assistant message', async () => { - const activeConversationId = - '00000000-0000-4000-8000-000000000441' - const activeMessageId = - '00000000-0000-4000-8000-000000000442' - const unrelatedConversationId = - '00000000-0000-4000-8000-000000000443' - const unrelatedMessageId = - '00000000-0000-4000-8000-000000000444' + it("persists only the changed conversation and streamed assistant message", async () => { + const activeConversationId = "00000000-0000-4000-8000-000000000441"; + const activeMessageId = "00000000-0000-4000-8000-000000000442"; + const unrelatedConversationId = "00000000-0000-4000-8000-000000000443"; + const unrelatedMessageId = "00000000-0000-4000-8000-000000000444"; vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: activeConversationId, projectId, - title: '增量持久化会话', + title: "增量持久化会话", updatedAt: 1_775_000_000_002, messages: [ { id: activeMessageId, - role: 'assistant', - content: '原有消息', + role: "assistant", + content: "原有消息", createdAt: 1_775_000_000_002, - state: 'complete' - } - ] + state: "complete", + }, + ], }, { id: unrelatedConversationId, projectId, - title: '无关会话', + title: "无关会话", updatedAt: 1_775_000_000_001, messages: [ { id: unrelatedMessageId, - role: 'assistant', - content: '不应重复保存', + role: "assistant", + content: "不应重复保存", createdAt: 1_775_000_000_001, - state: 'complete' - } - ] - } - ]) - render() - expect(await screen.findByText('原有消息')).toBeInTheDocument() + state: "complete", + }, + ], + }, + ]); + render(); + expect(await screen.findByText("原有消息")).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '只更新当前会话' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "只更新当前会话" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } - await waitFor(() => - expect(api.conversations.saveLocal).toHaveBeenCalled() - ) - vi.mocked(api.conversations.saveLocal).mockClear() + await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalled()); + vi.mocked(api.conversations.saveLocal).mockClear(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'text', - delta: '流式增量' - }) - }) + type: "text", + delta: "流式增量", + }); + }); await waitFor( () => { const batch = vi .mocked(api.conversations.saveLocal) - .mock.calls.at(-1)?.[0] - expect(batch).toHaveLength(1) - expect(batch?.[0]?.header.id).toBe(activeConversationId) + .mock.calls.at(-1)?.[0]; + expect(batch).toHaveLength(1); + expect(batch?.[0]?.header.id).toBe(activeConversationId); expect(batch?.[0]?.messages).toEqual([ expect.objectContaining({ - role: 'assistant', - content: '流式增量', - state: 'streaming' - }) - ]) + role: "assistant", + content: "流式增量", + state: "streaming", + }), + ]); expect( - batch?.some( - (entry) => entry.header.id === unrelatedConversationId - ) - ).toBe(false) + batch?.some((entry) => entry.header.id === unrelatedConversationId), + ).toBe(false); expect( - batch?.flatMap((entry) => entry.messages).some( - (message) => - message.id === activeMessageId || - message.id === unrelatedMessageId || - message.role === 'user' - ) - ).toBe(false) + batch + ?.flatMap((entry) => entry.messages) + .some( + (message) => + message.id === activeMessageId || + message.id === unrelatedMessageId || + message.role === "user", + ), + ).toBe(false); }, - { timeout: 2_000 } - ) - }) + { timeout: 2_000 }, + ); + }); - it('flushes pending local conversation changes before quit', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000445' + it("flushes pending local conversation changes before quit", async () => { + const conversationId = "00000000-0000-4000-8000-000000000445"; vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: conversationId, projectId, - title: '退出持久化会话', + title: "退出持久化会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000446', - role: 'assistant', - content: '已有内容', + id: "00000000-0000-4000-8000-000000000446", + role: "assistant", + content: "已有内容", createdAt: 1_775_000_000_000, - state: 'complete' - } - ] - } - ]) - render() - expect(await screen.findByText('已有内容')).toBeInTheDocument() - vi.mocked(api.conversations.saveLocal).mockClear() + state: "complete", + }, + ], + }, + ]); + render(); + expect(await screen.findByText("已有内容")).toBeInTheDocument(); + vi.mocked(api.conversations.saveLocal).mockClear(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '退出前必须保存' } - }) - fireEvent.click(screen.getByLabelText('发送')) - expect( - await screen.findByText('退出前必须保存') - ).toBeInTheDocument() + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "退出前必须保存" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + expect(await screen.findByText("退出前必须保存")).toBeInTheDocument(); if (!beforeQuitListener) { - throw new Error('Missing before-quit persistence listener') + throw new Error("Missing before-quit persistence listener"); } - await act(async () => beforeQuitListener?.()) + await act(async () => beforeQuitListener?.()); expect(api.conversations.saveLocal).toHaveBeenCalledWith([ expect.objectContaining({ header: expect.objectContaining({ id: conversationId }), messages: expect.arrayContaining([ expect.objectContaining({ - role: 'user', - content: '退出前必须保存', - state: 'complete' - }) - ]) - }) - ]) - }) + role: "user", + content: "退出前必须保存", + state: "complete", + }), + ]), + }), + ]); + }); - it('replaces the direct-model thinking status with real reasoning', async () => { - render() + it("replaces the direct-model thinking status with real reasoning", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '分析这个问题' } - }) - const send = await screen.findByLabelText('发送') - await waitFor(() => expect(send).toBeEnabled()) - fireEvent.click(send) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "分析这个问题" }, + }); + const send = await screen.findByLabelText("发送"); + await waitFor(() => expect(send).toBeEnabled()); + fireEvent.click(send); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'status', - message: 'deepseek-v4-flash 正在思考' - }) - }) - expect( - screen.getByText('deepseek-v4-flash 正在思考') - ).toBeInTheDocument() + type: "status", + message: "deepseek-v4-flash 正在思考", + }); + }); + expect(screen.getByText("deepseek-v4-flash 正在思考")).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'reasoning', - delta: '正在分析真实推理内容' - }) - }) + type: "reasoning", + delta: "正在分析真实推理内容", + }); + }); expect( - screen.queryByText('deepseek-v4-flash 正在思考') - ).not.toBeInTheDocument() - expect(screen.getByText('正在推理').closest('details')).toHaveAttribute( - 'open' - ) - expect(screen.getByText('正在分析真实推理内容')).toBeVisible() - }) + screen.queryByText("deepseek-v4-flash 正在思考"), + ).not.toBeInTheDocument(); + expect(screen.getByText("正在推理").closest("details")).toHaveAttribute( + "open", + ); + expect(screen.getByText("正在分析真实推理内容")).toBeVisible(); + }); - it('updates context usage after model responses and keeps compression status', async () => { - const settings = await api.settings.getRuntime() + it("updates context usage after model responses and keeps compression status", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'model', + provider: "model", modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, - contextWindowTokens: 32_000 + contextWindowTokens: 32_000, })), contextCompression: { enabled: true, triggerTokens: 200_000, recentRawTokens: 32_000, - modelSource: { kind: 'current' }, - summaryPrompt: 'Preserve important facts.' - } - }) - render() + modelSource: { kind: "current" }, + summaryPrompt: "Preserve important facts.", + }, + }); + render(); - await screen.findByLabelText('向 GoodBuddy 提问') + await screen.findByLabelText("向 GoodBuddy 提问"); expect( - screen.queryByRole('progressbar', { - name: '当前上下文使用量' - }) - ).not.toBeInTheDocument() + screen.queryByRole("progressbar", { + name: "当前上下文使用量", + }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("只读问答,不修改文件")).not.toBeInTheDocument(); expect( - screen.queryByText('只读问答,不修改文件') - ).not.toBeInTheDocument() - expect( - await screen.findByText('快捷唤起:', { exact: false }) - ).toHaveTextContent('快捷唤起:Ctrl+Shift+Space') + await screen.findByText("快捷唤起:", { exact: false }), + ).toHaveTextContent("快捷唤起:Ctrl+Shift+Space"); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '中'.repeat(1_000) } - }) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "中".repeat(1_000) }, + }); expect( - screen.queryByRole('progressbar', { - name: '当前上下文使用量' - }) - ).not.toBeInTheDocument() - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + screen.queryByRole("progressbar", { + name: "当前上下文使用量", + }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-metrics', + type: "context-metrics", contextTokens: 22_000, effectiveTriggerTokens: 20_000, contextWindowTokens: 32_000, compressionEnabled: true, - source: 'provider' - }) - }) + source: "provider", + }); + }); expect( - screen.getByText('本次调用 22.0K / 32.0K · 69%') - ).toBeInTheDocument() + screen.getByText("本次调用 22.0K / 32.0K · 69%"), + ).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-compression', - scope: 'conversation', - state: 'started', + type: "context-compression", + scope: "conversation", + state: "started", estimatedBeforeTokens: 22_000, effectiveTriggerTokens: 20_000, contextWindowTokens: 32_000, recentRawTokens: 32_000, - coveredMessageCount: 2 - }) - }) - expect( - screen.getByText('正在压缩较早对话…') - ).toBeInTheDocument() + coveredMessageCount: 2, + }); + }); + expect(screen.getByText("正在压缩较早对话…")).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'status', - message: 'sonnet-5 正在思考' - }) - }) - expect( - screen.getByText('正在压缩较早对话…') - ).toBeInTheDocument() - expect(screen.getByText('sonnet-5 正在思考')).toBeInTheDocument() + type: "status", + message: "sonnet-5 正在思考", + }); + }); + expect(screen.getByText("正在压缩较早对话…")).toBeInTheDocument(); + expect(screen.getByText("sonnet-5 正在思考")).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-compression', - scope: 'conversation', - state: 'completed', + type: "context-compression", + scope: "conversation", + state: "completed", estimatedBeforeTokens: 22_000, estimatedAfterTokens: 9_000, effectiveTriggerTokens: 20_000, contextWindowTokens: 32_000, recentRawTokens: 32_000, coveredMessageCount: 2, - summaryTokens: 1_000 - }) - }) + summaryTokens: 1_000, + }); + }); expect( - screen.getByText( - '已压缩较早对话(估算) · ≈22.0K → ≈9.0K' - ) - ).toBeInTheDocument() + screen.getByText("已压缩较早对话(估算) · ≈22.0K → ≈9.0K"), + ).toBeInTheDocument(); expect( - screen.getByText( - '压缩后对话估算 ≈9.0K / 32.0K · 28%' - ) - ).toBeInTheDocument() + screen.getByText("压缩后对话估算 ≈9.0K / 32.0K · 28%"), + ).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-compression', - scope: 'agent-run', - state: 'started', + type: "context-compression", + scope: "agent-run", + state: "started", estimatedBeforeTokens: 24_000, effectiveTriggerTokens: 20_000, contextWindowTokens: 32_000, recentRawTokens: 4_000, - compressionCount: 1 - }) - }) - expect( - screen.getByText('正在整理 Agent 执行上下文…') - ).toBeInTheDocument() + compressionCount: 1, + }); + }); + expect(screen.getByText("正在整理 Agent 执行上下文…")).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-compression', - scope: 'agent-run', - state: 'completed', + type: "context-compression", + scope: "agent-run", + state: "completed", estimatedBeforeTokens: 24_000, estimatedAfterTokens: 11_000, effectiveTriggerTokens: 20_000, contextWindowTokens: 32_000, recentRawTokens: 4_000, - compressionCount: 2 - }) - }) + compressionCount: 2, + }); + }); expect( screen.getByText( - 'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K' - ) - ).toBeInTheDocument() + "Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K", + ), + ).toBeInTheDocument(); expect( - screen.getByText( - '已压缩较早对话(估算) · ≈22.0K → ≈9.0K' - ) - ).toBeInTheDocument() + screen.getByText("已压缩较早对话(估算) · ≈22.0K → ≈9.0K"), + ).toBeInTheDocument(); expect( - screen.getByText( - '压缩后对话估算 ≈9.0K / 32.0K · 28%' - ) - ).toBeInTheDocument() + screen.getByText("压缩后对话估算 ≈9.0K / 32.0K · 28%"), + ).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-compression', - scope: 'conversation', - state: 'completed', + type: "context-compression", + scope: "conversation", + state: "completed", estimatedBeforeTokens: 24_000, estimatedAfterTokens: 8_500, effectiveTriggerTokens: 20_000, contextWindowTokens: 32_000, recentRawTokens: 4_000, - coveredMessageCount: 4 - }) - }) + coveredMessageCount: 4, + }); + }); expect( screen.getByText( - 'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K' - ) - ).toBeInTheDocument() + "Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K", + ), + ).toBeInTheDocument(); expect( - screen.getByText( - '已压缩较早对话(估算) · ≈24.0K → ≈8.5K' - ) - ).toBeInTheDocument() + screen.getByText("已压缩较早对话(估算) · ≈24.0K → ≈8.5K"), + ).toBeInTheDocument(); expect( - screen.getByText( - '压缩后对话估算 ≈8.5K / 32.0K · 27%' - ) - ).toBeInTheDocument() + screen.getByText("压缩后对话估算 ≈8.5K / 32.0K · 27%"), + ).toBeInTheDocument(); expect( Array.from( - document.querySelectorAll( - '.context-compression-event__label' - ) - ).map((element) => element.textContent) + document.querySelectorAll(".context-compression-event__label"), + ).map((element) => element.textContent), ).toEqual([ - 'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K', - '已压缩较早对话(估算) · ≈24.0K → ≈8.5K' - ]) + "Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K", + "已压缩较早对话(估算) · ≈24.0K → ≈8.5K", + ]); act(() => { agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); expect( screen.getByText( - 'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K' - ) - ).toBeInTheDocument() - }) + "Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K", + ), + ).toBeInTheDocument(); + }); - it('does not present the compression threshold as a context-window percentage', async () => { - const settings = await api.settings.getRuntime() + it("does not present the compression threshold as a context-window percentage", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'model', + provider: "model", modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, - contextWindowTokens: undefined + contextWindowTokens: undefined, })), contextCompression: { enabled: true, triggerTokens: 12_000, recentRawTokens: 4_000, - modelSource: { kind: 'current' }, - summaryPrompt: 'Preserve important facts.' - } - }) - render() + modelSource: { kind: "current" }, + summaryPrompt: "Preserve important facts.", + }, + }); + render(); - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '检查上下文显示' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "检查上下文显示" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-metrics', + type: "context-metrics", contextTokens: 22_000, effectiveTriggerTokens: 20_000, compressionEnabled: true, - source: 'provider' - }) - }) + source: "provider", + }); + }); expect( - screen.getByText('本次调用 22.0K · 压缩线 12.0K') - ).toBeInTheDocument() + screen.getByText("本次调用 22.0K · 压缩线 12.0K"), + ).toBeInTheDocument(); expect( - screen.queryByRole('progressbar', { - name: '当前上下文使用量' - }) - ).not.toBeInTheDocument() - expect(screen.queryByText('110%')).not.toBeInTheDocument() + screen.queryByRole("progressbar", { + name: "当前上下文使用量", + }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("110%")).not.toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-compression', - scope: 'conversation', - state: 'completed', + type: "context-compression", + scope: "conversation", + state: "completed", estimatedBeforeTokens: 22_000, estimatedAfterTokens: 9_400, effectiveTriggerTokens: 20_000, recentRawTokens: 4_000, - coveredMessageCount: 2 - }) - }) + coveredMessageCount: 2, + }); + }); expect( - screen.getByText( - '压缩后对话估算 ≈9.4K · 压缩线 12.0K' - ) - ).toBeInTheDocument() + screen.getByText("压缩后对话估算 ≈9.4K · 压缩线 12.0K"), + ).toBeInTheDocument(); expect( - screen.queryByText('本次调用 22.0K · 压缩线 12.0K') - ).not.toBeInTheDocument() - }) + screen.queryByText("本次调用 22.0K · 压缩线 12.0K"), + ).not.toBeInTheDocument(); + }); - it('updates the composer compression line immediately after settings change', async () => { - const settings = await api.settings.getRuntime() + it("updates the composer compression line immediately after settings change", async () => { + const settings = await api.settings.getRuntime(); const initialSettings = { ...settings, - provider: 'model' as const, + provider: "model" as const, modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, - contextWindowTokens: undefined + contextWindowTokens: undefined, })), contextCompression: { enabled: true, triggerTokens: 20_000, recentRawTokens: 4_000, - modelSource: { kind: 'current' as const }, - summaryPrompt: 'Preserve important facts.' - } - } + modelSource: { kind: "current" as const }, + summaryPrompt: "Preserve important facts.", + }, + }; vi.mocked(api.settings.getRuntime) .mockResolvedValueOnce(initialSettings) - .mockResolvedValueOnce(initialSettings) + .mockResolvedValueOnce(initialSettings); vi.mocked(api.settings.updateRuntime).mockImplementationOnce( async (input) => ({ ...initialSettings, contextCompression: - input.contextCompression ?? - initialSettings.contextCompression - }) - ) - render() + input.contextCompression ?? initialSettings.contextCompression, + }), + ); + render(); - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '检查压缩线设置刷新' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "检查压缩线设置刷新" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-metrics', + type: "context-metrics", contextTokens: 9_000, effectiveTriggerTokens: 20_000, compressionEnabled: true, - source: 'provider' - }) - }) + source: "provider", + }); + }); expect( - screen.getByText('本次调用 9.0K · 压缩线 20.0K') - ).toBeInTheDocument() + screen.getByText("本次调用 9.0K · 压缩线 20.0K"), + ).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); - fireEvent.click( - screen.getByRole('button', { name: /本地工作区/u }) - ) - await screen.findByRole('heading', { name: '设置中心' }) - fireEvent.click( - screen.getByRole('tab', { name: '上下文控制' }) - ) - const trigger = await screen.findByLabelText('压缩触发阈值') - fireEvent.change(trigger, { target: { value: '12' } }) - fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + fireEvent.click(screen.getByRole("button", { name: /本地工作区/u })); + await screen.findByRole("heading", { name: "设置中心" }); + fireEvent.click(screen.getByRole("tab", { name: "上下文控制" })); + const trigger = await screen.findByLabelText("压缩触发阈值"); + fireEvent.change(trigger, { target: { value: "12" } }); + fireEvent.click(screen.getByRole("button", { name: "保存设置" })); await waitFor(() => expect(api.settings.updateRuntime).toHaveBeenCalledWith( expect.objectContaining({ contextCompression: expect.objectContaining({ - triggerTokens: 12_000 - }) - }) - ) - ) + triggerTokens: 12_000, + }), + }), + ), + ); - fireEvent.click(screen.getByRole('button', { name: '对话' })) + fireEvent.click(screen.getByRole("button", { name: "对话" })); expect( - await screen.findByText('本次调用 9.0K · 压缩线 12.0K') - ).toBeInTheDocument() - }) + await screen.findByText("本次调用 9.0K · 压缩线 12.0K"), + ).toBeInTheDocument(); + }); - it('guards sidebar navigation away from dirty Settings drafts', async () => { - render() + it("guards sidebar navigation away from dirty Settings drafts", async () => { + render(); - fireEvent.click(await screen.findByText('本地工作区')) - await screen.findByRole('heading', { name: '设置中心' }) - fireEvent.change(await screen.findByLabelText('默认工作区目录'), { - target: { value: 'C:\\Unsaved from App' } - }) - const knowledgeNavigation = screen.getByRole('button', { - name: '知识库' - }) - fireEvent.click(knowledgeNavigation) + fireEvent.click(await screen.findByText("本地工作区")); + await screen.findByRole("heading", { name: "设置中心" }); + fireEvent.change(await screen.findByLabelText("默认工作区目录"), { + target: { value: "C:\\Unsaved from App" }, + }); + const knowledgeNavigation = screen.getByRole("button", { + name: "知识库", + }); + fireEvent.click(knowledgeNavigation); + expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改"); + fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" })); expect( - screen.getByRole('heading', { name: '设置中心' }) - ).toBeVisible() - expect(screen.getByRole('alert')).toHaveTextContent( - '当前设置有未保存更改' - ) - fireEvent.click( - screen.getByRole('button', { name: '放弃更改并关闭' }) - ) - expect( - await screen.findByRole('heading', { name: '知识库' }) - ).toBeVisible() - await waitFor(() => expect(knowledgeNavigation).toHaveFocus()) - }) + await screen.findByRole("heading", { name: "知识库" }), + ).toBeVisible(); + await waitFor(() => expect(knowledgeNavigation).toHaveFocus()); + }); - it('restores focus outside Settings after closing the page', async () => { - render() + it("restores focus outside Settings after closing the page", async () => { + render(); - await screen.findByLabelText('向 GoodBuddy 提问') - const settingsTrigger = screen.getByRole('button', { - name: /本地工作区/u - }) - fireEvent.click(settingsTrigger) - await screen.findByRole('heading', { name: '设置中心' }) + await screen.findByLabelText("向 GoodBuddy 提问"); + const settingsTrigger = screen.getByRole("button", { + name: /本地工作区/u, + }); + fireEvent.click(settingsTrigger); + await screen.findByRole("heading", { name: "设置中心" }); - fireEvent.click( - screen.getByRole('button', { name: '关闭设置' }) - ) + fireEvent.click(screen.getByRole("button", { name: "关闭设置" })); - await waitFor(() => expect(settingsTrigger).toHaveFocus()) - }) + await waitFor(() => expect(settingsTrigger).toHaveFocus()); + }); - it('focuses the Composer after closing automatically opened Settings', async () => { + it("focuses the Composer after closing automatically opened Settings", async () => { vi.mocked(api.agent.getStatus).mockResolvedValueOnce({ - id: 'setup', - label: '未配置', + id: "setup", + label: "未配置", available: false, supportsToolExecution: false, - detail: '尚未配置 Runtime' - }) - render() + detail: "尚未配置 Runtime", + }); + render(); - await screen.findByRole('heading', { name: '设置中心' }) - const composer = screen.getByLabelText('向 GoodBuddy 提问') - fireEvent.click( - screen.getByRole('button', { name: '关闭设置' }) - ) + await screen.findByRole("heading", { name: "设置中心" }); + const composer = screen.getByLabelText("向 GoodBuddy 提问"); + fireEvent.click(screen.getByRole("button", { name: "关闭设置" })); - await waitFor(() => expect(composer).toHaveFocus()) - }) + await waitFor(() => expect(composer).toHaveFocus()); + }); - it('updates and removes the composer shortcut hint immediately after saving Settings', async () => { + it("updates and removes the composer shortcut hint immediately after saving Settings", async () => { let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: false, - updateSource: 'github', - modelDownloadSource: 'modelscope', + updateSource: "github", + modelDownloadSource: "modelscope", localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate', - magicNoteCommentFormat: 'combined' - } + magicNoteCommentMode: "immediate", + magicNoteCommentFormat: "combined", + }; let shortcutSnapshot: GlobalShortcutSettingsSnapshot = { settings: { enabled: true, - accelerator: 'CommandOrControl+Shift+Space' + accelerator: "CommandOrControl+Shift+Space", }, defaultSettings: { enabled: true, - accelerator: 'CommandOrControl+Shift+Space' + accelerator: "CommandOrControl+Shift+Space", }, - platform: 'win32', - displayAccelerator: 'Ctrl+Shift+Space', + platform: "win32", + displayAccelerator: "Ctrl+Shift+Space", registered: true, - registeredAccelerator: 'CommandOrControl+Shift+Space', - status: 'registered' - } + registeredAccelerator: "CommandOrControl+Shift+Space", + status: "registered", + }; api.updates = { getSettings: vi.fn(async () => ({ ...applicationSettings })), updateSettings: vi.fn(async (input) => { - applicationSettings = { ...applicationSettings, ...input } - return { ...applicationSettings } + applicationSettings = { ...applicationSettings, ...input }; + return { ...applicationSettings }; }), check: vi.fn(), openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; api.shortcuts = { getSettings: vi.fn(async () => shortcutSnapshot), updateSettings: vi.fn(async (input) => { @@ -4543,861 +4269,795 @@ describe('App', () => { ...shortcutSnapshot, settings: input, displayAccelerator: - input.accelerator === 'Control+Alt+K' - ? 'Ctrl+Alt+K' - : 'Ctrl+Shift+Space', + input.accelerator === "Control+Alt+K" + ? "Ctrl+Alt+K" + : "Ctrl+Shift+Space", registered: input.enabled, - registeredAccelerator: input.enabled - ? input.accelerator - : undefined, - status: input.enabled ? 'registered' : 'disabled' - } - return { ok: true as const, snapshot: shortcutSnapshot } - }) - } + registeredAccelerator: input.enabled ? input.accelerator : undefined, + status: input.enabled ? "registered" : "disabled", + }; + return { ok: true as const, snapshot: shortcutSnapshot }; + }), + }; try { - render() - expect( - await screen.findByText('Ctrl+Shift+Space') - ).toBeInTheDocument() - fireEvent.click(await screen.findByText('本地工作区')) - await screen.findByRole('heading', { name: '设置中心' }) - fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) - const shortcutInput = await screen.findByLabelText('快捷键') + render(); + expect(await screen.findByText("Ctrl+Shift+Space")).toBeInTheDocument(); + fireEvent.click(await screen.findByText("本地工作区")); + await screen.findByRole("heading", { name: "设置中心" }); + fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); + const shortcutInput = await screen.findByLabelText("快捷键"); fireEvent.change(shortcutInput, { - target: { value: 'Control+Alt+K' } - }) - fireEvent.click( - screen.getByRole('button', { name: '保存快捷键' }) - ) + target: { value: "Control+Alt+K" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存快捷键" })); await waitFor(() => expect(api.shortcuts?.updateSettings).toHaveBeenCalledWith({ enabled: true, - accelerator: 'Control+Alt+K' - }) - ) - fireEvent.click(screen.getByRole('button', { name: '对话' })) - expect(await screen.findByText('Ctrl+Alt+K')).toBeInTheDocument() + accelerator: "Control+Alt+K", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "对话" })); + expect(await screen.findByText("Ctrl+Alt+K")).toBeInTheDocument(); - fireEvent.click(await screen.findByText('本地工作区')) - await screen.findByRole('heading', { name: '设置中心' }) - fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) - const shortcutSwitch = await screen.findByRole('switch', { - name: '启用全局快捷键' - }) - fireEvent.click(shortcutSwitch) - fireEvent.click( - screen.getByRole('button', { name: '保存快捷键' }) - ) + fireEvent.click(await screen.findByText("本地工作区")); + await screen.findByRole("heading", { name: "设置中心" }); + fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); + const shortcutSwitch = await screen.findByRole("switch", { + name: "启用全局快捷键", + }); + fireEvent.click(shortcutSwitch); + fireEvent.click(screen.getByRole("button", { name: "保存快捷键" })); await waitFor(() => expect(api.shortcuts?.updateSettings).toHaveBeenCalledWith({ enabled: false, - accelerator: 'Control+Alt+K' - }) - ) - fireEvent.click(screen.getByRole('button', { name: '对话' })) - await screen.findByLabelText('向 GoodBuddy 提问') - expect( - screen.queryByText('Ctrl+Alt+K') - ).not.toBeInTheDocument() + accelerator: "Control+Alt+K", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "对话" })); + await screen.findByLabelText("向 GoodBuddy 提问"); + expect(screen.queryByText("Ctrl+Alt+K")).not.toBeInTheDocument(); } finally { - delete api.shortcuts - delete api.updates + delete api.shortcuts; + delete api.updates; } - }) + }); - it('restores persisted context usage and compression state after restart', async () => { - const settings = await api.settings.getRuntime() - const profile = settings.modelProfiles[0]! - const conversationId = - '00000000-0000-4000-8000-000000000451' + it("restores persisted context usage and compression state after restart", async () => { + const settings = await api.settings.getRuntime(); + const profile = settings.modelProfiles[0]!; + const conversationId = "00000000-0000-4000-8000-000000000451"; const compressionState = { - coveredHistoryDigest: 'a'.repeat(64), + coveredHistoryDigest: "a".repeat(64), coveredMessageCount: 2, - coveredFromMessageId: - '00000000-0000-4000-8000-000000000452', - coveredThroughMessageId: - '00000000-0000-4000-8000-000000000453', - summary: 'Persisted conversation summary' - } + coveredFromMessageId: "00000000-0000-4000-8000-000000000452", + coveredThroughMessageId: "00000000-0000-4000-8000-000000000453", + summary: "Persisted conversation summary", + }; vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'model', + provider: "model", defaultModelProfileId: profile.id, modelProfiles: settings.modelProfiles.map((candidate) => ({ ...candidate, - contextWindowTokens: undefined + contextWindowTokens: undefined, })), contextCompression: { enabled: true, triggerTokens: 12_000, recentRawTokens: 4_000, - modelSource: { kind: 'current' }, - summaryPrompt: 'Preserve important facts.' - } - }) + modelSource: { kind: "current" }, + summaryPrompt: "Preserve important facts.", + }, + }); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: conversationId, projectId, runtimeSelection: { - provider: 'model', - profileId: profile.id + provider: "model", + profileId: profile.id, }, contextMetrics: { runtimeSelectionKey: `model:${profile.id}`, contextTokens: 9_000, - source: 'estimated', - basis: 'conversation' + source: "estimated", + basis: "conversation", }, contextCompressionState: compressionState, - title: '已压缩会话', + title: "已压缩会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000452', - role: 'user', - content: '此前问题', + id: "00000000-0000-4000-8000-000000000452", + role: "user", + content: "此前问题", createdAt: 1_775_000_000_000, - state: 'complete' + state: "complete", }, { - id: '00000000-0000-4000-8000-000000000453', - role: 'assistant', - content: '此前回答', + id: "00000000-0000-4000-8000-000000000453", + role: "assistant", + content: "此前回答", createdAt: 1_775_000_000_001, - state: 'complete', + state: "complete", contextCompression: { - state: 'completed', - scope: 'conversation', + state: "completed", + scope: "conversation", estimatedBeforeTokens: 22_000, - estimatedAfterTokens: 9_000 - } - } - ] - } - ]) - render() + estimatedAfterTokens: 9_000, + }, + }, + ], + }, + ]); + render(); expect( - await screen.findByText( - '压缩后对话估算 ≈9.0K · 压缩线 12.0K' - ) - ).toBeInTheDocument() + await screen.findByText("压缩后对话估算 ≈9.0K · 压缩线 12.0K"), + ).toBeInTheDocument(); expect( - screen.getByText( - '已压缩较早对话(估算) · ≈22.0K → ≈9.0K' - ) - ).toBeInTheDocument() + screen.getByText("已压缩较早对话(估算) · ≈22.0K → ≈9.0K"), + ).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '继续工作' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "继续工作" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); expect(run.mock.calls[0]?.[0].contextCompressionState).toEqual( - compressionState - ) + compressionState, + ); expect(run.mock.calls[0]?.[0]).toMatchObject({ historyMessageIds: [ - '00000000-0000-4000-8000-000000000452', - '00000000-0000-4000-8000-000000000453' + "00000000-0000-4000-8000-000000000452", + "00000000-0000-4000-8000-000000000453", ], currentUserMessageId: expect.any(String), - currentAssistantMessageId: expect.any(String) - }) - }) + currentAssistantMessageId: expect.any(String), + }); + }); - it('keeps a tool failure in details and hides retry after continuing', async () => { - render() + it("keeps a tool failure in details and hides retry after continuing", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '读取演示文稿' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "读取演示文稿" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } const toolError = - 'Cannot read binary file: D:\\workspace\\presentation.pptx' - const runtimeError = `OpenCode 工具执行失败(call-1):${toolError}` + "Cannot read binary file: D:\\workspace\\presentation.pptx"; + const runtimeError = `OpenCode 工具执行失败(call-1):${toolError}`; act(() => { agentListener?.({ requestId: request.requestId, - type: 'tool', - callId: 'call-1', - name: 'read', - state: 'failed', - summary: 'OpenCode 工具:read', + type: "tool", + callId: "call-1", + name: "read", + state: "failed", + summary: "OpenCode 工具:read", input: '{"path":"D:\\\\workspace\\\\presentation.pptx"}', - error: toolError - }) + error: toolError, + }); agentListener?.({ requestId: request.requestId, - type: 'error', - status: 'failed', - message: runtimeError - }) - }) + type: "error", + status: "failed", + message: runtimeError, + }); + }); - expect(screen.getByText(toolError)).toBeInTheDocument() - expect(screen.queryByText(runtimeError)).not.toBeInTheDocument() + expect(screen.getByText(toolError)).toBeInTheDocument(); + expect(screen.queryByText(runtimeError)).not.toBeInTheDocument(); expect( - screen.getByRole('button', { name: '重新编辑并发送' }) - ).toBeInTheDocument() + screen.getByRole("button", { name: "重新编辑并发送" }), + ).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '继续处理' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledTimes(2)) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "继续处理" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledTimes(2)); expect( - screen.queryByRole('button', { name: '重新编辑并发送' }) - ).not.toBeInTheDocument() - }) + screen.queryByRole("button", { name: "重新编辑并发送" }), + ).not.toBeInTheDocument(); + }); - it('submits knowledge scope without eager search or prompt injection and merges runtime references', async () => { - const libraryId = '11111111-1111-4111-8111-111111111111' + it("submits knowledge scope without eager search or prompt injection and merges runtime references", async () => { + const libraryId = "11111111-1111-4111-8111-111111111111"; vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({ libraries: [ { id: libraryId, - name: '产品知识', - description: '', - storageMode: 'managed', + name: "产品知识", + description: "", + storageMode: "managed", graphEnabled: false, - graphStrategy: 'rules', + graphStrategy: "rules", sourceCount: 1, documentCount: 1, - indexedDocumentCount: 1 - } + indexedDocumentCount: 1, + }, ], sources: [], documents: [], graphNodes: [], graphRelations: [], - evidence: [] - }) - render() - const knowledgeScopeTrigger = await screen.findByRole('button', { - name: '选择知识库,本次已启用 0 个' - }) - expect(knowledgeScopeTrigger).toHaveAttribute( - 'aria-haspopup', - 'dialog' - ) - fireEvent.click(knowledgeScopeTrigger) - expect(knowledgeScopeTrigger).toHaveAttribute( - 'aria-expanded', - 'true' - ) + evidence: [], + }); + render(); + const knowledgeScopeTrigger = await screen.findByRole("button", { + name: "选择知识库,本次已启用 0 个", + }); + expect(knowledgeScopeTrigger).toHaveAttribute("aria-haspopup", "dialog"); + fireEvent.click(knowledgeScopeTrigger); + expect(knowledgeScopeTrigger).toHaveAttribute("aria-expanded", "true"); expect( - screen.getByRole('dialog', { - name: '本次对话检索范围' - }) - ).toBeInTheDocument() + screen.getByRole("dialog", { + name: "本次对话检索范围", + }), + ).toBeInTheDocument(); await waitFor(() => - expect( - screen.getByRole('checkbox', { name: /产品知识/u }) - ).toHaveFocus() - ) - fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) - expect(knowledgeScopeTrigger).toHaveAttribute( - 'aria-expanded', - 'false' - ) - expect(knowledgeScopeTrigger).toHaveFocus() + expect(screen.getByRole("checkbox", { name: /产品知识/u })).toHaveFocus(), + ); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + expect(knowledgeScopeTrigger).toHaveAttribute("aria-expanded", "false"); + expect(knowledgeScopeTrigger).toHaveFocus(); - fireEvent.click(knowledgeScopeTrigger) - const scopeCheckbox = screen.getByRole('checkbox', { - name: /产品知识/u - }) - await waitFor(() => expect(scopeCheckbox).toHaveFocus()) - fireEvent.focusOut(scopeCheckbox, { relatedTarget: null }) - expect(knowledgeScopeTrigger).toHaveAttribute('aria-expanded', 'true') - fireEvent.click(screen.getByText('产品知识', { selector: 'span' })) - expect(scopeCheckbox).toBeChecked() - fireEvent.click(scopeCheckbox.closest('label')!) - expect(scopeCheckbox).not.toBeChecked() - fireEvent.click(scopeCheckbox.closest('label')!.querySelector('small')!) - expect(scopeCheckbox).toBeChecked() - const composerInput = screen.getByLabelText('向 GoodBuddy 提问') + fireEvent.click(knowledgeScopeTrigger); + const scopeCheckbox = screen.getByRole("checkbox", { + name: /产品知识/u, + }); + await waitFor(() => expect(scopeCheckbox).toHaveFocus()); + fireEvent.focusOut(scopeCheckbox, { relatedTarget: null }); + expect(knowledgeScopeTrigger).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(screen.getByText("产品知识", { selector: "span" })); + expect(scopeCheckbox).toBeChecked(); + fireEvent.click(scopeCheckbox.closest("label")!); + expect(scopeCheckbox).not.toBeChecked(); + fireEvent.click(scopeCheckbox.closest("label")!.querySelector("small")!); + expect(scopeCheckbox).toBeChecked(); + const composerInput = screen.getByLabelText("向 GoodBuddy 提问"); fireEvent.focusOut(scopeCheckbox, { - relatedTarget: composerInput - }) - composerInput.focus() - expect(knowledgeScopeTrigger).toHaveAttribute( - 'aria-expanded', - 'false' - ) - expect(composerInput).toHaveFocus() + relatedTarget: composerInput, + }); + composerInput.focus(); + expect(knowledgeScopeTrigger).toHaveAttribute("aria-expanded", "false"); + expect(composerInput).toHaveFocus(); fireEvent.change(composerInput, { - target: { value: '发布流程是什么?' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + target: { value: "发布流程是什么?" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; expect(request).toMatchObject({ - prompt: '发布流程是什么?', + prompt: "发布流程是什么?", knowledgeLibraryIds: [libraryId], - knowledgeRetrievalMode: 'auto' - }) - expect(api.knowledge.search).not.toHaveBeenCalled() - expect( - screen.queryByText(/查看 \d+ 条证据引用/u) - ).not.toBeInTheDocument() + knowledgeRetrievalMode: "auto", + }); + expect(api.knowledge.search).not.toHaveBeenCalled(); + expect(screen.queryByText(/查看 \d+ 条证据引用/u)).not.toBeInTheDocument(); act(() => { if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } for (let batch = 0; batch < 5; batch += 1) { agentListener?.({ requestId: request.requestId, - type: 'source-references', + type: "source-references", references: Array.from({ length: 25 }, (_, index) => ({ libraryId, - libraryName: '产品知识', + libraryName: "产品知识", documentId: crypto.randomUUID(), chunkId: crypto.randomUUID(), documentName: `发布手册 ${batch}-${index}`, sourceName: `release-${batch}-${index}.md`, locator: `第 ${batch}-${index} 节`, snippet: `证据 ${batch}-${index}`, - rank: index + 1 - })) - }) + rank: index + 1, + })), + }); } agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) - expect( - await screen.findByText('查看 20 条证据引用') - ).toBeInTheDocument() + type: "done", + }); + }); + expect(await screen.findByText("查看 20 条证据引用")).toBeInTheDocument(); vi.mocked(api.knowledge.getReferenceContext).mockResolvedValueOnce({ knowledgeBaseId: libraryId, - documentId: 'document-context', - chunkId: 'chunk-context', - documentTitle: '发布手册', - sourceDisplayName: 'release.md', - locator: '第 4 节', - matchedContent: '命中分块内容', - contextContent: '上文\n\n命中分块内容\n\n下文', - contextChunkIds: ['chunk-context'], - truncated: false - }) - fireEvent.click(screen.getByText('查看 20 条证据引用')) - fireEvent.click( - screen.getAllByRole('button', { name: '查看上下文' })[0]! - ) + documentId: "document-context", + chunkId: "chunk-context", + documentTitle: "发布手册", + sourceDisplayName: "release.md", + locator: "第 4 节", + matchedContent: "命中分块内容", + contextContent: "上文\n\n命中分块内容\n\n下文", + contextChunkIds: ["chunk-context"], + truncated: false, + }); + fireEvent.click(screen.getByText("查看 20 条证据引用")); + fireEvent.click(screen.getAllByRole("button", { name: "查看上下文" })[0]!); expect( - await screen.findByRole('dialog', { name: '引用上下文' }) - ).toBeInTheDocument() + await screen.findByRole("dialog", { name: "引用上下文" }), + ).toBeInTheDocument(); expect(api.knowledge.getReferenceContext).toHaveBeenCalledWith( expect.objectContaining({ - knowledgeBaseId: libraryId - }) - ) + knowledgeBaseId: libraryId, + }), + ); fireEvent.click( - screen.getAllByRole('button', { - name: '关闭引用上下文' - })[0]! - ) + screen.getAllByRole("button", { + name: "关闭引用上下文", + })[0]!, + ); await waitFor( () => { const persistedMessages = vi .mocked(api.conversations.saveLocal) .mock.calls.flatMap(([batch]) => - batch.flatMap((conversation) => conversation.messages) - ) + batch.flatMap((conversation) => conversation.messages), + ); const persisted = persistedMessages - .filter((message) => message.role === 'assistant') + .filter((message) => message.role === "assistant") .slice() .reverse() - .find( - (message) => message.sourceReferences?.length === 20 - ) - expect(persisted?.sourceReferences).toHaveLength(20) - expect(persisted?.sources).toBeUndefined() + .find((message) => message.sourceReferences?.length === 20); + expect(persisted?.sourceReferences).toHaveLength(20); + expect(persisted?.sources).toBeUndefined(); }, - { timeout: 2_000 } - ) - }) + { timeout: 2_000 }, + ); + }); - it('restores an independent knowledge scope for each conversation', async () => { - const libraryId = '11111111-1111-4111-8111-111111111111' - const firstConversationId = - '22222222-2222-4222-8222-222222222222' - const secondConversationId = - '33333333-3333-4333-8333-333333333333' + it("restores an independent knowledge scope for each conversation", async () => { + const libraryId = "11111111-1111-4111-8111-111111111111"; + const firstConversationId = "22222222-2222-4222-8222-222222222222"; + const secondConversationId = "33333333-3333-4333-8333-333333333333"; vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({ libraries: [ { id: libraryId, - name: '产品知识', - description: '', - storageMode: 'managed', + name: "产品知识", + description: "", + storageMode: "managed", graphEnabled: false, - graphStrategy: 'rules', + graphStrategy: "rules", sourceCount: 1, documentCount: 1, - indexedDocumentCount: 1 - } + indexedDocumentCount: 1, + }, ], sources: [], documents: [], graphNodes: [], graphRelations: [], - evidence: [] - }) + evidence: [], + }); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: firstConversationId, projectId, knowledgeLibraryIds: [libraryId], - knowledgeRetrievalMode: 'always', - title: '带知识范围', + knowledgeRetrievalMode: "always", + title: "带知识范围", updatedAt: 200, - messages: [] + messages: [], }, { id: secondConversationId, projectId, knowledgeLibraryIds: [], - knowledgeRetrievalMode: 'auto', - title: '空知识范围', + knowledgeRetrievalMode: "auto", + title: "空知识范围", updatedAt: 100, - messages: [] - } - ]) + messages: [], + }, + ]); - render() + render(); expect( - await screen.findByRole('button', { - name: '选择知识库,本次已启用 1 个' - }) - ).toBeInTheDocument() - fireEvent.click( - screen.getByRole('button', { name: /^空知识范围/u }) - ) + await screen.findByRole("button", { + name: "选择知识库,本次已启用 1 个", + }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^空知识范围/u })); expect( - screen.getByRole('button', { - name: '选择知识库,本次已启用 0 个' - }) - ).toBeInTheDocument() - fireEvent.click( - screen.getByRole('button', { name: /^带知识范围/u }) - ) + screen.getByRole("button", { + name: "选择知识库,本次已启用 0 个", + }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^带知识范围/u })); expect( - screen.getByRole('button', { - name: '选择知识库,本次已启用 1 个' - }) - ).toBeInTheDocument() - }) + screen.getByRole("button", { + name: "选择知识库,本次已启用 1 个", + }), + ).toBeInTheDocument(); + }); - it('persists and submits always-retrieve mode for the active conversation', async () => { - const libraryId = '11111111-1111-4111-8111-111111111111' + it("persists and submits always-retrieve mode for the active conversation", async () => { + const libraryId = "11111111-1111-4111-8111-111111111111"; vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({ libraries: [ { id: libraryId, - name: '产品知识', - description: '', - storageMode: 'managed', + name: "产品知识", + description: "", + storageMode: "managed", graphEnabled: false, - graphStrategy: 'rules', + graphStrategy: "rules", sourceCount: 1, documentCount: 1, - indexedDocumentCount: 1 - } + indexedDocumentCount: 1, + }, ], sources: [], documents: [], graphNodes: [], graphRelations: [], - evidence: [] - }) - render() - const knowledgeScope = await screen.findByRole('button', { - name: '选择知识库,本次已启用 0 个' - }) - fireEvent.click(knowledgeScope) + evidence: [], + }); + render(); + const knowledgeScope = await screen.findByRole("button", { + name: "选择知识库,本次已启用 0 个", + }); + fireEvent.click(knowledgeScope); + fireEvent.click(screen.getByRole("checkbox", { name: /产品知识/u })); + const retrievalMode = screen.getByRole("group", { + name: "知识检索方式", + }); fireEvent.click( - screen.getByRole('checkbox', { name: /产品知识/u }) - ) - const retrievalMode = screen.getByRole('group', { - name: '知识检索方式' - }) - fireEvent.click( - within(retrievalMode).getByRole('button', { - name: '每次先检索' - }) - ) + within(retrievalMode).getByRole("button", { + name: "每次先检索", + }), + ); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '必须查询发布流程' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "必须查询发布流程" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); expect(run.mock.calls[0]?.[0]).toMatchObject({ - prompt: '必须查询发布流程', + prompt: "必须查询发布流程", knowledgeLibraryIds: [libraryId], - knowledgeRetrievalMode: 'always' - }) - const request = run.mock.calls[0]?.[0] + knowledgeRetrievalMode: "always", + }); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'knowledge-retrieval', - mode: 'always', - state: 'degraded', + type: "knowledge-retrieval", + mode: "always", + state: "degraded", libraryCount: 1, resultCount: 2, durationMs: 18, - usedChannels: ['fts', 'cjk'], - warnings: ['向量模型未配置'] - }) - }) + usedChannels: ["fts", "cjk"], + warnings: ["向量模型未配置"], + }); + }); + expect(await screen.findByText("知识检索已降级")).toBeInTheDocument(); expect( - await screen.findByText('知识检索已降级') - ).toBeInTheDocument() - expect( - screen.getByText(/已检索 1 个知识库,获得 2 条结果/u) - ).toBeInTheDocument() - expect(screen.getByText('向量模型未配置')).toBeInTheDocument() + screen.getByText(/已检索 1 个知识库,获得 2 条结果/u), + ).toBeInTheDocument(); + expect(screen.getByText("向量模型未配置")).toBeInTheDocument(); await waitFor(() => { const snapshots = vi .mocked(api.conversations.saveLocal) - .mock.calls.flatMap(([batch]) => batch) + .mock.calls.flatMap(([batch]) => batch); expect( snapshots?.some( (conversation) => - conversation.header.knowledgeRetrievalMode === 'always' - ) - ).toBe(true) - }) - }) + conversation.header.knowledgeRetrievalMode === "always", + ), + ).toBe(true); + }); + }); - it('keeps a running response visible when cancellation fails', async () => { + it("keeps a running response visible when cancellation fails", async () => { vi.mocked(api.agent.cancel).mockRejectedValueOnce( - new Error('cancel failed') - ) - render() + new Error("cancel failed"), + ); + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '开始一个长任务' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "开始一个长任务" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); - fireEvent.click(await screen.findByLabelText('停止生成')) + fireEvent.click(await screen.findByLabelText("停止生成")); - await waitFor(() => - expect(api.agent.cancel).toHaveBeenCalledOnce() - ) + await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledOnce()); expect( - await screen.findByText(/停止生成失败,请重试/u) - ).toBeInTheDocument() - expect(screen.getByLabelText('停止生成')).toBeInTheDocument() - vi.useFakeTimers() + await screen.findByText(/停止生成失败,请重试/u), + ).toBeInTheDocument(); + expect(screen.getByLabelText("停止生成")).toBeInTheDocument(); + vi.useFakeTimers(); try { - act(() => vi.advanceTimersByTime(10_000)) - expect( - screen.getByText(/停止生成失败,请重试/u) - ).toBeInTheDocument() + act(() => vi.advanceTimersByTime(10_000)); + expect(screen.getByText(/停止生成失败,请重试/u)).toBeInTheDocument(); fireEvent.click( - screen.getByRole('button', { - name: '关闭通知' - }) - ) + screen.getByRole("button", { + name: "关闭通知", + }), + ); expect( - screen.queryByText(/停止生成失败,请重试/u) - ).not.toBeInTheDocument() + screen.queryByText(/停止生成失败,请重试/u), + ).not.toBeInTheDocument(); } finally { - vi.useRealTimers() + vi.useRealTimers(); } - }) + }); - it('locks agent context controls while a response is running', async () => { - render() + it("locks agent context controls while a response is running", async () => { + render(); - const expertButton = composerMenuTrigger('专家角色') - const modeButton = composerMenuTrigger('工作模式') - const runtimeButton = await screen.findByRole('button', { - name: /sonnet-5/u - }) - expect(expertButton).toBeEnabled() - expect(modeButton).toBeEnabled() - expect(runtimeButton).toBeEnabled() + const expertButton = composerMenuTrigger("专家角色"); + const modeButton = composerMenuTrigger("工作模式"); + const runtimeButton = await screen.findByRole("button", { + name: /sonnet-5/u, + }); + expect(expertButton).toBeEnabled(); + expect(modeButton).toBeEnabled(); + expect(runtimeButton).toBeEnabled(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '检查运行上下文锁定' } - }) - openComposerMenu('专家角色') - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "检查运行上下文锁定" }, + }); + openComposerMenu("专家角色"); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); expect( - screen.queryByRole('menu', { name: '专家角色' }) - ).not.toBeInTheDocument() - expect(expertButton).toBeDisabled() - expect(modeButton).toBeDisabled() - expect(runtimeButton).toBeDisabled() + screen.queryByRole("menu", { name: "专家角色" }), + ).not.toBeInTheDocument(); + expect(expertButton).toBeDisabled(); + expect(modeButton).toBeDisabled(); + expect(runtimeButton).toBeDisabled(); - const request = run.mock.calls[0]?.[0] + const request = run.mock.calls[0]?.[0]; act(() => { if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); - await waitFor(() => expect(expertButton).toBeEnabled()) - expect(modeButton).toBeEnabled() - expect(runtimeButton).toBeEnabled() - }) + await waitFor(() => expect(expertButton).toBeEnabled()); + expect(modeButton).toBeEnabled(); + expect(runtimeButton).toBeEnabled(); + }); - it('queues another ordinary message while the Conversation is running', async () => { - render() + it("queues another ordinary message while the Conversation is running", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '第一条长消息' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "第一条长消息" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); const secondItem = { - id: '00000000-0000-4000-8000-000000000941', + id: "00000000-0000-4000-8000-000000000941", conversationId: run.mock.calls[0]![0].conversationId, - source: 'user' as const, - label: '第二条排队消息', - createdAt: '2026-08-20T09:01:00.000Z' - } - vi.mocked(api.conversationQueue.enqueueUser) - .mockResolvedValueOnce(secondItem) - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: secondItem.label } - }) - fireEvent.click( - await screen.findByLabelText('加入待发送队列') - ) + source: "user" as const, + label: "第二条排队消息", + createdAt: "2026-08-20T09:01:00.000Z", + }; + vi.mocked(api.conversationQueue.enqueueUser).mockResolvedValueOnce( + secondItem, + ); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: secondItem.label }, + }); + fireEvent.click(await screen.findByLabelText("加入待发送队列")); await waitFor(() => - expect(api.conversationQueue.enqueueUser).toHaveBeenCalledTimes(2) - ) - expect(run).toHaveBeenCalledTimes(1) + expect(api.conversationQueue.enqueueUser).toHaveBeenCalledTimes(2), + ); + expect(run).toHaveBeenCalledTimes(1); expect( - screen.queryByText(/当前对话已有任务正在运行/u) - ).not.toBeInTheDocument() + screen.queryByText(/当前对话已有任务正在运行/u), + ).not.toBeInTheDocument(); - const firstRequest = run.mock.calls[0]![0] + const firstRequest = run.mock.calls[0]![0]; act(() => { agentListener?.({ requestId: firstRequest.requestId, - type: 'done' - }) - }) - const secondInput = vi.mocked( - api.conversationQueue.enqueueUser - ).mock.calls[1]![0] + type: "done", + }); + }); + const secondInput = vi.mocked(api.conversationQueue.enqueueUser).mock + .calls[1]![0]; act(() => { conversationQueueDispatchListener?.({ item: secondItem, - input: secondInput - }) - }) + input: secondInput, + }); + }); - await waitFor(() => expect(run).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(run).toHaveBeenCalledTimes(2)); expect(run.mock.calls[1]![0]).toMatchObject({ queueItemId: secondItem.id, - prompt: secondItem.label - }) - }) + prompt: secondItem.label, + }); + }); - it('restores a queued message when Agent preflight rejects it', async () => { + it("restores a queued message when Agent preflight rejects it", async () => { run.mockRejectedValueOnce( new Error( - "Error invoking remote method 'agent:run': Error: Runtime 暂不可用" - ) - ) - render() + "Error invoking remote method 'agent:run': Error: Runtime 暂不可用", + ), + ); + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '需要稍后重试的消息' } - }) - fireEvent.click(await screen.findByLabelText('发送')) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "需要稍后重试的消息" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); await waitFor(() => - expect(api.conversationQueue.releaseUser).toHaveBeenCalledOnce() - ) + expect(api.conversationQueue.releaseUser).toHaveBeenCalledOnce(), + ); + expect(await screen.findByText("Runtime 暂不可用")).toBeInTheDocument(); expect( - await screen.findByText('Runtime 暂不可用') - ).toBeInTheDocument() - expect( - screen.queryByText(/Error invoking remote method/u) - ).not.toBeInTheDocument() - expect( - screen.queryByText('需要稍后重试的消息') - ).not.toBeInTheDocument() - expect( - screen.queryByLabelText('停止生成') - ).not.toBeInTheDocument() - }) + screen.queryByText(/Error invoking remote method/u), + ).not.toBeInTheDocument(); + expect(screen.queryByText("需要稍后重试的消息")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument(); + }); - it('persists the claimed queue identity with a dispatched user message', async () => { - render() + it("persists the claimed queue identity with a dispatched user message", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '只发送一次' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "只发送一次" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request?.queueItemId) { - throw new Error('Missing queued request') + throw new Error("Missing queued request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'error', - status: 'failed', - message: 'Remote Runtime failed' - }) - }) + type: "error", + status: "failed", + message: "Remote Runtime failed", + }); + }); await waitFor(() => expect( - vi.mocked(api.conversations.saveLocal).mock.calls.some( - ([batch]) => + vi + .mocked(api.conversations.saveLocal) + .mock.calls.some(([batch]) => batch.some((conversation) => conversation.messages.some( (message) => - message.role === 'user' && - message.content === '只发送一次' && - message.queueItemId === request.queueItemId - ) - ) - ) - ).toBe(true) - ) - }) + message.role === "user" && + message.content === "只发送一次" && + message.queueItemId === request.queueItemId, + ), + ), + ), + ).toBe(true), + ); + }); - it('keeps sent documents and images in conversation history', async () => { + it("keeps sent documents and images in conversation history", async () => { const documentAttachment = { - id: '00000000-0000-4000-8000-000000000301', - name: '需求说明.md', + id: "00000000-0000-4000-8000-000000000301", + name: "需求说明.md", size: 2_048, - preview: '需要保留在用户消息中的文档', - kind: 'text' as const - } + preview: "需要保留在用户消息中的文档", + kind: "text" as const, + }; const imageAttachment = { - id: '00000000-0000-4000-8000-000000000302', - name: '页面截图.png', + id: "00000000-0000-4000-8000-000000000302", + name: "页面截图.png", size: 4_096, - preview: '1280 × 720', - kind: 'image' as const, - thumbnailUrl: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', - contentUrl: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q==' - } + preview: "1280 × 720", + kind: "image" as const, + thumbnailUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB", + contentUrl: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q==", + }; vi.mocked(api.context.selectFiles).mockResolvedValueOnce([ documentAttachment, - imageAttachment - ]) - render() + imageAttachment, + ]); + render(); - fireEvent.click(await screen.findByLabelText('添加附件')) + fireEvent.click(await screen.findByLabelText("添加附件")); const composer = screen - .getByLabelText('向 GoodBuddy 提问') - .closest('.composer') - expect(composer).not.toBeNull() + .getByLabelText("向 GoodBuddy 提问") + .closest(".composer"); + expect(composer).not.toBeNull(); if (!composer) { - return + return; } expect( - await within(composer).findByText('需求说明.md') - ).toBeInTheDocument() - expect( - within(composer).getByText('页面截图.png') - ).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '分析这些附件' } - }) - fireEvent.click(screen.getByLabelText('发送')) + await within(composer).findByText("需求说明.md"), + ).toBeInTheDocument(); + expect(within(composer).getByText("页面截图.png")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "分析这些附件" }, + }); + fireEvent.click(screen.getByLabelText("发送")); - await waitFor(() => expect(run).toHaveBeenCalledOnce()) + await waitFor(() => expect(run).toHaveBeenCalledOnce()); expect(run.mock.calls[0]?.[0].contextIds).toEqual([ documentAttachment.id, - imageAttachment.id - ]) + imageAttachment.id, + ]); const userArticle = screen - .getAllByText('分析这些附件') - .map((element) => element.closest('article')) - .find((element) => element?.classList.contains('message--user')) - expect(userArticle).not.toBeNull() + .getAllByText("分析这些附件") + .map((element) => element.closest("article")) + .find((element) => element?.classList.contains("message--user")); + expect(userArticle).not.toBeNull(); if (!userArticle) { - return + return; } const anchorClick = vi - .spyOn(HTMLAnchorElement.prototype, 'click') - .mockImplementation(() => {}) - expect(within(userArticle).getByText('需求说明.md')).toBeInTheDocument() - expect(within(userArticle).getByText('2 KB')).toBeInTheDocument() + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + expect(within(userArticle).getByText("需求说明.md")).toBeInTheDocument(); + expect(within(userArticle).getByText("2 KB")).toBeInTheDocument(); expect( - within(userArticle).getByRole('img', { name: '页面截图.png' }) - ).toHaveAttribute('src', imageAttachment.contentUrl) - const viewerTrigger = within(userArticle).getByRole('button', { - name: '查看图片 页面截图.png' - }) - fireEvent.click(viewerTrigger) - const imageDialog = await screen.findByRole('dialog', { - name: '页面截图.png' - }) + within(userArticle).getByRole("img", { name: "页面截图.png" }), + ).toHaveAttribute("src", imageAttachment.contentUrl); + const viewerTrigger = within(userArticle).getByRole("button", { + name: "查看图片 页面截图.png", + }); + fireEvent.click(viewerTrigger); + const imageDialog = await screen.findByRole("dialog", { + name: "页面截图.png", + }); expect( - within(imageDialog).getByRole('img', { name: '页面截图.png' }) - ).toHaveAttribute('src', imageAttachment.contentUrl) - const closeViewer = within(imageDialog).getByRole('button', { - name: '关闭图片查看器' - }) - expect(closeViewer).toHaveFocus() - expect(document.querySelector('main')?.inert).toBe(true) - fireEvent.keyDown(closeViewer, { key: 'Tab' }) + within(imageDialog).getByRole("img", { name: "页面截图.png" }), + ).toHaveAttribute("src", imageAttachment.contentUrl); + const closeViewer = within(imageDialog).getByRole("button", { + name: "关闭图片查看器", + }); + expect(closeViewer).toHaveFocus(); + expect(document.querySelector("main")?.inert).toBe(true); + fireEvent.keyDown(closeViewer, { key: "Tab" }); expect( - within(imageDialog).getByRole('button', { - name: '下载图片' - }) - ).toHaveFocus() - fireEvent.keyDown(imageDialog, { key: 'Escape' }) - await waitFor(() => - expect(viewerTrigger).toHaveFocus() - ) + within(imageDialog).getByRole("button", { + name: "下载图片", + }), + ).toHaveFocus(); + fireEvent.keyDown(imageDialog, { key: "Escape" }); + await waitFor(() => expect(viewerTrigger).toHaveFocus()); expect( - screen.queryByRole('dialog', { name: '页面截图.png' }) - ).not.toBeInTheDocument() - expect(document.querySelector('main')?.inert).toBe(false) + screen.queryByRole("dialog", { name: "页面截图.png" }), + ).not.toBeInTheDocument(); + expect(document.querySelector("main")?.inert).toBe(false); fireEvent.click( - within(userArticle).getByRole('button', { - name: '下载图片 页面截图.png' - }) - ) - expect(anchorClick).toHaveBeenCalledOnce() + within(userArticle).getByRole("button", { + name: "下载图片 页面截图.png", + }), + ); + expect(anchorClick).toHaveBeenCalledOnce(); await waitFor( () => expect(api.conversations.saveLocal).toHaveBeenCalledWith( @@ -5405,608 +5065,594 @@ describe('App', () => { expect.objectContaining({ messages: expect.arrayContaining([ expect.objectContaining({ - role: 'user', - attachments: [ - documentAttachment, - imageAttachment - ] - }) - ]) - }) - ]) + role: "user", + attachments: [documentAttachment, imageAttachment], + }), + ]), + }), + ]), ), - { timeout: 2_000 } - ) - }) + { timeout: 2_000 }, + ); + }); - it('shows attachment parsing progress and prevents duplicate selection', async () => { + it("shows attachment parsing progress and prevents duplicate selection", async () => { const attachment = { - id: '00000000-0000-4000-8000-000000000309', - name: '扫描材料.pdf', + id: "00000000-0000-4000-8000-000000000309", + name: "扫描材料.pdf", size: 8_705_692, - preview: '解析后的文档', - kind: 'text' as const - } + preview: "解析后的文档", + kind: "text" as const, + }; let resolveSelection: - | ((attachments: ContextAttachment[]) => void) - | undefined + ((attachments: ContextAttachment[]) => void) | undefined; vi.mocked(api.context.selectFiles).mockImplementationOnce( () => new Promise((resolve) => { - resolveSelection = resolve - }) - ) - render() + resolveSelection = resolve; + }), + ); + render(); - const addButton = await screen.findByLabelText('添加附件') - fireEvent.click(addButton) + const addButton = await screen.findByLabelText("添加附件"); + fireEvent.click(addButton); - expect(addButton).toBeDisabled() + expect(addButton).toBeDisabled(); expect( - screen.getByRole('progressbar', { - name: '附件读取与解析进度' - }) - ).toBeInTheDocument() - expect(screen.getByText('正在选择附件…')).toBeInTheDocument() + screen.getByRole("progressbar", { + name: "附件读取与解析进度", + }), + ).toBeInTheDocument(); + expect(screen.getByText("正在选择附件…")).toBeInTheDocument(); act(() => { fileSelectionProgressListener?.({ - phase: 'parsing', - fileName: '扫描材料.pdf', + phase: "parsing", + fileName: "扫描材料.pdf", fileNumber: 1, - fileCount: 1 - }) - }) - expect(screen.getByText('正在解析 扫描材料.pdf')).toBeInTheDocument() - expect(screen.getByText('第 1 / 1 个文件')).toBeInTheDocument() - fireEvent.click(addButton) - expect(api.context.selectFiles).toHaveBeenCalledOnce() + fileCount: 1, + }); + }); + expect(screen.getByText("正在解析 扫描材料.pdf")).toBeInTheDocument(); + expect(screen.getByText("第 1 / 1 个文件")).toBeInTheDocument(); + fireEvent.click(addButton); + expect(api.context.selectFiles).toHaveBeenCalledOnce(); - act(() => resolveSelection?.([attachment])) - expect(await screen.findByText('扫描材料.pdf')).toBeInTheDocument() + act(() => resolveSelection?.([attachment])); + expect(await screen.findByText("扫描材料.pdf")).toBeInTheDocument(); await waitFor(() => { - expect(addButton).toBeEnabled() + expect(addButton).toBeEnabled(); expect( - screen.queryByRole('progressbar', { - name: '附件读取与解析进度' - }) - ).not.toBeInTheDocument() - }) - }) + screen.queryByRole("progressbar", { + name: "附件读取与解析进度", + }), + ).not.toBeInTheDocument(); + }); + }); - it('associates attachment errors with the Composer controls', async () => { + it("associates attachment errors with the Composer controls", async () => { vi.mocked(api.context.selectFiles).mockRejectedValueOnce( - new Error('文件解析失败,请重新选择') - ) - render() + new Error("文件解析失败,请重新选择"), + ); + render(); - const addButton = await screen.findByLabelText('添加附件') - const input = screen.getByLabelText('向 GoodBuddy 提问') - fireEvent.click(addButton) + const addButton = await screen.findByLabelText("添加附件"); + const input = screen.getByLabelText("向 GoodBuddy 提问"); + fireEvent.click(addButton); - const error = await screen.findByRole('alert', { - name: '文件解析失败,请重新选择' - }) - expect(error).toHaveAttribute('id', 'composer-context-error') - expect(input).toHaveAttribute('aria-invalid', 'true') - expect(input).toHaveAttribute( - 'aria-describedby', - 'composer-context-error' - ) - expect(addButton).toHaveAttribute('aria-invalid', 'true') + const error = await screen.findByRole("alert", { + name: "文件解析失败,请重新选择", + }); + expect(error).toHaveAttribute("id", "composer-context-error"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).toHaveAttribute("aria-describedby", "composer-context-error"); + expect(addButton).toHaveAttribute("aria-invalid", "true"); expect(addButton).toHaveAttribute( - 'aria-describedby', - 'composer-context-error' - ) - }) + "aria-describedby", + "composer-context-error", + ); + }); - it('sends and renders five selected images together', async () => { + it("sends and renders five selected images together", async () => { const imageAttachments = Array.from({ length: 5 }, (_, index) => ({ id: `00000000-0000-4000-8000-00000000031${index}`, name: `参考图-${index + 1}.png`, size: 4_096, - preview: '640 × 480', - kind: 'image' as const, - thumbnailUrl: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q==', - contentUrl: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q==' - })) - vi.mocked(api.context.selectFiles).mockResolvedValueOnce(imageAttachments) - render() + preview: "640 × 480", + kind: "image" as const, + thumbnailUrl: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q==", + contentUrl: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q==", + })); + vi.mocked(api.context.selectFiles).mockResolvedValueOnce(imageAttachments); + render(); - fireEvent.click(await screen.findByLabelText('添加附件')) + fireEvent.click(await screen.findByLabelText("添加附件")); const composer = screen - .getByLabelText('向 GoodBuddy 提问') - .closest('.composer') - expect(composer).not.toBeNull() + .getByLabelText("向 GoodBuddy 提问") + .closest(".composer"); + expect(composer).not.toBeNull(); if (!composer) { - return + return; } await waitFor(() => - expect( - within(composer).getAllByText(/^参考图-\d\.png$/u) - ).toHaveLength(5) - ) - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '比较这五张图片' } - }) - fireEvent.click(screen.getByLabelText('发送')) + expect(within(composer).getAllByText(/^参考图-\d\.png$/u)).toHaveLength( + 5, + ), + ); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "比较这五张图片" }, + }); + fireEvent.click(screen.getByLabelText("发送")); - await waitFor(() => expect(run).toHaveBeenCalledOnce()) + await waitFor(() => expect(run).toHaveBeenCalledOnce()); expect(run.mock.calls[0]?.[0].contextIds).toEqual( - imageAttachments.map((attachment) => attachment.id) - ) + imageAttachments.map((attachment) => attachment.id), + ); const userArticle = screen - .getAllByText('比较这五张图片') - .map((element) => element.closest('article')) - .find((element) => element?.classList.contains('message--user')) - expect(userArticle).not.toBeNull() + .getAllByText("比较这五张图片") + .map((element) => element.closest("article")) + .find((element) => element?.classList.contains("message--user")); + expect(userArticle).not.toBeNull(); if (!userArticle) { - return + return; } - expect(within(userArticle).getAllByRole('img')).toHaveLength(5) - expect(within(userArticle).getByLabelText('消息附件')).toHaveClass( - 'message-attachments' - ) - }) + expect(within(userArticle).getAllByRole("img")).toHaveLength(5); + expect(within(userArticle).getByLabelText("消息附件")).toHaveClass( + "message-attachments", + ); + }); - it('accepts pasted images without intercepting pasted text', async () => { + it("accepts pasted images without intercepting pasted text", async () => { vi.mocked(api.context.addPastedImage).mockResolvedValueOnce({ - id: '00000000-0000-4000-8000-000000000303', - name: '粘贴图片.jpg', + id: "00000000-0000-4000-8000-000000000303", + name: "粘贴图片.jpg", size: 120_000, - preview: '1280 × 800', - kind: 'image', - thumbnailUrl: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB' - }) + preview: "1280 × 800", + kind: "image", + thumbnailUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB", + }); const pastedImage = new File( [Uint8Array.from([0x89, 0x50, 0x4e, 0x47])], - 'pasted.png', - { type: 'image/png' } - ) - render() + "pasted.png", + { type: "image/png" }, + ); + render(); - const input = await screen.findByLabelText('向 GoodBuddy 提问') + const input = await screen.findByLabelText("向 GoodBuddy 提问"); expect( fireEvent.paste(input, { clipboardData: { items: [ { getAsFile: () => null, - kind: 'string', - type: 'text/plain' - } - ] - } - }) - ).toBe(true) - expect(api.context.addPastedImage).not.toHaveBeenCalled() + kind: "string", + type: "text/plain", + }, + ], + }, + }), + ).toBe(true); + expect(api.context.addPastedImage).not.toHaveBeenCalled(); fireEvent.paste(input, { clipboardData: { items: [ { getAsFile: () => pastedImage, - kind: 'file', - type: 'image/png' - } - ] - } - }) + kind: "file", + type: "image/png", + }, + ], + }, + }); await waitFor(() => expect(api.context.addPastedImage).toHaveBeenCalledWith({ data: Uint8Array.from([0x89, 0x50, 0x4e, 0x47]), - mimeType: 'image/png' - }) - ) - expect(api.context.addPastedImage).toHaveBeenCalledTimes(1) - expect(api.context.readClipboard).not.toHaveBeenCalled() - const composer = input.closest('.composer') - expect(composer).not.toBeNull() + mimeType: "image/png", + }), + ); + expect(api.context.addPastedImage).toHaveBeenCalledTimes(1); + expect(api.context.readClipboard).not.toHaveBeenCalled(); + const composer = input.closest(".composer"); + expect(composer).not.toBeNull(); if (!composer) { - return + return; } expect( - await within(composer).findByText('粘贴图片.jpg') - ).toBeInTheDocument() + await within(composer).findByText("粘贴图片.jpg"), + ).toBeInTheDocument(); expect( - within(composer).queryByRole('button', { - name: '截取当前屏幕' - }) - ).not.toBeInTheDocument() + within(composer).queryByRole("button", { + name: "截取当前屏幕", + }), + ).not.toBeInTheDocument(); expect( - within(composer).queryByRole('button', { - name: '捕获应用窗口' - }) - ).not.toBeInTheDocument() + within(composer).queryByRole("button", { + name: "捕获应用窗口", + }), + ).not.toBeInTheDocument(); expect( - within(composer).queryByRole('button', { - name: '读取剪贴板' - }) - ).not.toBeInTheDocument() - }) + within(composer).queryByRole("button", { + name: "读取剪贴板", + }), + ).not.toBeInTheDocument(); + }); - it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => { - vi.mocked(api.agent.getStatus).mockReturnValue( - new Promise(() => {}) - ) - render() + it("keeps a draft in chat when Enter is pressed while the runtime loads", async () => { + vi.mocked(api.agent.getStatus).mockReturnValue(new Promise(() => {})); + render(); - const composer = screen.getByLabelText('向 GoodBuddy 提问') + const composer = screen.getByLabelText("向 GoodBuddy 提问"); fireEvent.change(composer, { - target: { value: '等待 Runtime' } - }) - fireEvent.keyDown(composer, { key: 'Enter' }) + target: { value: "等待 Runtime" }, + }); + fireEvent.keyDown(composer, { key: "Enter" }); - expect(composer).toHaveValue('等待 Runtime') + expect(composer).toHaveValue("等待 Runtime"); expect( - screen.queryByRole('heading', { name: '设置中心' }) - ).not.toBeInTheDocument() + screen.queryByRole("heading", { name: "设置中心" }), + ).not.toBeInTheDocument(); expect( - await screen.findByText('Agent Runtime 正在加载,请稍后重试') - ).toBeInTheDocument() - expect(run).not.toHaveBeenCalled() - }) + await screen.findByText("Agent Runtime 正在加载,请稍后重试"), + ).toBeInTheDocument(); + expect(run).not.toHaveBeenCalled(); + }); - it('keeps a new-conversation draft in chat when the runtime is unavailable', async () => { + it("keeps a new-conversation draft in chat when the runtime is unavailable", async () => { vi.mocked(api.agent.getStatus).mockResolvedValue({ - id: 'setup', - label: '需要配置模型', + id: "setup", + label: "需要配置模型", available: false, supportsToolExecution: false, - detail: '请配置模型' - }) - render() + detail: "请配置模型", + }); + render(); expect( - await screen.findByRole('heading', { name: '设置中心' }) - ).toBeInTheDocument() - const newConversation = screen.getByRole('button', { - name: /新建对话/u - }) - fireEvent.click(newConversation) + await screen.findByRole("heading", { name: "设置中心" }), + ).toBeInTheDocument(); + const newConversation = screen.getByRole("button", { + name: /新建对话/u, + }); + fireEvent.click(newConversation); - const composer = screen.getByLabelText('向 GoodBuddy 提问') - await waitFor(() => expect(composer).toHaveFocus()) + const composer = screen.getByLabelText("向 GoodBuddy 提问"); + await waitFor(() => expect(composer).toHaveFocus()); fireEvent.change(composer, { - target: { value: '保留这条草稿' } - }) - fireEvent.keyDown(composer, { key: 'Enter' }) + target: { value: "保留这条草稿" }, + }); + fireEvent.keyDown(composer, { key: "Enter" }); - expect(composer).toHaveValue('保留这条草稿') + expect(composer).toHaveValue("保留这条草稿"); expect( - screen.queryByRole('heading', { name: '设置中心' }) - ).not.toBeInTheDocument() - expect(screen.getByLabelText('发送')).toBeDisabled() - expect(run).not.toHaveBeenCalled() - }) + screen.queryByRole("heading", { name: "设置中心" }), + ).not.toBeInTheDocument(); + expect(screen.getByLabelText("发送")).toBeDisabled(); + expect(run).not.toHaveBeenCalled(); + }); - it('opens chat and focuses the composer for tray conversations', async () => { - render() + it("opens chat and focuses the composer for tray conversations", async () => { + render(); - fireEvent.click(await screen.findByText('本地工作区')) + fireEvent.click(await screen.findByText("本地工作区")); expect( - await screen.findByRole('heading', { name: '设置中心' }) - ).toBeInTheDocument() + await screen.findByRole("heading", { name: "设置中心" }), + ).toBeInTheDocument(); - act(() => newConversationListener?.()) + act(() => newConversationListener?.()); - const composer = await screen.findByLabelText('向 GoodBuddy 提问') - await waitFor(() => expect(composer).toHaveFocus()) - }) + const composer = await screen.findByLabelText("向 GoodBuddy 提问"); + await waitFor(() => expect(composer).toHaveFocus()); + }); - it('reuses the active empty conversation and preserves its draft', async () => { - render() + it("reuses the active empty conversation and preserves its draft", async () => { + render(); - const composer = await screen.findByLabelText('向 GoodBuddy 提问') + const composer = await screen.findByLabelText("向 GoodBuddy 提问"); fireEvent.change(composer, { - target: { value: '尚未发送的草稿' } - }) - const newConversation = screen.getByRole('button', { - name: /新建对话/u - }) - fireEvent.click(newConversation) - fireEvent.click(newConversation) + target: { value: "尚未发送的草稿" }, + }); + const newConversation = screen.getByRole("button", { + name: /新建对话/u, + }); + fireEvent.click(newConversation); + fireEvent.click(newConversation); - expect(composer).toHaveValue('尚未发送的草稿') + expect(composer).toHaveValue("尚未发送的草稿"); expect( - screen.getAllByRole('button', { - name: '更多会话操作 新对话' - }) - ).toHaveLength(1) - }) + screen.getAllByRole("button", { + name: "更多会话操作 新对话", + }), + ).toHaveLength(1); + }); - it('coalesces batched new-conversation requests after a used conversation', async () => { - render() + it("coalesces batched new-conversation requests after a used conversation", async () => { + render(); - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '已有内容' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const requestId = run.mock.calls[0]?.[0].requestId + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "已有内容" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const requestId = run.mock.calls[0]?.[0].requestId; act(() => { if (requestId) { - agentListener?.({ requestId, type: 'done' }) + agentListener?.({ requestId, type: "done" }); } - newConversationListener?.() - newConversationListener?.() - }) + newConversationListener?.(); + newConversationListener?.(); + }); await waitFor(() => expect( - screen.getAllByRole('button', { - name: /^更多会话操作/u - }) - ).toHaveLength(2) - ) - }) + screen.getAllByRole("button", { + name: /^更多会话操作/u, + }), + ).toHaveLength(2), + ); + }); - it('opens a workspace Markdown file in the right-side preview', async () => { + it("opens a workspace Markdown file in the right-side preview", async () => { vi.mocked(api.workspace.listDirectory).mockResolvedValue({ - path: '', + path: "", entries: [ { - name: 'README.md', - path: 'README.md', - type: 'file' - } + name: "README.md", + path: "README.md", + type: "file", + }, ], - truncated: false - }) + truncated: false, + }); vi.mocked(api.workspace.readFile).mockResolvedValue({ - path: 'README.md', - name: 'README.md', - content: '# 工作区说明', - mimeType: 'text/markdown', + path: "README.md", + name: "README.md", + content: "# 工作区说明", + mimeType: "text/markdown", size: 19, offsetBytes: 0, nextOffsetBytes: 19, - truncated: false - }) - render() + truncated: false, + }); + render(); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - fireEvent.click(await screen.findByRole('tab', { name: '工作区' })) - fireEvent.click( - await screen.findByRole('button', { name: 'README.md' }) - ) + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + fireEvent.click(await screen.findByRole("tab", { name: "工作区" })); + fireEvent.click(await screen.findByRole("button", { name: "README.md" })); expect( - await screen.findByRole('heading', { name: '工作区说明' }) - ).toBeInTheDocument() - expect( - screen.getByRole('tab', { name: '工作区' }) - ).toHaveAttribute('aria-selected', 'true') - expect( - screen.queryByRole('tab', { name: '预览' }) - ).not.toBeInTheDocument() + await screen.findByRole("heading", { name: "工作区说明" }), + ).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "工作区" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.queryByRole("tab", { name: "预览" })).not.toBeInTheDocument(); expect(api.workspace.readFile).toHaveBeenCalledWith( projectId, - 'README.md', - 0 - ) - fireEvent.click( - screen.getByRole('button', { name: '返回工作区' }) - ) + "README.md", + 0, + ); + fireEvent.click(screen.getByRole("button", { name: "返回工作区" })); expect( - await screen.findByRole('button', { name: 'README.md' }) - ).toBeInTheDocument() - }) + await screen.findByRole("button", { name: "README.md" }), + ).toBeInTheDocument(); + }); - it('opens multiple current-project terminals without a second target picker', async () => { - render() + it("opens multiple current-project terminals without a second target picker", async () => { + render(); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - const add = await screen.findByRole('button', { - name: '打开工作栏应用' - }) + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + const add = await screen.findByRole("button", { + name: "打开工作栏应用", + }); for (let count = 1; count <= 2; count += 1) { - fireEvent.click(add) + fireEvent.click(add); fireEvent.click( - await screen.findByRole('button', { - name: /打开当前项目的用户终端/u - }) - ) + await screen.findByRole("button", { + name: /打开当前项目的用户终端/u, + }), + ); expect( - await screen.findByRole('tab', { - name: `终端 ${count}` - }) - ).toHaveAttribute('aria-selected', 'true') + await screen.findByRole("tab", { + name: `终端 ${count}`, + }), + ).toHaveAttribute("aria-selected", "true"); } expect( - screen.getByRole('button', { name: '打开工作栏应用' }) - ).toBeVisible() - expect( - screen.getAllByRole('tab', { name: /^终端 \d+$/u }) - ).toHaveLength(2) - }) + screen.getByRole("button", { name: "打开工作栏应用" }), + ).toBeVisible(); + expect(screen.getAllByRole("tab", { name: /^终端 \d+$/u })).toHaveLength(2); + }); - it('opens workspace entries from their row actions', async () => { + it("opens workspace entries from their row actions", async () => { vi.mocked(api.workspace.listDirectory).mockResolvedValue({ - path: '', + path: "", entries: [ - { name: 'docs', path: 'docs', type: 'directory' }, - { name: 'README.md', path: 'README.md', type: 'file' } + { name: "docs", path: "docs", type: "directory" }, + { name: "README.md", path: "README.md", type: "file" }, ], - truncated: false - }) - render() + truncated: false, + }); + render(); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - fireEvent.click(await screen.findByRole('tab', { name: '工作区' })) + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + fireEvent.click(await screen.findByRole("tab", { name: "工作区" })); fireEvent.click( - await screen.findByRole('button', { - name: '在系统资源管理器中打开文件夹 docs' - }) - ) + await screen.findByRole("button", { + name: "在系统资源管理器中打开文件夹 docs", + }), + ); fireEvent.click( - screen.getByRole('button', { - name: '使用默认应用打开文件 README.md' - }) - ) + screen.getByRole("button", { + name: "使用默认应用打开文件 README.md", + }), + ); await waitFor(() => expect(api.workspace.openPath).toHaveBeenCalledWith( projectId, - 'docs', - 'directory' - ) - ) + "docs", + "directory", + ), + ); expect(api.workspace.openPath).toHaveBeenCalledWith( projectId, - 'README.md', - 'file' - ) - }) + "README.md", + "file", + ); + }); - it('refreshes generated workspace files when a run completes', async () => { - vi.mocked(api.workspace.listDirectory).mockImplementation(async (_projectId, path) => ({ - path, - entries: vi.mocked(api.workspace.getChanges).mock.calls.length >= 2 - ? [{ name: 'tree-only.txt', path: 'tree-only.txt', type: 'file' as const }] - : [], - truncated: false - })) + it("refreshes generated workspace files when a run completes", async () => { + vi.mocked(api.workspace.listDirectory).mockImplementation( + async (_projectId, path) => ({ + path, + entries: + vi.mocked(api.workspace.getChanges).mock.calls.length >= 2 + ? [ + { + name: "tree-only.txt", + path: "tree-only.txt", + type: "file" as const, + }, + ] + : [], + truncated: false, + }), + ); vi.mocked(api.workspace.getChanges) .mockResolvedValueOnce({ rootPath: project.rootPath, available: true, - status: '', - patch: '', + status: "", + patch: "", files: [], - truncated: false + truncated: false, }) .mockResolvedValueOnce({ rootPath: project.rootPath, available: true, - status: '?? generated.md', - patch: '', - files: [{ path: 'generated.md', status: '??' }], - truncated: false - }) - render() + status: "?? generated.md", + patch: "", + files: [{ path: "generated.md", status: "??" }], + truncated: false, + }); + render(); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - fireEvent.click(await screen.findByRole('tab', { name: '工作区' })) + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + fireEvent.click(await screen.findByRole("tab", { name: "工作区" })); await waitFor(() => - expect(api.workspace.getChanges).toHaveBeenCalledOnce() - ) - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '生成文件' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const requestId = run.mock.calls[0]?.[0].requestId + expect(api.workspace.getChanges).toHaveBeenCalledOnce(), + ); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "生成文件" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const requestId = run.mock.calls[0]?.[0].requestId; act(() => { if (requestId) { - agentListener?.({ requestId, type: 'done' }) + agentListener?.({ requestId, type: "done" }); } - }) + }); - expect(await screen.findByText('generated.md')).toBeInTheDocument() - expect(await screen.findByText('tree-only.txt')).toBeInTheDocument() - }) + expect(await screen.findByText("generated.md")).toBeInTheDocument(); + expect(await screen.findByText("tree-only.txt")).toBeInTheDocument(); + }); - it('ignores stale Git changes after switching projects', async () => { + it("ignores stale Git changes after switching projects", async () => { const secondProject = { ...project, - id: '00000000-0000-4000-8000-000000000102', - name: '第二项目', - rootPath: 'C:\\Second', + id: "00000000-0000-4000-8000-000000000102", + name: "第二项目", + rootPath: "C:\\Second", executionSpace: { - kind: 'local' as const, - rootPath: 'C:\\Second' - } - } + kind: "local" as const, + rootPath: "C:\\Second", + }, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - secondProject - ]) + secondProject, + ]); let resolveFirst: - | ((value: Awaited>) => void) - | undefined + | (( + value: Awaited>, + ) => void) + | undefined; let resolveSecond: - | ((value: Awaited>) => void) - | undefined + | (( + value: Awaited>, + ) => void) + | undefined; vi.mocked(api.workspace.getChanges) .mockImplementationOnce( () => new Promise((resolve) => { - resolveFirst = resolve - }) + resolveFirst = resolve; + }), ) .mockImplementationOnce( () => new Promise((resolve) => { - resolveSecond = resolve - }) - ) - render() + resolveSecond = resolve; + }), + ); + render(); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - fireEvent.click(await screen.findByRole('tab', { name: '工作区' })) + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + fireEvent.click(await screen.findByRole("tab", { name: "工作区" })); await waitFor(() => - expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId) - ) - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - selectProjectOption(secondProject.name) + expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId), + ); + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + selectProjectOption(secondProject.name); await waitFor(() => - expect(api.workspace.getChanges).toHaveBeenCalledWith( - secondProject.id - ) - ) + expect(api.workspace.getChanges).toHaveBeenCalledWith(secondProject.id), + ); resolveSecond?.({ rootPath: secondProject.rootPath, available: true, - status: '?? second.md', - patch: '', - files: [{ path: 'second.md', status: '??' }], - truncated: false - }) - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - expect(await screen.findByText('second.md')).toBeInTheDocument() + status: "?? second.md", + patch: "", + files: [{ path: "second.md", status: "??" }], + truncated: false, + }); + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + expect(await screen.findByText("second.md")).toBeInTheDocument(); resolveFirst?.({ rootPath: project.rootPath, available: true, - status: '?? stale.md', - patch: '', - files: [{ path: 'stale.md', status: '??' }], - truncated: false - }) + status: "?? stale.md", + patch: "", + files: [{ path: "stale.md", status: "??" }], + truncated: false, + }); await waitFor(() => - expect(screen.queryByText('stale.md')).not.toBeInTheDocument() - ) - expect(screen.getByText('second.md')).toBeInTheDocument() - }) + expect(screen.queryByText("stale.md")).not.toBeInTheDocument(), + ); + expect(screen.getByText("second.md")).toBeInTheDocument(); + }); - it('applies and persists a dark appearance from Settings', async () => { - render() + it("applies and persists a dark appearance from Settings", async () => { + render(); - fireEvent.click(await screen.findByText('本地工作区')) - await screen.findByRole( - 'heading', - { name: '设置中心' }, - { timeout: 3000 } - ) - fireEvent.click(screen.getByRole('tab', { name: '外观' })) - fireEvent.click(screen.getByRole('radio', { name: /暗色/u })) + fireEvent.click(await screen.findByText("本地工作区")); + await screen.findByRole("heading", { name: "设置中心" }, { timeout: 3000 }); + fireEvent.click(screen.getByRole("tab", { name: "外观" })); + fireEvent.click(screen.getByRole("radio", { name: /暗色/u })); await waitFor(() => - expect(document.documentElement.dataset.theme).toBe('dark') - ) - expect(document.documentElement.style.colorScheme).toBe('dark') - expect(localStorage.getItem('goodbuddy.appearance-theme')).toBe( - 'dark' - ) - }) + expect(document.documentElement.dataset.theme).toBe("dark"), + ); + expect(document.documentElement.style.colorScheme).toBe("dark"); + expect(localStorage.getItem("goodbuddy.appearance-theme")).toBe("dark"); + }); - it('loads token usage in activity and refreshes it when a run finishes', async () => { + it("loads token usage in activity and refreshes it when a run finishes", async () => { vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({ totals: { callCount: 1, @@ -6014,57 +5660,53 @@ describe('App', () => { output: 20, cacheRead: 0, cacheWrite: 0, - totalTokens: 120 + totalTokens: 120, }, records: [ { - requestId: 'usage-request-1', + requestId: "usage-request-1", projectId, projectName: project.name, - conversationId: 'usage-conversation-1', - conversationTitle: '用量会话', - runtime: 'model', - provider: 'anthropic', - model: 'sonnet-5', + conversationId: "usage-conversation-1", + conversationTitle: "用量会话", + runtime: "model", + provider: "anthropic", + model: "sonnet-5", callCount: 1, input: 100, output: 20, cacheRead: 0, cacheWrite: 0, - totalTokens: 120 - } - ] - }) + totalTokens: 120, + }, + ], + }); - render() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '统计用量' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "统计用量" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } - fireEvent.click(screen.getByText('运行记录')) - fireEvent.click( - await screen.findByRole('tab', { name: '用量统计' }) - ) - const stats = await screen.findByLabelText('Token 用量统计') + fireEvent.click(screen.getByText("运行记录")); + fireEvent.click(await screen.findByRole("tab", { name: "用量统计" })); + const stats = await screen.findByLabelText("Token 用量统计"); expect( - screen.getByRole('heading', { level: 1, name: '运行记录' }) - ).toBeInTheDocument() + screen.getByRole("heading", { level: 1, name: "运行记录" }), + ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: /^专家角色:/u }) - ).not.toBeInTheDocument() - expect( - screen.queryByLabelText('切换助手工作栏') - ).toBeInTheDocument() + screen.queryByRole("button", { name: /^专家角色:/u }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument(); await waitFor(() => - expect(api.usage.getTokenSummary).toHaveBeenCalledOnce() - ) - expect(within(stats).getByText('120')).toBeInTheDocument() + expect(api.usage.getTokenSummary).toHaveBeenCalledOnce(), + ); + expect(within(stats).getByText("120")).toBeInTheDocument(); vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({ totals: { @@ -6073,7 +5715,7 @@ describe('App', () => { output: 45, cacheRead: 10, cacheWrite: 5, - totalTokens: 360 + totalTokens: 360, }, records: [ { @@ -6081,1794 +5723,1684 @@ describe('App', () => { projectId, projectName: project.name, conversationId: request.conversationId, - conversationTitle: '用量会话', - runtime: 'model', - provider: 'anthropic', - model: 'sonnet-5', + conversationTitle: "用量会话", + runtime: "model", + provider: "anthropic", + model: "sonnet-5", callCount: 2, input: 300, output: 45, cacheRead: 10, cacheWrite: 5, - totalTokens: 360 - } - ] - }) + totalTokens: 360, + }, + ], + }); act(() => { agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); await waitFor(() => - expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2) - ) - expect(within(stats).getByText('345')).toBeInTheDocument() - }) + expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2), + ); + expect(within(stats).getByText("345")).toBeInTheDocument(); + }); - it('offers only Ask and Execute in visible work mode controls', async () => { - render() + it("offers only Ask and Execute in visible work mode controls", async () => { + render(); - await screen.findByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - const modeMenu = openComposerMenu('工作模式') + await screen.findByRole("button", { + name: "工作模式:Ask · 只读问答", + }); + const modeMenu = openComposerMenu("工作模式"); expect( within(modeMenu) - .getAllByRole('menuitemradio') - .map((option) => option.querySelector('span')?.textContent) - ).toEqual(['Ask · 只读问答', 'Execute · 完全权限']) + .getAllByRole("menuitemradio") + .map((option) => option.querySelector("span")?.textContent), + ).toEqual(["Ask · 只读问答", "Execute · 完全权限"]); - fireEvent.click(screen.getByLabelText('新建项目')) - const dialog = screen.getByRole('dialog', { name: '新建项目' }) + fireEvent.click(screen.getByLabelText("新建项目")); + const dialog = screen.getByRole("dialog", { name: "新建项目" }); const defaultMode = within(dialog) - .getAllByRole('group', { name: '默认模式' }) - .find((candidate) => - candidate.classList.contains('segmented-control') - )! + .getAllByRole("group", { name: "默认模式" }) + .find((candidate) => candidate.classList.contains("segmented-control"))!; expect( within(defaultMode) - .getAllByRole('button') - .map((button) => button.textContent) - ).toEqual(['Ask · 只读问答', 'Execute · 完全权限']) - expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull() - }) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["Ask · 只读问答", "Execute · 完全权限"]); + expect(screen.queryByRole("option", { name: /Plan/u })).toBeNull(); + }); - it('matches expert and work mode keyboard menus to the model picker', async () => { - render() + it("matches expert and work mode keyboard menus to the model picker", async () => { + render(); - const expertTrigger = await screen.findByRole('button', { - name: '专家角色:通用助手' - }) - expect(expertTrigger).toHaveClass('model-button') - fireEvent.keyDown(expertTrigger, { key: 'ArrowDown' }) + const expertTrigger = await screen.findByRole("button", { + name: "专家角色:通用助手", + }); + expect(expertTrigger).toHaveClass("model-button"); + fireEvent.keyDown(expertTrigger, { key: "ArrowDown" }); - const expertMenu = screen.getByRole('menu', { - name: '专家角色' - }) - expect(expertMenu).toHaveClass('runtime-picker__menu') - const generalExpert = within(expertMenu).getByRole( - 'menuitemradio', - { name: /^通用助手/u } - ) - const expertTeam = within(expertMenu).getByRole( - 'menuitemradio', - { name: /^专家团队(并行)/u } - ) - await waitFor(() => expect(generalExpert).toHaveFocus()) - fireEvent.keyDown(generalExpert, { key: 'ArrowDown' }) - expect(expertTeam).toHaveFocus() - fireEvent.keyDown(expertTeam, { key: 'Escape' }) - expect(expertTrigger).toHaveFocus() + const expertMenu = screen.getByRole("menu", { + name: "专家角色", + }); + expect(expertMenu).toHaveClass("runtime-picker__menu"); + const generalExpert = within(expertMenu).getByRole("menuitemradio", { + name: /^通用助手/u, + }); + const expertTeam = within(expertMenu).getByRole("menuitemradio", { + name: /^专家团队(并行)/u, + }); + await waitFor(() => expect(generalExpert).toHaveFocus()); + fireEvent.keyDown(generalExpert, { key: "ArrowDown" }); + expect(expertTeam).toHaveFocus(); + fireEvent.keyDown(expertTeam, { key: "Escape" }); + expect(expertTrigger).toHaveFocus(); expect( - screen.queryByRole('menu', { name: '专家角色' }) - ).not.toBeInTheDocument() + screen.queryByRole("menu", { name: "专家角色" }), + ).not.toBeInTheDocument(); - const modeTrigger = composerMenuTrigger('工作模式') - fireEvent.click(modeTrigger) - const modeMenu = screen.getByRole('menu', { name: '工作模式' }) - expect(modeMenu).toHaveClass('runtime-picker__menu') - fireEvent.pointerDown(screen.getByLabelText('向 GoodBuddy 提问')) + const modeTrigger = composerMenuTrigger("工作模式"); + fireEvent.click(modeTrigger); + const modeMenu = screen.getByRole("menu", { name: "工作模式" }); + expect(modeMenu).toHaveClass("runtime-picker__menu"); + fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问")); expect( - screen.queryByRole('menu', { name: '工作模式' }) - ).not.toBeInTheDocument() - }) + screen.queryByRole("menu", { name: "工作模式" }), + ).not.toBeInTheDocument(); + }); - it('groups composer tools and exposes clear control descriptions', async () => { - render() + it("groups composer tools and exposes clear control descriptions", async () => { + render(); - const composer = (await screen.findByLabelText( - '向 GoodBuddy 提问' - )).closest('.composer') - expect(composer).not.toBeNull() + const composer = ( + await screen.findByLabelText("向 GoodBuddy 提问") + ).closest(".composer"); + expect(composer).not.toBeNull(); - const contentTools = within(composer!).getByRole('group', { - name: '添加内容' - }) + const contentTools = within(composer!).getByRole("group", { + name: "添加内容", + }); expect( - within(contentTools).getByRole('button', { name: '添加附件' }) - ).toHaveAttribute('title', '添加附件') + within(contentTools).getByRole("button", { name: "添加附件" }), + ).toHaveAttribute("title", "添加附件"); expect( - within(contentTools).getByRole('button', { name: '语音输入' }) - ).toHaveAttribute('title', '语音转文字,转写后可编辑再发送') + within(contentTools).getByRole("button", { name: "语音输入" }), + ).toHaveAttribute("title", "语音转文字,转写后可编辑再发送"); - const conversationSettings = within(composer!).getByRole( - 'group', - { name: '对话设置' } - ) + const conversationSettings = within(composer!).getByRole("group", { + name: "对话设置", + }); expect( - within(conversationSettings).getByRole('button', { - name: '专家角色:通用助手' - }) - ).toBeInTheDocument() + within(conversationSettings).getByRole("button", { + name: "专家角色:通用助手", + }), + ).toBeInTheDocument(); expect( - within(conversationSettings).getByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - ).toBeInTheDocument() + within(conversationSettings).getByRole("button", { + name: "工作模式:Ask · 只读问答", + }), + ).toBeInTheDocument(); expect( - within(conversationSettings).getByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - ).toHaveTextContent(/^Ask$/u) - expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute( - 'placeholder', - '给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本' - ) + within(conversationSettings).getByRole("button", { + name: "工作模式:Ask · 只读问答", + }), + ).toHaveTextContent(/^Ask$/u); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( + "placeholder", + "给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本", + ); expect( - within(conversationSettings).getByRole('button', { - name: /默认模型/u - }) - ).toHaveAttribute( - 'title', - expect.stringContaining('Runtime 和模型') - ) - }) + within(conversationSettings).getByRole("button", { + name: /默认模型/u, + }), + ).toHaveAttribute("title", expect.stringContaining("Runtime 和模型")); + }); - it('starts in the first local project and persists manual selection', async () => { + it("starts in the first local project and persists manual selection", async () => { const secondProject = { ...project, - id: '00000000-0000-4000-8000-000000000102', - name: '第二项目', - rootPath: 'C:\\Second', + id: "00000000-0000-4000-8000-000000000102", + name: "第二项目", + rootPath: "C:\\Second", executionSpace: { - kind: 'local' as const, - rootPath: 'C:\\Second' + kind: "local" as const, + rootPath: "C:\\Second", }, - defaultWorkMode: 'execute' as const - } + defaultWorkMode: "execute" as const, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - secondProject - ]) - localStorage.setItem( - 'goodbuddy.active-project.v1', - secondProject.id - ) + secondProject, + ]); + localStorage.setItem("goodbuddy.active-project.v1", secondProject.id); - render() + render(); expect( - await screen.findByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) + await screen.findByRole("button", { name: "当前项目" }), + ).toHaveTextContent(project.name); expect( - screen.getByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - ).toBeEnabled() + screen.getByRole("button", { + name: "工作模式:Ask · 只读问答", + }), + ).toBeEnabled(); - selectProjectOption(secondProject.name) + selectProjectOption(secondProject.name); await waitFor(() => - expect( - localStorage.getItem('goodbuddy.active-project.v1') - ).toBe(secondProject.id) - ) - }) + expect(localStorage.getItem("goodbuddy.active-project.v1")).toBe( + secondProject.id, + ), + ); + }); - it('persists a new local conversation before queueing its first message', async () => { + it("persists a new local conversation before queueing its first message", async () => { const secondProject = { ...project, - id: '00000000-0000-4000-8000-000000000105', - name: '新本地项目', - rootPath: 'C:\\NewLocal', + id: "00000000-0000-4000-8000-000000000105", + name: "新本地项目", + rootPath: "C:\\NewLocal", executionSpace: { - kind: 'local' as const, - rootPath: 'C:\\NewLocal' - } - } - const persistence = deferred() + kind: "local" as const, + rootPath: "C:\\NewLocal", + }, + }; + const persistence = deferred(); vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - secondProject - ]) - vi.mocked(api.conversations.saveLocal).mockImplementation( - async (batch) => { - if ( - batch.some( - (conversation) => - conversation.header.projectId === secondProject.id - ) - ) { - await persistence.promise - } + secondProject, + ]); + vi.mocked(api.conversations.saveLocal).mockImplementation(async (batch) => { + if ( + batch.some( + (conversation) => conversation.header.projectId === secondProject.id, + ) + ) { + await persistence.promise; } - ) - render() + }); + render(); expect( - await screen.findByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) + await screen.findByRole("button", { name: "当前项目" }), + ).toHaveTextContent(project.name); - selectProjectOption(secondProject.name) + selectProjectOption(secondProject.name); await waitFor(() => expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(secondProject.name) - ) - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '首条消息必须先保存会话' } - }) - await waitFor(() => - expect(screen.getByLabelText('发送')).toBeEnabled() - ) - fireEvent.click(screen.getByLabelText('发送')) + screen.getByRole("button", { name: "当前项目" }), + ).toHaveTextContent(secondProject.name), + ); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "首条消息必须先保存会话" }, + }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.click(screen.getByLabelText("发送")); await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith([ expect.objectContaining({ header: expect.objectContaining({ - projectId: secondProject.id - }) - }) - ]) - ) - expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled() + projectId: secondProject.id, + }), + }), + ]), + ); + expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled(); - persistence.resolve(undefined) + persistence.resolve(undefined); await waitFor(() => - expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce() - ) + expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce(), + ); const projectSaveIndex = vi .mocked(api.conversations.saveLocal) .mock.calls.findIndex(([batch]) => batch.some( - (conversation) => - conversation.header.projectId === secondProject.id - ) - ) - expect(projectSaveIndex).toBeGreaterThanOrEqual(0) + (conversation) => conversation.header.projectId === secondProject.id, + ), + ); + expect(projectSaveIndex).toBeGreaterThanOrEqual(0); expect( vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder[ projectSaveIndex - ] + ], ).toBeLessThan( - vi.mocked( - api.conversationQueue.enqueueUser - ).mock.invocationCallOrder[0]! - ) - }) + vi.mocked(api.conversationQueue.enqueueUser).mock.invocationCallOrder[0]!, + ); + }); - it('starts in the first local project instead of restoring an SSH project', async () => { + it("starts in the first local project instead of restoring an SSH project", async () => { const remoteProject = { ...project, - id: '00000000-0000-4000-8000-000000000103', - name: '远程项目', - rootPath: '/srv/project', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000103", + name: "远程项目", + rootPath: "/srv/project", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000203', - remoteRootPath: '/srv/project' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000203", + remoteRootPath: "/srv/project", }, - builtInDefault: false - } + builtInDefault: false, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ remoteProject, - project - ]) - localStorage.setItem( - 'goodbuddy.active-project.v1', - remoteProject.id - ) + project, + ]); + localStorage.setItem("goodbuddy.active-project.v1", remoteProject.id); - render() + render(); expect( - await screen.findByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) + await screen.findByRole("button", { name: "当前项目" }), + ).toHaveTextContent(project.name); await waitFor(() => - expect( - localStorage.getItem('goodbuddy.active-project.v1') - ).toBe(project.id) - ) - }) + expect(localStorage.getItem("goodbuddy.active-project.v1")).toBe( + project.id, + ), + ); + }); - it('switches immediately to a managed SSH project and shows only supported OpenCode choices', async () => { - installRemoteProjectsSetting(true) + it("switches immediately to a managed SSH project and shows only supported OpenCode choices", async () => { + installRemoteProjectsSetting(true); const remoteProject = { ...project, - id: '00000000-0000-4000-8000-000000000119', - name: '仅 OpenCode 远程项目', - rootPath: '/srv/project', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000119", + name: "仅 OpenCode 远程项目", + rootPath: "/srv/project", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000219', - remoteRootPath: '/srv/project' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000219", + remoteRootPath: "/srv/project", }, - builtInDefault: false - } + builtInDefault: false, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - remoteProject - ]) + remoteProject, + ]); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000319', + id: "00000000-0000-4000-8000-000000000319", projectId: remoteProject.id, runtimeSelection: { - provider: 'model', - profileId: modelProfileId + provider: "model", + profileId: modelProfileId, }, - title: '旧直连模型会话', + title: "旧直连模型会话", updatedAt: 1_775_000_000_000, - messages: [] + messages: [], }, { - id: '00000000-0000-4000-8000-000000000419', + id: "00000000-0000-4000-8000-000000000419", projectId: remoteProject.id, runtimeSelection: { - provider: 'continue', - profileId: modelProfileId + provider: "continue", + profileId: modelProfileId, }, - title: '旧 Continue 会话', + title: "旧 Continue 会话", updatedAt: 1_774_000_000_000, - messages: [] - } - ]) + messages: [], + }, + ]); - render() - await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(remoteProject.name) + render(); + await screen.findByRole("button", { name: "当前项目" }); + selectProjectOption(remoteProject.name); await waitFor(() => expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(remoteProject.name) - ) - expect( - screen.queryByRole('progressbar') - ).not.toBeInTheDocument() + screen.getByRole("button", { name: "当前项目" }), + ).toHaveTextContent(remoteProject.name), + ); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); - const runtimeButton = await screen.findByRole('button', { - name: /OpenCode · 默认模型/u - }) - fireEvent.click(runtimeButton) - const runtimeMenu = screen.getByRole('menu', { - name: 'Runtime 和模型' - }) + const runtimeButton = await screen.findByRole("button", { + name: /OpenCode · 默认模型/u, + }); + fireEvent.click(runtimeButton); + const runtimeMenu = screen.getByRole("menu", { + name: "Runtime 和模型", + }); expect( - within(runtimeMenu).getByRole('menuitemradio', { - name: /^OpenCode · 默认模型.*sonnet-5$/u - }) - ).toBeInTheDocument() + within(runtimeMenu).getByRole("menuitemradio", { + name: /^OpenCode · 默认模型.*sonnet-5$/u, + }), + ).toBeInTheDocument(); + expect(within(runtimeMenu).getAllByRole("menuitemradio")).toHaveLength(1); + expect(within(runtimeMenu).queryByText("直连模型")).not.toBeInTheDocument(); expect( - within(runtimeMenu).getAllByRole('menuitemradio') - ).toHaveLength(1) + within(runtimeMenu).queryByText("Continue Runtime"), + ).not.toBeInTheDocument(); expect( - within(runtimeMenu).queryByText('直连模型') - ).not.toBeInTheDocument() + within(runtimeMenu).queryByText(/DeepSeek Harness/u), + ).not.toBeInTheDocument(); expect( - within(runtimeMenu).queryByText('Continue Runtime') - ).not.toBeInTheDocument() - expect( - within(runtimeMenu).queryByText(/DeepSeek Harness/u) - ).not.toBeInTheDocument() - expect( - within(runtimeMenu).getByRole('menuitem', { - name: '管理 Runtime 和模型连接' - }) - ).toBeInTheDocument() + within(runtimeMenu).getByRole("menuitem", { + name: "管理 Runtime 和模型连接", + }), + ).toBeInTheDocument(); - fireEvent.keyDown(document, { key: 'Escape' }) - fireEvent.click( - screen.getByText('旧 Continue 会话').closest('button')! - ) + fireEvent.keyDown(document, { key: "Escape" }); + fireEvent.click(screen.getByText("旧 Continue 会话").closest("button")!); expect( - await screen.findByRole('button', { - name: /OpenCode · 默认模型/u - }) - ).toBeInTheDocument() - }) + await screen.findByRole("button", { + name: /OpenCode · 默认模型/u, + }), + ).toBeInTheDocument(); + }); - it('keeps managed SSH histories visible while independently blocking recovery projects', async () => { - installRemoteProjectsSetting(true) + it("keeps managed SSH histories visible while independently blocking recovery projects", async () => { + installRemoteProjectsSetting(true); const first = { ...project, - id: '00000000-0000-4000-8000-000000000131', - name: '恢复项目一', - rootPath: '/srv/one', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000131", + name: "恢复项目一", + rootPath: "/srv/one", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000231', - remoteRootPath: '/srv/one' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000231", + remoteRootPath: "/srv/one", }, - builtInDefault: false - } + builtInDefault: false, + }; const second = { ...first, - id: '00000000-0000-4000-8000-000000000132', - name: '恢复项目二', - rootPath: '/srv/two', + id: "00000000-0000-4000-8000-000000000132", + name: "恢复项目二", + rootPath: "/srv/two", executionSpace: { ...first.executionSpace, - remoteRootPath: '/srv/two' - } - } - const firstConversationId = - '00000000-0000-4000-8000-000000000331' - const secondConversationId = - '00000000-0000-4000-8000-000000000332' - const firstRequestId = - '00000000-0000-4000-8000-000000000431' + remoteRootPath: "/srv/two", + }, + }; + const firstConversationId = "00000000-0000-4000-8000-000000000331"; + const secondConversationId = "00000000-0000-4000-8000-000000000332"; + const firstRequestId = "00000000-0000-4000-8000-000000000431"; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, first, - second - ]) + second, + ]); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: firstConversationId, projectId: first.id, - runtimeSelection: { provider: 'opencode' }, - title: '恢复前的会话', + runtimeSelection: { provider: "opencode" }, + title: "恢复前的会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000531', - role: 'assistant', - content: '恢复期间仍可查看的历史', + id: "00000000-0000-4000-8000-000000000531", + role: "assistant", + content: "恢复期间仍可查看的历史", createdAt: 1_775_000_000_000, - state: 'complete' - } - ] + state: "complete", + }, + ], }, { id: secondConversationId, projectId: second.id, - runtimeSelection: { provider: 'opencode' }, - title: '第二个恢复会话', + runtimeSelection: { provider: "opencode" }, + title: "第二个恢复会话", updatedAt: 1_774_000_000_000, - messages: [] - } - ]) - vi.mocked( - api.projects.remote.getRecoverySnapshot - ).mockResolvedValueOnce({ + messages: [], + }, + ]); + vi.mocked(api.projects.remote.getRecoverySnapshot).mockResolvedValueOnce({ recoveries: [ { projectId: first.id, requestId: firstRequestId, - stage: 'cursor', - current: '125' + stage: "cursor", + current: "125", }, { projectId: second.id, - requestId: - '00000000-0000-4000-8000-000000000432', - stage: 'agent' - } - ] - }) + requestId: "00000000-0000-4000-8000-000000000432", + stage: "agent", + }, + ], + }); - render() - await screen.findByRole('button', { name: '当前项目' }) + render(); + await screen.findByRole("button", { name: "当前项目" }); await waitFor(() => - expect( - api.projects.remote.getRecoverySnapshot - ).toHaveBeenCalledOnce() - ) - vi.mocked(api.conversationQueue.ready).mockClear() + expect(api.projects.remote.getRecoverySnapshot).toHaveBeenCalledOnce(), + ); + vi.mocked(api.conversationQueue.ready).mockClear(); - selectProjectOption(first.name) + selectProjectOption(first.name); + expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent( + first.name, + ); expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(first.name) + await screen.findByText("恢复期间仍可查看的历史"), + ).toBeInTheDocument(); expect( - await screen.findByText('恢复期间仍可查看的历史') - ).toBeInTheDocument() - expect( - screen.getByText('正在恢复对话,已同步到事件 125') - ).toBeInTheDocument() + screen.getByText("正在恢复对话,已同步到事件 125"), + ).toBeInTheDocument(); expect(api.conversationQueue.ready).not.toHaveBeenCalledWith( - firstConversationId - ) + firstConversationId, + ); - const firstComposer = screen.getByLabelText('向 GoodBuddy 提问') + const firstComposer = screen.getByLabelText("向 GoodBuddy 提问"); fireEvent.change(firstComposer, { - target: { value: '恢复完成后发送' } - }) - fireEvent.keyDown(firstComposer, { key: 'Enter' }) - expect(firstComposer).toHaveValue('恢复完成后发送') - expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled() - expect(screen.getByLabelText('发送')).toBeDisabled() + target: { value: "恢复完成后发送" }, + }); + fireEvent.keyDown(firstComposer, { key: "Enter" }); + expect(firstComposer).toHaveValue("恢复完成后发送"); + expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled(); + expect(screen.getByLabelText("发送")).toBeDisabled(); - fireEvent.click(screen.getByRole('button', { name: '当前项目' })) - const menu = screen.getByRole('menu', { name: '当前项目' }) - expect( - within(menu).getByText('正在恢复远端 Agent…') - ).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "当前项目" })); + const menu = screen.getByRole("menu", { name: "当前项目" }); + expect(within(menu).getByText("正在恢复远端 Agent…")).toBeInTheDocument(); fireEvent.click( - within(menu).getByText(project.name, { selector: 'b' }) - .closest('[role="menuitemradio"]')! - ) - const localComposer = screen.getByLabelText('向 GoodBuddy 提问') + within(menu) + .getByText(project.name, { selector: "b" }) + .closest('[role="menuitemradio"]')!, + ); + const localComposer = screen.getByLabelText("向 GoodBuddy 提问"); fireEvent.change(localComposer, { - target: { value: '本地项目不受影响' } - }) + target: { value: "本地项目不受影响" }, + }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.keyDown(localComposer, { key: "Enter" }); await waitFor(() => - expect(screen.getByLabelText('发送')).toBeEnabled() - ) - fireEvent.keyDown(localComposer, { key: 'Enter' }) - await waitFor(() => - expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce() - ) + expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce(), + ); - selectProjectOption(first.name) + selectProjectOption(first.name); act(() => { remoteProjectRecoveryProgressListener?.({ projectId: first.id, - requestId: - '00000000-0000-4000-8000-000000000499', - stage: 'completed' - }) - }) - expect(screen.getByLabelText('发送')).toBeDisabled() - expect(firstComposer).toHaveValue('恢复完成后发送') + requestId: "00000000-0000-4000-8000-000000000499", + stage: "completed", + }); + }); + expect(screen.getByLabelText("发送")).toBeDisabled(); + expect(firstComposer).toHaveValue("恢复完成后发送"); act(() => { remoteProjectRecoveryProgressListener?.({ projectId: first.id, requestId: firstRequestId, - stage: 'completed' - }) - }) + stage: "completed", + }); + }); await waitFor(() => - expect(screen.getByText('恢复完成')).toBeInTheDocument() - ) - expect(screen.getByLabelText('发送')).toBeEnabled() + expect(screen.getByText("恢复完成")).toBeInTheDocument(), + ); + expect(screen.getByLabelText("发送")).toBeEnabled(); await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith( - firstConversationId - ) - ) + firstConversationId, + ), + ); expect(api.conversationQueue.ready).not.toHaveBeenCalledWith( - secondConversationId - ) - }) + secondConversationId, + ); + }); - describe('persisted terminal state convergence', () => { - let conversationsChangedListener: (() => void) | undefined + describe("persisted terminal state convergence", () => { + let conversationsChangedListener: (() => void) | undefined; const startStreamingRun = async (): Promise<{ - requestId: string - conversationId: string - assistantMessageId: string - userMessageId: string + requestId: string; + conversationId: string; + assistantMessageId: string; + userMessageId: string; }> => { - conversationsChangedListener = undefined - vi.mocked(api.conversations.onChanged).mockImplementation( - (listener) => { - conversationsChangedListener = listener - return () => { - if (conversationsChangedListener === listener) { - conversationsChangedListener = undefined - } + conversationsChangedListener = undefined; + vi.mocked(api.conversations.onChanged).mockImplementation((listener) => { + conversationsChangedListener = listener; + return () => { + if (conversationsChangedListener === listener) { + conversationsChangedListener = undefined; } - } - ) - render() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '断网期间继续执行' } - }) - await waitFor(() => - expect(screen.getByLabelText('发送')).toBeEnabled() - ) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + }; + }); + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "断网期间继续执行" }, + }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if ( !request?.currentAssistantMessageId || !request.currentUserMessageId ) { - throw new Error('Missing request') + throw new Error("Missing request"); } expect( screen - .getByText('正在连接 Agent Runtime') - .querySelector('.message__status-dot') - ).toHaveClass('message__status-dot--active') - expect(screen.getByLabelText('加入待发送队列')).toBeInTheDocument() - vi.mocked(api.conversationQueue.ready).mockClear() + .getByText("正在连接 Agent Runtime") + .querySelector(".message__status-dot"), + ).toHaveClass("message__status-dot--active"); + expect(screen.getByLabelText("加入待发送队列")).toBeInTheDocument(); + vi.mocked(api.conversationQueue.ready).mockClear(); return { requestId: request.requestId, conversationId: request.conversationId, assistantMessageId: request.currentAssistantMessageId, - userMessageId: request.currentUserMessageId - } - } + userMessageId: request.currentUserMessageId, + }; + }; const persistedSnapshot = ( input: { - conversationId: string - userMessageId: string - assistantMessageId: string + conversationId: string; + userMessageId: string; + assistantMessageId: string; }, assistant: { - state: 'streaming' | 'complete' | 'error' - content: string - status?: string - } + state: "streaming" | "complete" | "error"; + content: string; + status?: string; + }, ): ConversationSnapshot => ({ id: input.conversationId, projectId, - runtimeSelection: { provider: 'model', profileId: modelProfileId }, - title: '断网期间继续执行', + runtimeSelection: { provider: "model", profileId: modelProfileId }, + title: "断网期间继续执行", updatedAt: 1_000, messages: [ { id: input.userMessageId, - role: 'user', - content: '断网期间继续执行', + role: "user", + content: "断网期间继续执行", createdAt: 900, - state: 'complete' + state: "complete", }, { id: input.assistantMessageId, - role: 'assistant', + role: "assistant", content: assistant.content, createdAt: 950, state: assistant.state, - status: assistant.status - } - ] - }) + status: assistant.status, + }, + ], + }); - it('converges a streaming message onto the persisted completed state after reconnecting', async () => { - const started = await startStreamingRun() - expect(conversationsChangedListener).toBeDefined() + it("converges a streaming message onto the persisted completed state after reconnecting", async () => { + const started = await startStreamingRun(); + expect(conversationsChangedListener).toBeDefined(); vi.mocked(api.conversations.list).mockResolvedValue([ persistedSnapshot(started, { - state: 'complete', - content: '远端在断线期间已经完成的回复', - status: '任务已完成' - }) - ]) + state: "complete", + content: "远端在断线期间已经完成的回复", + status: "任务已完成", + }), + ]); act(() => { - conversationsChangedListener?.() - }) + conversationsChangedListener?.(); + }); expect( - await screen.findByText('远端在断线期间已经完成的回复') - ).toBeInTheDocument() + await screen.findByText("远端在断线期间已经完成的回复"), + ).toBeInTheDocument(); await waitFor(() => expect( - screen.queryByText('正在连接 Agent Runtime') - ).not.toBeInTheDocument() - ) - expect( - document.querySelector('.message__status-dot--active') - ).toBeNull() - expect(screen.getByLabelText('发送')).toBeInTheDocument() + screen.queryByText("正在连接 Agent Runtime"), + ).not.toBeInTheDocument(), + ); + expect(document.querySelector(".message__status-dot--active")).toBeNull(); + expect(screen.getByLabelText("发送")).toBeInTheDocument(); await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith( - started.conversationId - ) - ) + started.conversationId, + ), + ); // A late terminal event for the settled run must be ignored rather // than resurrecting the message or double-releasing the queue. - vi.mocked(api.conversationQueue.ready).mockClear() + vi.mocked(api.conversationQueue.ready).mockClear(); act(() => { agentListener?.({ requestId: started.requestId, - type: 'error', - status: 'failed', - message: '迟到的连接中断错误' - }) - }) + type: "error", + status: "failed", + message: "迟到的连接中断错误", + }); + }); + expect(screen.queryByText("迟到的连接中断错误")).not.toBeInTheDocument(); expect( - screen.queryByText('迟到的连接中断错误') - ).not.toBeInTheDocument() - expect( - screen.getByText('远端在断线期间已经完成的回复') - ).toBeInTheDocument() - expect(api.conversationQueue.ready).not.toHaveBeenCalled() - }) + screen.getByText("远端在断线期间已经完成的回复"), + ).toBeInTheDocument(); + expect(api.conversationQueue.ready).not.toHaveBeenCalled(); + }); - it('converges a streaming message onto the persisted failed state after reconnecting', async () => { - const started = await startStreamingRun() + it("converges a streaming message onto the persisted failed state after reconnecting", async () => { + const started = await startStreamingRun(); vi.mocked(api.conversations.list).mockResolvedValue([ persistedSnapshot(started, { - state: 'error', - content: '', - status: '远端 Agent 执行失败' - }) - ]) + state: "error", + content: "", + status: "远端 Agent 执行失败", + }), + ]); act(() => { - conversationsChangedListener?.() - }) + conversationsChangedListener?.(); + }); expect( - await screen.findByText('远端 Agent 执行失败') - ).toBeInTheDocument() + await screen.findByText("远端 Agent 执行失败"), + ).toBeInTheDocument(); expect( - screen.queryByText('正在连接 Agent Runtime') - ).not.toBeInTheDocument() - expect( - document.querySelector('.message__status-dot--active') - ).toBeNull() + screen.queryByText("正在连接 Agent Runtime"), + ).not.toBeInTheDocument(); + expect(document.querySelector(".message__status-dot--active")).toBeNull(); await waitFor(() => - expect(screen.getByLabelText('发送')).toBeInTheDocument() - ) + expect(screen.getByLabelText("发送")).toBeInTheDocument(), + ); await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith( - started.conversationId - ) - ) - }) + started.conversationId, + ), + ); + }); - it('keeps the run in flight while the persisted message is still streaming and refreshes on focus', async () => { - const started = await startStreamingRun() + it("keeps the run in flight while the persisted message is still streaming and refreshes on focus", async () => { + const started = await startStreamingRun(); vi.mocked(api.conversations.list).mockResolvedValue([ persistedSnapshot(started, { - state: 'streaming', - content: '', - status: '远端 Agent 正在处理请求' - }) - ]) + state: "streaming", + content: "", + status: "远端 Agent 正在处理请求", + }), + ]); act(() => { - conversationsChangedListener?.() - }) + conversationsChangedListener?.(); + }); await waitFor(() => - expect(api.conversations.list).toHaveBeenCalledTimes(2) - ) + expect(api.conversations.list).toHaveBeenCalledTimes(2), + ); // The local streaming copy still wins over a non-terminal snapshot. - expect( - screen.getByText('正在连接 Agent Runtime') - ).toBeInTheDocument() - expect(screen.getByLabelText('加入待发送队列')).toBeInTheDocument() - expect(api.conversationQueue.ready).not.toHaveBeenCalled() + expect(screen.getByText("正在连接 Agent Runtime")).toBeInTheDocument(); + expect(screen.getByLabelText("加入待发送队列")).toBeInTheDocument(); + expect(api.conversationQueue.ready).not.toHaveBeenCalled(); // Regaining focus re-reads the store and converges once the remote // terminal state has landed, even without another change event. vi.mocked(api.conversations.list).mockResolvedValue([ persistedSnapshot(started, { - state: 'complete', - content: '聚焦后读到的最终回复', - status: '任务已完成' - }) - ]) + state: "complete", + content: "聚焦后读到的最终回复", + status: "任务已完成", + }), + ]); act(() => { - window.dispatchEvent(new Event('focus')) - }) + window.dispatchEvent(new Event("focus")); + }); expect( - await screen.findByText('聚焦后读到的最终回复') - ).toBeInTheDocument() + await screen.findByText("聚焦后读到的最终回复"), + ).toBeInTheDocument(); expect( - screen.queryByText('正在连接 Agent Runtime') - ).not.toBeInTheDocument() + screen.queryByText("正在连接 Agent Runtime"), + ).not.toBeInTheDocument(); await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith( - started.conversationId - ) - ) + started.conversationId, + ), + ); // The live done event now targets an already-settled run: no-op. act(() => { - agentListener?.({ requestId: started.requestId, type: 'done' }) - }) - expect( - screen.getByText('聚焦后读到的最终回复') - ).toBeInTheDocument() - }) + agentListener?.({ requestId: started.requestId, type: "done" }); + }); + expect(screen.getByText("聚焦后读到的最终回复")).toBeInTheDocument(); + }); - it('keeps streaming through window switches while the store still reports the run', async () => { - const started = await startStreamingRun() + it("keeps streaming through window switches while the store still reports the run", async () => { + const started = await startStreamingRun(); act(() => { agentListener?.({ requestId: started.requestId, - type: 'text', - delta: '第一段回答' - }) - }) - expect(await screen.findByText('第一段回答')).toBeInTheDocument() + type: "text", + delta: "第一段回答", + }); + }); + expect(await screen.findByText("第一段回答")).toBeInTheDocument(); // The renderer keeps saving the streaming message, so a refresh reads // it back as streaming. That must not be reported as an interruption. vi.mocked(api.conversations.list).mockResolvedValue([ persistedSnapshot(started, { - state: 'streaming', - content: '第一段回答' - }) - ]) + state: "streaming", + content: "第一段回答", + }), + ]); act(() => { - window.dispatchEvent( - new Event('visibilitychange', { bubbles: true }) - ) - window.dispatchEvent(new Event('focus')) - }) + window.dispatchEvent(new Event("visibilitychange", { bubbles: true })); + window.dispatchEvent(new Event("focus")); + }); await waitFor(() => - expect(api.conversations.list).toHaveBeenCalledTimes(2) - ) + expect(api.conversations.list).toHaveBeenCalledTimes(2), + ); expect( - screen.queryByText('上次运行意外中断,可以重新发送问题') - ).not.toBeInTheDocument() + screen.queryByText("上次运行意外中断,可以重新发送问题"), + ).not.toBeInTheDocument(); expect( - screen.queryByRole('button', { name: '重新编辑并发送' }) - ).not.toBeInTheDocument() - expect(api.conversationQueue.ready).not.toHaveBeenCalled() + screen.queryByRole("button", { name: "重新编辑并发送" }), + ).not.toBeInTheDocument(); + expect(api.conversationQueue.ready).not.toHaveBeenCalled(); // The run is still live, so later deltas and the terminal event must // still reach the message. act(() => { agentListener?.({ requestId: started.requestId, - type: 'text', - delta: ',第二段回答' - }) + type: "text", + delta: ",第二段回答", + }); agentListener?.({ requestId: started.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); expect( - await screen.findByText('第一段回答,第二段回答') - ).toBeInTheDocument() + await screen.findByText("第一段回答,第二段回答"), + ).toBeInTheDocument(); await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith( - started.conversationId - ) - ) - expect(screen.getByLabelText('发送')).toBeInTheDocument() - }) - }) + started.conversationId, + ), + ); + expect(screen.getByLabelText("发送")).toBeInTheDocument(); + }); + }); - it('retries a failed recovery with a new request ID and cleans up its listener', async () => { - installRemoteProjectsSetting(true) + it("retries a failed recovery with a new request ID and cleans up its listener", async () => { + installRemoteProjectsSetting(true); const remoteProject = { ...project, - id: '00000000-0000-4000-8000-000000000133', - name: '失败的恢复项目', - rootPath: '/srv/failed', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000133", + name: "失败的恢复项目", + rootPath: "/srv/failed", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000233', - remoteRootPath: '/srv/failed' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000233", + remoteRootPath: "/srv/failed", }, - builtInDefault: false - } - const oldRequestId = - '00000000-0000-4000-8000-000000000433' - const newRequestId = - '00000000-0000-4000-8000-000000000434' + builtInDefault: false, + }; + const oldRequestId = "00000000-0000-4000-8000-000000000433"; + const newRequestId = "00000000-0000-4000-8000-000000000434"; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - remoteProject - ]) - vi.mocked( - api.projects.remote.getRecoverySnapshot - ).mockResolvedValueOnce({ + remoteProject, + ]); + vi.mocked(api.projects.remote.getRecoverySnapshot).mockResolvedValueOnce({ recoveries: [ { projectId: remoteProject.id, requestId: oldRequestId, - stage: 'failed', - message: '无法连接 Host', - retryable: true - } - ] - }) + stage: "failed", + message: "无法连接 Host", + retryable: true, + }, + ], + }); vi.mocked(api.projects.remote.retryRecovery).mockResolvedValueOnce({ projectId: remoteProject.id, requestId: newRequestId, - stage: 'network' - }) + stage: "network", + }); - const { unmount } = render() - await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(remoteProject.name) - expect( - await screen.findByRole('alert') - ).toHaveTextContent('恢复失败:无法连接 Host') + const { unmount } = render(); + await screen.findByRole("button", { name: "当前项目" }); + selectProjectOption(remoteProject.name); + expect(await screen.findByRole("alert")).toHaveTextContent( + "恢复失败:无法连接 Host", + ); fireEvent.click( - screen.getByRole('button', { - name: `重试恢复项目 ${remoteProject.name}` - }) - ) + screen.getByRole("button", { + name: `重试恢复项目 ${remoteProject.name}`, + }), + ); await waitFor(() => - expect( - api.projects.remote.retryRecovery - ).toHaveBeenCalledWith(remoteProject.id) - ) - expect( - await screen.findByText('正在恢复网络连接…') - ).toBeInTheDocument() + expect(api.projects.remote.retryRecovery).toHaveBeenCalledWith( + remoteProject.id, + ), + ); + expect(await screen.findByText("正在恢复网络连接…")).toBeInTheDocument(); act(() => { remoteProjectRecoveryProgressListener?.({ projectId: remoteProject.id, requestId: oldRequestId, - stage: 'completed' - }) - }) - expect(screen.getByText('正在恢复网络连接…')).toBeInTheDocument() - expect(screen.getByLabelText('发送')).toBeDisabled() + stage: "completed", + }); + }); + expect(screen.getByText("正在恢复网络连接…")).toBeInTheDocument(); + expect(screen.getByLabelText("发送")).toBeDisabled(); act(() => { remoteProjectRecoveryProgressListener?.({ projectId: remoteProject.id, requestId: newRequestId, - stage: 'completed' - }) - }) - expect( - await screen.findByText('恢复完成') - ).toBeInTheDocument() + stage: "completed", + }); + }); + expect(await screen.findByText("恢复完成")).toBeInTheDocument(); act(() => { remoteProjectRecoveryProgressListener?.({ projectId: remoteProject.id, requestId: newRequestId, - stage: 'network' - }) - }) - expect(screen.getByText('恢复完成')).toBeInTheDocument() + stage: "network", + }); + }); + expect(screen.getByText("恢复完成")).toBeInTheDocument(); - unmount() - expect(removeProjectRecoveryListener).toHaveBeenCalledOnce() - }) + unmount(); + expect(removeProjectRecoveryListener).toHaveBeenCalledOnce(); + }); - it('switches between projects on one Host without a connection phase', async () => { - installRemoteProjectsSetting(true) + it("switches between projects on one Host without a connection phase", async () => { + installRemoteProjectsSetting(true); const first = { ...project, - id: '00000000-0000-4000-8000-000000000126', - name: 'Host 项目一', - rootPath: '/srv/one', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000126", + name: "Host 项目一", + rootPath: "/srv/one", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000226', - remoteRootPath: '/srv/one' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000226", + remoteRootPath: "/srv/one", }, - builtInDefault: false - } + builtInDefault: false, + }; const second = { ...first, - id: '00000000-0000-4000-8000-000000000127', - name: 'Host 项目二', - rootPath: '/srv/two', + id: "00000000-0000-4000-8000-000000000127", + name: "Host 项目二", + rootPath: "/srv/two", executionSpace: { ...first.executionSpace, - remoteRootPath: '/srv/two' - } - } + remoteRootPath: "/srv/two", + }, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, first, - second - ]) + second, + ]); - render() - await screen.findByRole('button', { name: '当前项目' }) - vi.mocked( - api.runtimeCustomization.getNativeSnapshot - ).mockClear() + render(); + await screen.findByRole("button", { name: "当前项目" }); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockClear(); for (const remoteProject of [first, second, first]) { - selectProjectOption(remoteProject.name) + selectProjectOption(remoteProject.name); expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(remoteProject.name) - expect( - screen.queryByRole('progressbar') - ).not.toBeInTheDocument() + screen.getByRole("button", { name: "当前项目" }), + ).toHaveTextContent(remoteProject.name); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); } - expect( - api.runtimeCustomization.getNativeSnapshot - ).not.toHaveBeenCalled() - }) + expect(api.runtimeCustomization.getNativeSnapshot).not.toHaveBeenCalled(); + }); - it('keeps saved remote projects hidden while the preview is disabled', async () => { - installRemoteProjectsSetting(false) + it("keeps saved remote projects hidden while the preview is disabled", async () => { + installRemoteProjectsSetting(false); const remoteProject = { ...project, - id: '00000000-0000-4000-8000-000000000116', - name: '已保存远程项目', - rootPath: '/srv/project', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000116", + name: "已保存远程项目", + rootPath: "/srv/project", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000216', - remoteRootPath: '/srv/project' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000216", + remoteRootPath: "/srv/project", }, - builtInDefault: false - } + builtInDefault: false, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - remoteProject - ]) + remoteProject, + ]); - render() + render(); - const trigger = await screen.findByRole('button', { - name: '当前项目' - }) - fireEvent.click(trigger) - const menu = screen.getByRole('menu', { name: '当前项目' }) + const trigger = await screen.findByRole("button", { + name: "当前项目", + }); + fireEvent.click(trigger); + const menu = screen.getByRole("menu", { name: "当前项目" }); expect( - within(menu).queryByText(remoteProject.name) - ).not.toBeInTheDocument() - }) + within(menu).queryByText(remoteProject.name), + ).not.toBeInTheDocument(); + }); - it('falls back to a local project when the remote preview is disabled', async () => { - const { updateSettings } = installRemoteProjectsSetting(true) + it("falls back to a local project when the remote preview is disabled", async () => { + const { updateSettings } = installRemoteProjectsSetting(true); const remoteProject = { ...project, - id: '00000000-0000-4000-8000-000000000117', - name: '预览远程项目', - rootPath: '/srv/project', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000117", + name: "预览远程项目", + rootPath: "/srv/project", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000217', - remoteRootPath: '/srv/project' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000217", + remoteRootPath: "/srv/project", }, - builtInDefault: false - } + builtInDefault: false, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - remoteProject - ]) + remoteProject, + ]); - render() - await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(remoteProject.name) + render(); + await screen.findByRole("button", { name: "当前项目" }); + selectProjectOption(remoteProject.name); await waitFor(() => expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(remoteProject.name) - ) + screen.getByRole("button", { name: "当前项目" }), + ).toHaveTextContent(remoteProject.name), + ); - fireEvent.click(screen.getByText('本地工作区')) + fireEvent.click(screen.getByText("本地工作区")); + fireEvent.click(await screen.findByRole("tab", { name: "平台功能" })); fireEvent.click( - await screen.findByRole('tab', { name: '平台功能' }) - ) - fireEvent.click( - await screen.findByRole('tab', { - name: '远程项目(技术预览)' - }) - ) - const remoteProjectsSwitch = await screen.findByRole('switch', { - name: '远程项目(技术预览)' - }) - expect(remoteProjectsSwitch).toBeChecked() - fireEvent.click(remoteProjectsSwitch) + await screen.findByRole("tab", { + name: "远程项目(技术预览)", + }), + ); + const remoteProjectsSwitch = await screen.findByRole("switch", { + name: "远程项目(技术预览)", + }); + expect(remoteProjectsSwitch).toBeChecked(); + fireEvent.click(remoteProjectsSwitch); await waitFor(() => expect(updateSettings).toHaveBeenCalledWith({ - remoteProjectsEnabled: false - }) - ) + remoteProjectsEnabled: false, + }), + ); await waitFor(() => expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) - ) - fireEvent.click( - screen.getByRole('button', { name: '当前项目' }) - ) - expect( - screen.queryByText(remoteProject.name) - ).not.toBeInTheDocument() - expect(api.projects.setArchived).not.toHaveBeenCalled() - expect(api.projects.delete).not.toHaveBeenCalled() - }) + screen.getByRole("button", { name: "当前项目" }), + ).toHaveTextContent(project.name), + ); + fireEvent.click(screen.getByRole("button", { name: "当前项目" })); + expect(screen.queryByText(remoteProject.name)).not.toBeInTheDocument(); + expect(api.projects.setArchived).not.toHaveBeenCalled(); + expect(api.projects.delete).not.toHaveBeenCalled(); + }); - it('deletes an unreachable non-active remote project without connecting', async () => { - installRemoteProjectsSetting(true) + it("deletes an unreachable non-active remote project without connecting", async () => { + installRemoteProjectsSetting(true); const remoteProject = { ...project, - id: '00000000-0000-4000-8000-000000000125', - name: '目录已丢失的远程项目', - rootPath: '/srv/missing', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000125", + name: "目录已丢失的远程项目", + rootPath: "/srv/missing", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000225', - remoteRootPath: '/srv/missing' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000225", + remoteRootPath: "/srv/missing", }, - builtInDefault: false - } + builtInDefault: false, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - remoteProject - ]) - render() - await screen.findByRole('button', { name: '当前项目' }) + remoteProject, + ]); + render(); + await screen.findByRole("button", { name: "当前项目" }); - fireEvent.click(screen.getByLabelText('当前项目')) + fireEvent.click(screen.getByLabelText("当前项目")); fireEvent.click( - screen.getByRole('menuitem', { - name: `管理项目 ${remoteProject.name}` - }) - ) - const dialog = screen.getByRole('dialog', { name: '项目设置' }) - fireEvent.click( - within(dialog).getByRole('button', { name: '删除项目' }) - ) + screen.getByRole("menuitem", { + name: `管理项目 ${remoteProject.name}`, + }), + ); + const dialog = screen.getByRole("dialog", { name: "项目设置" }); + fireEvent.click(within(dialog).getByRole("button", { name: "删除项目" })); fireEvent.change( - within(dialog).getByLabelText( - `输入“${remoteProject.name}”确认删除` - ), - { target: { value: remoteProject.name } } - ) + within(dialog).getByLabelText(`输入“${remoteProject.name}”确认删除`), + { target: { value: remoteProject.name } }, + ); fireEvent.click( - within(dialog).getByRole('button', { - name: '永久删除项目' - }) - ) + within(dialog).getByRole("button", { + name: "永久删除项目", + }), + ); await waitFor(() => expect(api.projects.delete).toHaveBeenCalledWith( remoteProject.id, - remoteProject.name - ) - ) - expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) - }) + remoteProject.name, + ), + ); + expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent( + project.name, + ); + }); - it('shows grouped project details and keeps the rich menu keyboard accessible', async () => { + it("shows grouped project details and keeps the rich menu keyboard accessible", async () => { const channelProject = { ...project, - id: '00000000-0000-4000-8000-000000000201', - name: '微信 ClawBot', - kind: 'channel' as const, - channel: 'weixin' as const - } + id: "00000000-0000-4000-8000-000000000201", + name: "微信 ClawBot", + kind: "channel" as const, + channel: "weixin" as const, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - channelProject - ]) - render() + channelProject, + ]); + render(); - const trigger = await screen.findByRole('button', { - name: '当前项目' - }) - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + const trigger = await screen.findByRole("button", { + name: "当前项目", + }); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); - const menu = screen.getByRole('menu', { name: '当前项目' }) + const menu = screen.getByRole("menu", { name: "当前项目" }); expect( - within(menu).getByRole('group', { name: '本地项目' }) - ).toHaveTextContent('本地目录 · C:\\Users\\test') + within(menu).getByRole("group", { name: "本地项目" }), + ).toHaveTextContent("本地目录 · C:\\Users\\test"); expect( - within(menu).getByRole('group', { name: '远程通道' }) - ).toHaveTextContent( - '微信 ClawBot · 远程通道 · C:\\Users\\test' - ) + within(menu).getByRole("group", { name: "远程通道" }), + ).toHaveTextContent("微信 ClawBot · 远程通道 · C:\\Users\\test"); const selectedProject = within(menu) - .getByText(project.name, { selector: 'b' }) - .closest('[role="menuitemradio"]') - const remoteProjectSettings = within(menu).getByRole( - 'menuitem', - { name: `管理项目 ${channelProject.name}` } - ) - expect(selectedProject).toHaveAttribute('aria-checked', 'true') - await waitFor(() => expect(selectedProject).toHaveFocus()) + .getByText(project.name, { selector: "b" }) + .closest('[role="menuitemradio"]'); + const remoteProjectSettings = within(menu).getByRole("menuitem", { + name: `管理项目 ${channelProject.name}`, + }); + expect(selectedProject).toHaveAttribute("aria-checked", "true"); + await waitFor(() => expect(selectedProject).toHaveFocus()); - fireEvent.keyDown(selectedProject!, { key: 'End' }) - expect(remoteProjectSettings).toHaveFocus() - fireEvent.keyDown(remoteProjectSettings, { key: 'Escape' }) - expect(trigger).toHaveFocus() + fireEvent.keyDown(selectedProject!, { key: "End" }); + expect(remoteProjectSettings).toHaveFocus(); + fireEvent.keyDown(remoteProjectSettings, { key: "Escape" }); + expect(trigger).toHaveFocus(); expect( - screen.queryByRole('menu', { name: '当前项目' }) - ).not.toBeInTheDocument() - }) + screen.queryByRole("menu", { name: "当前项目" }), + ).not.toBeInTheDocument(); + }); - it('keeps channel projects empty until a client message creates a remote conversation', async () => { + it("keeps channel projects empty until a client message creates a remote conversation", async () => { const channelProject = { ...project, - id: '00000000-0000-4000-8000-000000000201', - name: '微信 ClawBot', - kind: 'channel' as const, - channel: 'weixin' as const, + id: "00000000-0000-4000-8000-000000000201", + name: "微信 ClawBot", + kind: "channel" as const, + channel: "weixin" as const, runtimeSelection: { - provider: 'model' as const, - profileId: modelProfileId - } - } + provider: "model" as const, + profileId: modelProfileId, + }, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - channelProject - ]) - render() + channelProject, + ]); + render(); - await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(channelProject.name) + await screen.findByRole("button", { name: "当前项目" }); + selectProjectOption(channelProject.name); expect( - screen.queryByRole('button', { name: /新建对话/u }) - ).not.toBeInTheDocument() - expect(screen.queryByText('Ctrl N')).not.toBeInTheDocument() - expect( - screen.getAllByText('尚无远程会话').length - ).toBeGreaterThan(0) + screen.queryByRole("button", { name: /新建对话/u }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Ctrl N")).not.toBeInTheDocument(); + expect(screen.getAllByText("尚无远程会话").length).toBeGreaterThan(0); expect( screen.getByText( - '请先连接微信 ClawBot,远程用户发送第一条消息后,会话会自动出现在这里。' - ) - ).toBeInTheDocument() + "请先连接微信 ClawBot,远程用户发送第一条消息后,会话会自动出现在这里。", + ), + ).toBeInTheDocument(); fireEvent.keyDown(document, { - key: 'n', - ctrlKey: true - }) + key: "n", + ctrlKey: true, + }); expect( - await screen.findByText( - '通道项目的会话由客户端收到新消息后自动创建' - ) - ).toBeInTheDocument() + await screen.findByText("通道项目的会话由客户端收到新消息后自动创建"), + ).toBeInTheDocument(); await act( () => new Promise((resolve) => { - setTimeout(resolve, 550) - }) - ) + setTimeout(resolve, 550); + }), + ); const savedChannelHeaders = vi .mocked(api.conversations.saveLocal) - .mock.calls.flatMap(([batch]) => batch.map((entry) => entry.header)) + .mock.calls.flatMap(([batch]) => batch.map((entry) => entry.header)); expect( savedChannelHeaders.some( - (header) => header.projectId === channelProject.id - ) - ).toBe(false) - expect(screen.getAllByText('尚无远程会话').length).toBeGreaterThan(0) + (header) => header.projectId === channelProject.id, + ), + ).toBe(false); + expect(screen.getAllByText("尚无远程会话").length).toBeGreaterThan(0); - fireEvent.click(screen.getByRole('button', { name: '打开设置' })) + fireEvent.click(screen.getByRole("button", { name: "打开设置" })); - const channelSettingsTab = await screen.findByRole('tab', { - name: '消息通道' - }) - expect(channelSettingsTab).toHaveAttribute('aria-selected', 'true') - }) + const channelSettingsTab = await screen.findByRole("tab", { + name: "消息通道", + }); + expect(channelSettingsTab).toHaveAttribute("aria-selected", "true"); + }); - it('keeps channel project settings synchronized between both entry points', async () => { + it("keeps channel project settings synchronized between both entry points", async () => { let channelProjects: AssistantProject[] = [ - ['weixin', '微信 ClawBot'], - ['wecom', '企业微信'], - ['dingtalk', '钉钉'] + ["weixin", "微信 ClawBot"], + ["wecom", "企业微信"], + ["dingtalk", "钉钉"], ].map(([channel, name], index) => ({ ...project, id: `00000000-0000-4000-8000-00000000020${index + 1}`, name: name!, description: `${name}通道项目`, - kind: 'channel' as const, - channel: channel as 'weixin' | 'wecom' | 'dingtalk', + kind: "channel" as const, + channel: channel as "weixin" | "wecom" | "dingtalk", runtimeSelection: { - provider: 'model' as const, - profileId: modelProfileId - } - })) + provider: "model" as const, + profileId: modelProfileId, + }, + })); vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - ...channelProjects - ]) + ...channelProjects, + ]); vi.mocked(api.projects.update).mockImplementation( async (projectToUpdate, input) => { const existing = channelProjects.find( - (candidate) => candidate.id === projectToUpdate - ) + (candidate) => candidate.id === projectToUpdate, + ); if (!existing) { return { ...project, ...input, - id: projectToUpdate - } + id: projectToUpdate, + }; } const updated = { ...existing, ...input, - updatedAt: '2026-08-14T00:00:00.000Z' - } + updatedAt: "2026-08-14T00:00:00.000Z", + }; channelProjects = channelProjects.map((candidate) => - candidate.id === updated.id ? updated : candidate - ) - return updated - } - ) + candidate.id === updated.id ? updated : candidate, + ); + return updated; + }, + ); api.channels = { getSnapshot: vi.fn(async () => ({ weixin: { enabled: false, bindingConfigured: false, - source: 'none' as const, - status: { state: 'disabled' as const } + source: "none" as const, + status: { state: "disabled" as const }, }, wecom: { enabled: false, - botId: '', + botId: "", secretConfigured: false, - source: 'none' as const, + source: "none" as const, readOnly: false, allowedSenderIds: [], allowGroupMessages: false, - status: { state: 'disabled' as const } + status: { state: "disabled" as const }, }, dingtalk: { enabled: false, - clientId: '', + clientId: "", secretConfigured: false, - source: 'none' as const, + source: "none" as const, readOnly: false, allowedSenderIds: [], allowGroupMessages: false, - status: { state: 'disabled' as const } - } + status: { state: "disabled" as const }, + }, })), apply: vi.fn(async () => { - throw new Error('No channel connection settings changed') + throw new Error("No channel connection settings changed"); }), testConnection: vi.fn(async (channel) => ({ channel, - ok: true + ok: true, })), getWeixinBinding: vi.fn(async () => ({ - status: 'stopped' as const + status: "stopped" as const, })), startWeixinBinding: vi.fn(async () => ({ - status: 'starting' as const + status: "starting" as const, })), submitWeixinVerification: vi.fn(async () => ({ - status: 'scanned' as const + status: "scanned" as const, })), disconnectWeixin: vi.fn(async () => ({ - status: 'stopped' as const + status: "stopped" as const, })), onWeixinBindingChanged: vi.fn(() => () => undefined), - onRemoteActivity: vi.fn(() => () => undefined) - } + onRemoteActivity: vi.fn(() => () => undefined), + }; - render() - const weixinProject = channelProjects[0]! - await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(weixinProject.name) - fireEvent.click( - await screen.findByRole('button', { name: '打开设置' }) - ) + render(); + const weixinProject = channelProjects[0]!; + await screen.findByRole("button", { name: "当前项目" }); + selectProjectOption(weixinProject.name); + fireEvent.click(await screen.findByRole("button", { name: "打开设置" })); + expect(await screen.findByLabelText("微信 ClawBot 项目说明")).toHaveValue( + "微信 ClawBot通道项目", + ); + + fireEvent.click(screen.getByLabelText("项目设置")); + let dialog = screen.getByRole("dialog", { name: "项目设置" }); + const dialogDescription = + within(dialog).getByLabelText("微信 ClawBot 项目说明"); + expect(dialogDescription).toHaveFocus(); + const dialogBackend = + within(dialog).getByLabelText("微信 ClawBot 消息处理后端"); expect( - await screen.findByLabelText('微信 ClawBot 项目说明') - ).toHaveValue('微信 ClawBot通道项目') - - fireEvent.click(screen.getByLabelText('项目设置')) - let dialog = screen.getByRole('dialog', { name: '项目设置' }) - const dialogDescription = within(dialog).getByLabelText( - '微信 ClawBot 项目说明' - ) - expect(dialogDescription).toHaveFocus() - const dialogBackend = within(dialog).getByLabelText( - '微信 ClawBot 消息处理后端' - ) - expect( - within(dialogBackend).getByRole('option', { - name: 'DeepSeek Harness(预览 · OpenAI 兼容)' - }) - ).toBeInTheDocument() + within(dialogBackend).getByRole("option", { + name: "DeepSeek Harness(预览 · OpenAI 兼容)", + }), + ).toBeInTheDocument(); + fireEvent.change(dialogDescription, { target: { value: "从左上角更新" } }); fireEvent.change( - dialogDescription, - { target: { value: '从左上角更新' } } - ) - fireEvent.change( - within(dialog).getByLabelText('微信 ClawBot 默认工作目录'), - { target: { value: 'C:\\FromSwitcher' } } - ) - fireEvent.change( - dialogBackend, - { - target: { - value: agentRuntimeSelectionKey({ - provider: 'opencode' - }) - } - } - ) + within(dialog).getByLabelText("微信 ClawBot 默认工作目录"), + { target: { value: "C:\\FromSwitcher" } }, + ); + fireEvent.change(dialogBackend, { + target: { + value: agentRuntimeSelectionKey({ + provider: "opencode", + }), + }, + }); fireEvent.click( within( - within(dialog).getByRole('group', { - name: '微信 ClawBot 默认模式' - }) - ).getByRole('button', { name: '执行' }) - ) - fireEvent.click( - within(dialog).getByRole('button', { name: '保存项目' }) - ) + within(dialog).getByRole("group", { + name: "微信 ClawBot 默认模式", + }), + ).getByRole("button", { name: "执行" }), + ); + fireEvent.click(within(dialog).getByRole("button", { name: "保存项目" })); await waitFor(() => { - expect( - screen.getByLabelText('微信 ClawBot 项目说明') - ).toHaveValue('从左上角更新') - expect( - screen.getByLabelText('微信 ClawBot 默认工作目录') - ).toHaveValue('C:\\FromSwitcher') - expect( - screen.getByLabelText('微信 ClawBot 消息处理后端') - ).toHaveValue( - agentRuntimeSelectionKey({ provider: 'opencode' }) - ) - }) + expect(screen.getByLabelText("微信 ClawBot 项目说明")).toHaveValue( + "从左上角更新", + ); + expect(screen.getByLabelText("微信 ClawBot 默认工作目录")).toHaveValue( + "C:\\FromSwitcher", + ); + expect(screen.getByLabelText("微信 ClawBot 消息处理后端")).toHaveValue( + agentRuntimeSelectionKey({ provider: "opencode" }), + ); + }); - fireEvent.change( - screen.getByLabelText('微信 ClawBot 项目说明'), - { target: { value: '从消息通道更新' } } - ) - fireEvent.change( - screen.getByLabelText('微信 ClawBot 默认工作目录'), - { target: { value: 'C:\\FromChannels' } } - ) - fireEvent.change( - screen.getByLabelText('微信 ClawBot 消息处理后端'), - { - target: { - value: agentRuntimeSelectionKey({ - provider: 'continue' - }) - } - } - ) + fireEvent.change(screen.getByLabelText("微信 ClawBot 项目说明"), { + target: { value: "从消息通道更新" }, + }); + fireEvent.change(screen.getByLabelText("微信 ClawBot 默认工作目录"), { + target: { value: "C:\\FromChannels" }, + }); + fireEvent.change(screen.getByLabelText("微信 ClawBot 消息处理后端"), { + target: { + value: agentRuntimeSelectionKey({ + provider: "continue", + }), + }, + }); fireEvent.click( within( - screen.getByRole('group', { - name: '微信 ClawBot 默认模式' - }) - ).getByRole('button', { name: '对话' }) - ) - fireEvent.click( - screen.getByRole('button', { name: '保存通道设置' }) - ) + screen.getByRole("group", { + name: "微信 ClawBot 默认模式", + }), + ).getByRole("button", { name: "对话" }), + ); + fireEvent.click(screen.getByRole("button", { name: "保存通道设置" })); await waitFor(() => expect(api.projects.update).toHaveBeenCalledWith( weixinProject.id, expect.objectContaining({ - description: '从消息通道更新', - rootPath: 'C:\\FromChannels', - defaultWorkMode: 'ask', - runtimeSelection: { provider: 'continue' } - }) - ) - ) + description: "从消息通道更新", + rootPath: "C:\\FromChannels", + defaultWorkMode: "ask", + runtimeSelection: { provider: "continue" }, + }), + ), + ); - fireEvent.click(screen.getByLabelText('项目设置')) - dialog = screen.getByRole('dialog', { name: '项目设置' }) + fireEvent.click(screen.getByLabelText("项目设置")); + dialog = screen.getByRole("dialog", { name: "项目设置" }); + expect(within(dialog).getByLabelText("微信 ClawBot 项目说明")).toHaveValue( + "从消息通道更新", + ); expect( - within(dialog).getByLabelText('微信 ClawBot 项目说明') - ).toHaveValue('从消息通道更新') + within(dialog).getByLabelText("微信 ClawBot 默认工作目录"), + ).toHaveValue("C:\\FromChannels"); expect( - within(dialog).getByLabelText('微信 ClawBot 默认工作目录') - ).toHaveValue('C:\\FromChannels') - expect( - within(dialog).getByLabelText('微信 ClawBot 消息处理后端') - ).toHaveValue( - agentRuntimeSelectionKey({ provider: 'continue' }) - ) + within(dialog).getByLabelText("微信 ClawBot 消息处理后端"), + ).toHaveValue(agentRuntimeSelectionKey({ provider: "continue" })); expect( within( - within(dialog).getByRole('group', { - name: '微信 ClawBot 默认模式' - }) - ).getByRole('button', { name: '对话' }) - ).toHaveAttribute('aria-pressed', 'true') - }) + within(dialog).getByRole("group", { + name: "微信 ClawBot 默认模式", + }), + ).getByRole("button", { name: "对话" }), + ).toHaveAttribute("aria-pressed", "true"); + }); - it('shows client-created remote conversations without obsolete approval copy', async () => { + it("shows client-created remote conversations without obsolete approval copy", async () => { const channelProject = { ...project, - id: '00000000-0000-4000-8000-000000000201', - name: '微信 ClawBot', - kind: 'channel' as const, - channel: 'weixin' as const, + id: "00000000-0000-4000-8000-000000000201", + name: "微信 ClawBot", + kind: "channel" as const, + channel: "weixin" as const, runtimeSelection: { - provider: 'model' as const, - profileId: modelProfileId - } - } + provider: "model" as const, + profileId: modelProfileId, + }, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - channelProject - ]) + channelProject, + ]); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000301', + id: "00000000-0000-4000-8000-000000000301", projectId: channelProject.id, runtimeSelection: channelProject.runtimeSelection, remote: { - channel: 'weixin', - accountDisplay: '发送者 ****0001', - conversationType: 'direct' + channel: "weixin", + accountDisplay: "发送者 ****0001", + conversationType: "direct", }, - title: '微信 ClawBot · ****0001', + title: "微信 ClawBot · ****0001", updatedAt: 1_775_000_000_000, - messages: [] - } - ]) - render() + messages: [], + }, + ]); + render(); - await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(channelProject.name) + await screen.findByRole("button", { name: "当前项目" }); + selectProjectOption(channelProject.name); expect( - screen.getAllByRole('button', { - name: /微信 ClawBot · \*{4}0001/u - }).length - ).toBeGreaterThan(0) + screen.getAllByRole("button", { + name: /微信 ClawBot · \*{4}0001/u, + }).length, + ).toBeGreaterThan(0); expect( screen.getByText( - '请在 微信 ClawBot 客户端继续发送消息。本窗口用于查看历史、任务与执行结果。' - ) - ).toBeInTheDocument() - expect(screen.queryByText(/审批执行/u)).not.toBeInTheDocument() - }) + "请在 微信 ClawBot 客户端继续发送消息。本窗口用于查看历史、任务与执行结果。", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/审批执行/u)).not.toBeInTheDocument(); + }); - it('falls back when the last active project is no longer available', async () => { + it("falls back when the last active project is no longer available", async () => { localStorage.setItem( - 'goodbuddy.active-project.v1', - '00000000-0000-4000-8000-000000000999' - ) + "goodbuddy.active-project.v1", + "00000000-0000-4000-8000-000000000999", + ); - render() + render(); expect( - await screen.findByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) + await screen.findByRole("button", { name: "当前项目" }), + ).toHaveTextContent(project.name); await waitFor(() => - expect( - localStorage.getItem('goodbuddy.active-project.v1') - ).toBe(project.id) - ) - }) + expect(localStorage.getItem("goodbuddy.active-project.v1")).toBe( + project.id, + ), + ); + }); it.each([ - ['opencode', 'OpenCode'], - ['continue', 'Continue CLI'] - ] as const)( - 'lets %s select Ask or Execute', - async (runtimeId, label) => { + ["opencode", "OpenCode"], + ["continue", "Continue CLI"], + ] as const)("lets %s select Ask or Execute", async (runtimeId, label) => { vi.mocked(api.agent.getStatus).mockResolvedValue({ id: runtimeId, label, available: true, supportsToolExecution: true, - detail: 'Ready' - }) - render() + detail: "Ready", + }); + render(); - const mode = await screen.findByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - expect(mode).toBeEnabled() - expect(mode.closest('.composer')).not.toBeNull() + const mode = await screen.findByRole("button", { + name: "工作模式:Ask · 只读问答", + }); + expect(mode).toBeEnabled(); + expect(mode.closest(".composer")).not.toBeNull(); expect( - await screen.findByText('快捷唤起:', { exact: false }) - ).toBeInTheDocument() - selectComposerOption('工作模式', 'Execute · 完全权限') - expect(mode).toHaveAccessibleName( - '工作模式:Execute · 完全权限' - ) - expect(mode).toHaveTextContent(/^Execute$/u) + await screen.findByText("快捷唤起:", { exact: false }), + ).toBeInTheDocument(); + selectComposerOption("工作模式", "Execute · 完全权限"); + expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限"); + expect(mode).toHaveTextContent(/^Execute$/u); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '执行任务' } - }) - fireEvent.click(await screen.findByLabelText('发送')) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "执行任务" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); await waitFor(() => expect(run).toHaveBeenCalledWith( expect.objectContaining({ - prompt: '执行任务', - workMode: 'execute' - }) - ) - ) - } - ) + prompt: "执行任务", + workMode: "execute", + }), + ), + ); + }); - it('submits native OpenCode Agent and Command controls', async () => { - const settings = await api.settings.getRuntime() + it("submits native OpenCode Agent and Command controls", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'opencode', + provider: "opencode", opencodeEmbedded: true, - opencodeModelSource: { kind: 'platform' } - }) + opencodeModelSource: { kind: "platform" }, + }); vi.mocked(api.agent.getStatus).mockResolvedValueOnce({ - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: true, supportsToolExecution: true, - detail: 'Ready' - }) - vi.mocked( - api.runtimeCustomization.getSettings - ).mockResolvedValueOnce({ + detail: "Ready", + }); + vi.mocked(api.runtimeCustomization.getSettings).mockResolvedValueOnce({ opencode: {}, - continue: { presets: [] } - }) - vi.mocked( - api.runtimeCustomization.getNativeSnapshot - ).mockResolvedValueOnce({ - provider: 'opencode', - available: true, - inventoryStatus: 'available', - detail: 'Ready', - agents: [ - { - id: 'planner', - name: 'Planner', - description: 'Plan before editing', - mode: 'primary', - native: true, - hidden: false - } - ], - tools: [], - toolsSupported: true, - commands: [ - { - id: 'review', - name: 'review', - description: 'Review a target', - source: 'command' - } - ], - lsp: [], - formatters: [], - mcpServers: [], - skills: [], - rules: [], - prompts: [], - resources: [], - resourcesSupported: true, - context: { - strategy: 'native', - manualCompact: true, - detail: 'OpenCode native context' - } - }) + continue: { presets: [] }, + }); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce( + { + provider: "opencode", + available: true, + inventoryStatus: "available", + detail: "Ready", + agents: [ + { + id: "planner", + name: "Planner", + description: "Plan before editing", + mode: "primary", + native: true, + hidden: false, + }, + ], + tools: [], + toolsSupported: true, + commands: [ + { + id: "review", + name: "review", + description: "Review a target", + source: "command", + }, + ], + lsp: [], + formatters: [], + mcpServers: [], + skills: [], + rules: [], + prompts: [], + resources: [], + resourcesSupported: true, + context: { + strategy: "native", + manualCompact: true, + detail: "OpenCode native context", + }, + }, + ); - render() + render(); - const agentPicker = await screen.findByRole('button', { - name: /OpenCode Runtime Agent/u - }) - const runtimeToolbar = screen.getByRole('group', { - name: 'OpenCode 专属功能' - }) - const universalSettings = screen.getByRole('group', { - name: '对话设置' - }) - expect(runtimeToolbar).toHaveClass('composer__runtime-toolbar') - expect(runtimeToolbar).toContainElement(agentPicker) - expect(universalSettings).not.toContainElement(agentPicker) - expect(universalSettings.closest('.composer__toolbar')).toHaveClass( - 'composer__toolbar--with-runtime-controls' - ) - fireEvent.click(agentPicker) + const agentPicker = await screen.findByRole("button", { + name: /OpenCode Runtime Agent/u, + }); + const runtimeToolbar = screen.getByRole("group", { + name: "OpenCode 专属功能", + }); + const universalSettings = screen.getByRole("group", { + name: "对话设置", + }); + expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar"); + expect(runtimeToolbar).toContainElement(agentPicker); + expect(universalSettings).not.toContainElement(agentPicker); + expect(universalSettings.closest(".composer__toolbar")).toHaveClass( + "composer__toolbar--with-runtime-controls", + ); + fireEvent.click(agentPicker); fireEvent.click( within( - screen.getByRole('menu', { - name: 'OpenCode Runtime Agent' - }) - ).getByRole('menuitemradio', { name: /Planner/u }) - ) - const actionPicker = screen.getByRole('button', { - name: /Runtime 快捷操作/u - }) - expect(runtimeToolbar).toContainElement(actionPicker) - fireEvent.click(actionPicker) + screen.getByRole("menu", { + name: "OpenCode Runtime Agent", + }), + ).getByRole("menuitemradio", { name: /Planner/u }), + ); + const actionPicker = screen.getByRole("button", { + name: /Runtime 快捷操作/u, + }); + expect(runtimeToolbar).toContainElement(actionPicker); + fireEvent.click(actionPicker); fireEvent.click( within( - screen.getByRole('menu', { - name: 'Runtime 快捷操作' - }) - ).getByRole('menuitemradio', { name: /\/review/u }) - ) - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: 'src/main' } - }) - fireEvent.click(screen.getByLabelText('发送')) + screen.getByRole("menu", { + name: "Runtime 快捷操作", + }), + ).getByRole("menuitemradio", { name: /\/review/u }), + ); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "src/main" }, + }); + fireEvent.click(screen.getByLabelText("发送")); await waitFor(() => expect(run).toHaveBeenCalledWith( expect.objectContaining({ - prompt: '/review src/main', + prompt: "/review src/main", runtimeControl: { - provider: 'opencode', - agent: 'planner', + provider: "opencode", + agent: "planner", command: { - name: 'review', - arguments: 'src/main' - } - } - }) - ) - ) - }) + name: "review", + arguments: "src/main", + }, + }, + }), + ), + ); + }); - it('keeps native OpenCode controls mounted across conversation refreshes', async () => { - const conversationId = '00000000-0000-4000-8000-000000000761' - const settings = await api.settings.getRuntime() + it.each(["rejected", "unavailable", "partial"] as const)("retries %s native OpenCode controls and keeps them mounted across conversation refreshes", async (failure) => { + const conversationId = "00000000-0000-4000-8000-000000000761"; + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'opencode', + provider: "opencode", opencodeEmbedded: true, - opencodeModelSource: { kind: 'platform' } - }) + opencodeModelSource: { kind: "platform" }, + }); vi.mocked(api.agent.getStatus).mockResolvedValue({ - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: true, supportsToolExecution: true, - detail: 'Ready' - }) - vi.mocked( - api.runtimeCustomization.getSettings - ).mockResolvedValueOnce({ + detail: "Ready", + }); + vi.mocked(api.runtimeCustomization.getSettings).mockResolvedValueOnce({ opencode: {}, - continue: { presets: [] } - }) - vi.mocked( - api.runtimeCustomization.getNativeSnapshot - ).mockResolvedValueOnce({ - provider: 'opencode', - available: true, - inventoryStatus: 'available', - detail: 'Ready', - agents: [ - { - id: 'planner', - name: 'Planner', - description: 'Plan before editing', - mode: 'primary', - native: true, - hidden: false - } - ], - tools: [], - toolsSupported: true, - commands: [], - lsp: [], - formatters: [], - mcpServers: [], - skills: [], - rules: [], - prompts: [], - resources: [], - resourcesSupported: true, - context: { - strategy: 'native', - manualCompact: true, - detail: 'OpenCode native context' - } - }) + continue: { presets: [] }, + }); + const emptySnapshot = await api.runtimeCustomization.getNativeSnapshot({ provider: "opencode" }); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockClear(); + if (failure === "rejected") { + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockRejectedValueOnce( + new Error("temporary inventory failure"), + ); + } else { + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce({ + ...emptySnapshot, + available: failure === "partial", + inventoryStatus: failure, + }); + } + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce({ + provider: "opencode", + available: true, + inventoryStatus: "available", + detail: "Ready", + agents: [ + { + id: "planner", + name: "Planner", + description: "Plan before editing", + mode: "primary", + native: true, + hidden: false, + }, + ], + tools: [], + toolsSupported: true, + commands: [], + lsp: [], + formatters: [], + mcpServers: [], + skills: [], + rules: [], + prompts: [], + resources: [], + resourcesSupported: true, + context: { + strategy: "native", + manualCompact: true, + detail: "OpenCode native context", + }, + }); // Every read returns freshly parsed rows, so the conversation and its // nested runtime selection change identity on each refresh even when the // selection itself is unchanged. The store keeps the persisted timestamp @@ -7877,933 +7409,1048 @@ describe('App', () => { { id: conversationId, projectId, - runtimeSelection: { provider: 'opencode' }, - title: 'OpenCode 会话', + runtimeSelection: { provider: "opencode" }, + title: "OpenCode 会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000762', - role: 'assistant', - content: '已就绪', + id: "00000000-0000-4000-8000-000000000762", + role: "assistant", + content: "已就绪", createdAt: 1_775_000_000_000, - state: 'complete' - } - ] - } - ]) - let conversationsChangedListener: (() => void) | undefined - vi.mocked(api.conversations.onChanged).mockImplementation( - (listener) => { - conversationsChangedListener = listener - return () => { - if (conversationsChangedListener === listener) { - conversationsChangedListener = undefined - } + state: "complete", + }, + ], + }, + ]); + let conversationsChangedListener: (() => void) | undefined; + vi.mocked(api.conversations.onChanged).mockImplementation((listener) => { + conversationsChangedListener = listener; + return () => { + if (conversationsChangedListener === listener) { + conversationsChangedListener = undefined; } - } - ) + }; + }); - render() + render(); - const runtimeToolbar = await screen.findByRole('group', { - name: 'OpenCode 专属功能' - }) - expect(await screen.findByText('已就绪')).toBeInTheDocument() + const runtimeToolbar = await screen.findByRole("group", { + name: "OpenCode 专属功能", + }); + expect(await screen.findByText("已就绪")).toBeInTheDocument(); await waitFor(() => - expect( - api.runtimeCustomization.getNativeSnapshot - ).toHaveBeenCalled() - ) - vi.mocked( - api.runtimeCustomization.getNativeSnapshot - ).mockClear() + expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes( + 2, + ), + ); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockClear(); // The resolved runtime scope is unchanged, so the runtime controls must // not unmount and refetch, which visibly shifted the conversation // layout on every window switch. - const refreshesBefore = vi.mocked(api.conversations.list).mock.calls - .length + const refreshesBefore = vi.mocked(api.conversations.list).mock.calls.length; act(() => { - conversationsChangedListener?.() - window.dispatchEvent(new Event('focus')) - }) + conversationsChangedListener?.(); + window.dispatchEvent(new Event("focus")); + }); await waitFor(() => expect( - vi.mocked(api.conversations.list).mock.calls.length - ).toBeGreaterThan(refreshesBefore) - ) + vi.mocked(api.conversations.list).mock.calls.length, + ).toBeGreaterThan(refreshesBefore), + ); // Let the merged conversation render before asserting that the runtime // scope stayed stable. await act(async () => { - await Promise.resolve() - }) + await Promise.resolve(); + }); + expect(screen.getByRole("group", { name: "OpenCode 专属功能" })).toBe( + runtimeToolbar, + ); expect( - screen.getByRole('group', { name: 'OpenCode 专属功能' }) - ).toBe(runtimeToolbar) - expect( - screen.getByRole('button', { name: /OpenCode Runtime Agent/u }) - ).toBeInTheDocument() - expect( - api.runtimeCustomization.getNativeSnapshot - ).not.toHaveBeenCalled() - }) + screen.getByRole("button", { name: /OpenCode Runtime Agent/u }), + ).toBeInTheDocument(); + expect(api.runtimeCustomization.getNativeSnapshot).not.toHaveBeenCalled(); + }); - it('fills editable Continue Prompts and submits the selected preset', async () => { - const presetId = '00000000-0000-4000-8000-000000000721' - const promptId = '00000000-0000-4000-8000-000000000722' - const settings = await api.settings.getRuntime() + it("shows native agents and commands after an unavailable inventory retries to available partial data", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'continue', - continueModelSource: { kind: 'platform' } - }) + provider: "opencode", + opencodeEmbedded: true, + opencodeModelSource: { kind: "platform" }, + }); + vi.mocked(api.agent.getStatus).mockResolvedValue({ + id: "opencode", label: "OpenCode", available: true, + supportsToolExecution: true, detail: "Ready", + }); + const empty = await api.runtimeCustomization.getNativeSnapshot({ provider: "opencode" }); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockClear(); + vi.mocked(api.runtimeCustomization.getNativeSnapshot) + .mockResolvedValueOnce({ + ...empty, + available: false, + inventoryStatus: "unavailable", + }) + .mockResolvedValueOnce({ + ...empty, + available: true, + inventoryStatus: "partial", + agents: [{ id: "planner", name: "Planner", mode: "primary", native: true, hidden: false }], + commands: [{ id: "review", name: "review", source: "command" }], + }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /OpenCode Runtime Agent/u })); + expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeVisible(); + fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" }); + fireEvent.click(screen.getByRole("button", { name: /Runtime 快捷操作/u })); + expect(screen.getByRole("menuitemradio", { name: /review/u })).toBeVisible(); + expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(2); + }); + + it.each(["unavailable", "partial"] as const)("retains cached native controls through %s project revalidation with a bounded retry", async (inventoryStatus) => { + const settings = await api.settings.getRuntime(); + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + provider: "opencode", + opencodeEmbedded: true, + opencodeModelSource: { kind: "platform" }, + }); + vi.mocked(api.agent.getStatus).mockResolvedValue({ + id: "opencode", label: "OpenCode", available: true, + supportsToolExecution: true, detail: "Ready", + }); + const firstProject = { ...project, runtimeSelection: { provider: "opencode" as const } }; + const secondProject = { ...firstProject, id: "00000000-0000-4000-8000-000000000179", name: "Inventory project B" }; + vi.mocked(api.projects.list).mockResolvedValueOnce([firstProject, secondProject]); + const empty = await api.runtimeCustomization.getNativeSnapshot({ provider: "opencode" }); + const healthy = { + ...empty, + agents: [{ id: "planner", name: "Planner", mode: "primary" as const, native: true, hidden: false }], + }; + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockClear(); + vi.mocked(api.runtimeCustomization.getNativeSnapshot) + .mockResolvedValueOnce(healthy) + .mockResolvedValueOnce(healthy); + render(); + await screen.findByRole("button", { name: /OpenCode Runtime Agent/u }); + selectProjectOption(secondProject.name); + await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenLastCalledWith( + expect.objectContaining({ projectId: secondProject.id }), + )); + const pending = deferred(); + const failed = { ...empty, available: inventoryStatus === "partial", inventoryStatus }; + vi.mocked(api.runtimeCustomization.getNativeSnapshot) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(failed); + const callsBefore = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length; + selectProjectOption(firstProject.name); + const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" }); + await act(async () => { + pending.resolve(failed); + await pending.promise; + }); + await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 2)); + expect(screen.getByRole("group", { name: "OpenCode 专属功能" })).toBe(toolbar); + fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })); + expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeInTheDocument(); + fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" }); + + // A successful empty inventory is authoritative, not a transient failure. + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(healthy); + selectProjectOption(secondProject.name); + await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 3)); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty); + selectProjectOption(firstProject.name); + await waitFor(() => expect(screen.queryByRole("group", { name: "OpenCode 专属功能" })).not.toBeInTheDocument()); + expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 4); + }); + + it("resets selected native Agent and Command when switching same-scope conversations", async () => { + const settings = await api.settings.getRuntime(); + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + provider: "opencode", + opencodeEmbedded: true, + opencodeModelSource: { kind: "platform" }, + }); + vi.mocked(api.agent.getStatus).mockResolvedValue({ + id: "opencode", label: "OpenCode", available: true, + supportsToolExecution: true, detail: "Ready", + }); + const snapshot = await api.runtimeCustomization.getNativeSnapshot({ provider: "opencode" }); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockClear(); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce({ + ...snapshot, + agents: [{ id: "planner", name: "Planner", mode: "primary", native: true, hidden: false }], + commands: [{ id: "review", name: "review", source: "command" }], + }); + const rows = [1, 2].map((index) => ({ + id: `00000000-0000-4000-8000-00000000077${index}`, + projectId, + runtimeSelection: { provider: "opencode" as const }, + title: `Native conversation ${index}`, + updatedAt: 1_775_000_000_000 - index, + messages: [], + })); + vi.mocked(api.conversations.list).mockResolvedValueOnce(rows); + render(); + + const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" }); + fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Planner/u })); + fireEvent.click(screen.getByRole("button", { name: /Runtime 快捷操作/u })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /\/review/u })); + const inventoryCalls = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length; + fireEvent.click(screen.getByText(rows[1]!.title).closest("button")!); + expect(screen.getByRole("group", { name: "OpenCode 专属功能" })).toBe(toolbar); + expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(inventoryCalls); + await waitFor(() => expect(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })).not.toHaveTextContent("Planner")); + expect(screen.getByRole("button", { name: /Runtime 快捷操作/u })).not.toHaveTextContent("/review"); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "plain request" } }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + expect(run.mock.calls[0]?.[0]).toMatchObject({ conversationId: rows[1]!.id, prompt: "plain request" }); + expect(run.mock.calls[0]?.[0].runtimeControl).toBeUndefined(); + }); + + it("fills editable Continue Prompts and submits the selected preset", async () => { + const presetId = "00000000-0000-4000-8000-000000000721"; + const promptId = "00000000-0000-4000-8000-000000000722"; + const settings = await api.settings.getRuntime(); + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + provider: "continue", + continueModelSource: { kind: "platform" }, + }); vi.mocked(api.agent.getStatus).mockResolvedValueOnce({ - id: 'continue', - label: 'Continue', + id: "continue", + label: "Continue", available: true, supportsToolExecution: true, - detail: 'Ready' - }) - vi.mocked( - api.runtimeCustomization.getSettings - ).mockResolvedValueOnce({ + detail: "Ready", + }); + vi.mocked(api.runtimeCustomization.getSettings).mockResolvedValueOnce({ opencode: {}, continue: { defaultPresetId: presetId, presets: [ { id: presetId, - name: '代码审查', + name: "代码审查", rules: [], prompts: [ { id: promptId, - name: '审查草稿', - description: '检查当前草稿', - prompt: '请审查当前草稿。' - } - ] - } - ] - } - }) - vi.mocked( - api.runtimeCustomization.getNativeSnapshot - ).mockResolvedValueOnce({ - provider: 'continue', - available: true, - inventoryStatus: 'available', - detail: 'Ready', - agents: [], - tools: [], - toolsSupported: false, - commands: [], - lsp: [], - formatters: [], - mcpServers: [], - skills: [], - rules: [], - prompts: [ - { - id: promptId, - name: '原生同 ID Prompt', - prompt: '不应填入此原生 Prompt。', - source: 'configuration' - } - ], - resources: [], - resourcesSupported: false, - context: { - strategy: 'goodbuddy-summary', - manualCompact: true, - detail: 'GoodBuddy summary context' - } - }) + name: "审查草稿", + description: "检查当前草稿", + prompt: "请审查当前草稿。", + }, + ], + }, + ], + }, + }); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce( + { + provider: "continue", + available: true, + inventoryStatus: "available", + detail: "Ready", + agents: [], + tools: [], + toolsSupported: false, + commands: [], + lsp: [], + formatters: [], + mcpServers: [], + skills: [], + rules: [], + prompts: [ + { + id: promptId, + name: "原生同 ID Prompt", + prompt: "不应填入此原生 Prompt。", + source: "configuration", + }, + ], + resources: [], + resourcesSupported: false, + context: { + strategy: "goodbuddy-summary", + manualCompact: true, + detail: "GoodBuddy summary context", + }, + }, + ); - render() + render(); - const presetPicker = await screen.findByRole('button', { - name: /Continue 配置预设.*使用设置默认预设/u - }) - const runtimeToolbar = screen.getByRole('group', { - name: 'Continue 专属功能' - }) - expect(runtimeToolbar).toHaveClass('composer__runtime-toolbar') - expect(runtimeToolbar).toContainElement(presetPicker) + const presetPicker = await screen.findByRole("button", { + name: /Continue 配置预设.*使用设置默认预设/u, + }); + const runtimeToolbar = screen.getByRole("group", { + name: "Continue 专属功能", + }); + expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar"); + expect(runtimeToolbar).toContainElement(presetPicker); expect( - screen.getByRole('group', { name: '对话设置' }) - ).not.toContainElement(presetPicker) - fireEvent.click(presetPicker) + screen.getByRole("group", { name: "对话设置" }), + ).not.toContainElement(presetPicker); + fireEvent.click(presetPicker); fireEvent.click( within( - screen.getByRole('menu', { - name: 'Continue 配置预设' - }) - ).getByRole('menuitemradio', { name: /代码审查/u }) - ) - const actionPicker = screen.getByRole('button', { - name: /Runtime 快捷操作/u - }) - expect(runtimeToolbar).toContainElement(actionPicker) - fireEvent.click(actionPicker) + screen.getByRole("menu", { + name: "Continue 配置预设", + }), + ).getByRole("menuitemradio", { name: /代码审查/u }), + ); + const actionPicker = screen.getByRole("button", { + name: /Runtime 快捷操作/u, + }); + expect(runtimeToolbar).toContainElement(actionPicker); + fireEvent.click(actionPicker); fireEvent.click( within( - screen.getByRole('menu', { - name: 'Runtime 快捷操作' - }) - ).getByRole('menuitemradio', { name: /审查草稿/u }) - ) - const composer = screen.getByLabelText('向 GoodBuddy 提问') - expect(composer).toHaveValue('请审查当前草稿。') + screen.getByRole("menu", { + name: "Runtime 快捷操作", + }), + ).getByRole("menuitemradio", { name: /审查草稿/u }), + ); + const composer = screen.getByLabelText("向 GoodBuddy 提问"); + expect(composer).toHaveValue("请审查当前草稿。"); fireEvent.change(composer, { - target: { value: '请审查当前草稿,并优先检查权限。' } - }) - fireEvent.click(screen.getByLabelText('发送')) + target: { value: "请审查当前草稿,并优先检查权限。" }, + }); + fireEvent.click(screen.getByLabelText("发送")); await waitFor(() => expect(run).toHaveBeenCalledWith( expect.objectContaining({ - prompt: '请审查当前草稿,并优先检查权限。', + prompt: "请审查当前草稿,并优先检查权限。", runtimeControl: { - provider: 'continue', - presetId - } - }) - ) - ) - }) + provider: "continue", + presetId, + }, + }), + ), + ); + }); - it('manually compacts Continue context and persists the summary state', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000731' + it("manually compacts Continue context and persists the summary state", async () => { + const conversationId = "00000000-0000-4000-8000-000000000731"; const messages = [ { - id: '00000000-0000-4000-8000-000000000732', - role: 'user' as const, - content: '第一轮问题', + id: "00000000-0000-4000-8000-000000000732", + role: "user" as const, + content: "第一轮问题", createdAt: 1_775_000_000_000, - state: 'complete' as const + state: "complete" as const, }, { - id: '00000000-0000-4000-8000-000000000733', - role: 'assistant' as const, - content: '第一轮回答', + id: "00000000-0000-4000-8000-000000000733", + role: "assistant" as const, + content: "第一轮回答", createdAt: 1_775_000_000_001, - state: 'complete' as const - } - ] + state: "complete" as const, + }, + ]; const summaryState = { - coveredHistoryDigest: 'a'.repeat(64), + coveredHistoryDigest: "a".repeat(64), coveredMessageCount: 1, coveredFromMessageId: messages[0]!.id, coveredThroughMessageId: messages[0]!.id, - summary: '用户提出了第一轮问题。' - } - const settings = await api.settings.getRuntime() + summary: "用户提出了第一轮问题。", + }; + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, contextCompression: { enabled: true, triggerTokens: 20_000, recentRawTokens: 4_000, - modelSource: { kind: 'current' }, - summaryPrompt: 'Preserve important facts.' - } - }) + modelSource: { kind: "current" }, + summaryPrompt: "Preserve important facts.", + }, + }); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: conversationId, projectId, - runtimeSelection: { provider: 'continue' }, - title: 'Continue 长对话', + runtimeSelection: { provider: "continue" }, + title: "Continue 长对话", updatedAt: 1_775_000_000_001, - messages - } - ]) + messages, + }, + ]); vi.mocked(api.agent.getStatus).mockResolvedValueOnce({ - id: 'continue', - label: 'Continue', + id: "continue", + label: "Continue", available: true, supportsToolExecution: true, - detail: 'Ready' - }) - vi.mocked( - api.runtimeCustomization.getNativeSnapshot - ).mockResolvedValueOnce({ - provider: 'continue', - available: true, - inventoryStatus: 'available', - detail: 'Ready', - agents: [], - tools: [], - toolsSupported: false, - commands: [], - lsp: [], - formatters: [], - mcpServers: [], - skills: [], - rules: [], - prompts: [], - resources: [], - resourcesSupported: false, - context: { - strategy: 'goodbuddy-summary', - manualCompact: true, - detail: 'GoodBuddy summary context' - } - }) + detail: "Ready", + }); + vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce( + { + provider: "continue", + available: true, + inventoryStatus: "available", + detail: "Ready", + agents: [], + tools: [], + toolsSupported: false, + commands: [], + lsp: [], + formatters: [], + mcpServers: [], + skills: [], + rules: [], + prompts: [], + resources: [], + resourcesSupported: false, + context: { + strategy: "goodbuddy-summary", + manualCompact: true, + detail: "GoodBuddy summary context", + }, + }, + ); vi.mocked(api.agent.compactConversation).mockResolvedValueOnce({ - provider: 'continue', - strategy: 'goodbuddy-summary', + provider: "continue", + strategy: "goodbuddy-summary", compacted: true, - detail: '已压缩 Continue 对话历史', - contextCompressionState: summaryState - }) + detail: "已压缩 Continue 对话历史", + contextCompressionState: summaryState, + }); - render() + render(); - const compactContext = await screen.findByRole('button', { - name: '压缩上下文' - }) + const compactContext = await screen.findByRole("button", { + name: "压缩上下文", + }); expect(compactContext.parentElement).toHaveClass( - 'composer-meta', - 'composer-meta--with-context-compact' - ) - expect( - compactContext.parentElement?.firstElementChild - ).toBe(compactContext) - fireEvent.click(compactContext) + "composer-meta", + "composer-meta--with-context-compact", + ); + expect(compactContext.parentElement?.firstElementChild).toBe( + compactContext, + ); + fireEvent.click(compactContext); await waitFor(() => expect(api.agent.compactConversation).toHaveBeenCalledWith({ requestId: expect.any(String), conversationId, projectId, - runtimeSelection: { provider: 'continue' }, + runtimeSelection: { provider: "continue" }, history: messages.map(({ role, content }) => ({ role, - content + content, })), historyMessageIds: messages.map((message) => message.id), - contextCompressionState: undefined - }) - ) + contextCompressionState: undefined, + }), + ); expect( - await screen.findByText('已压缩 Continue 对话历史') - ).toBeInTheDocument() - expect( - await screen.findByText(/压缩后对话估算/u) - ).not.toHaveTextContent('压缩线') + await screen.findByText("已压缩 Continue 对话历史"), + ).toBeInTheDocument(); + expect(await screen.findByText(/压缩后对话估算/u)).not.toHaveTextContent( + "压缩线", + ); await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith([ expect.objectContaining({ header: expect.objectContaining({ contextCompressionState: summaryState, contextMetrics: expect.objectContaining({ - basis: 'conversation', - source: 'estimated' - }) - }) - }) - ]) - ) - }) + basis: "conversation", + source: "estimated", + }), + }), + }), + ]), + ); + }); - it('restores the direct-model mode after leaving an Agent Runtime', async () => { - const settings = await api.settings.getRuntime() + it("restores the direct-model mode after leaving an Agent Runtime", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'opencode', + provider: "opencode", opencodeEmbedded: true, - opencodeModelSource: { kind: 'platform' } - }) + opencodeModelSource: { kind: "platform" }, + }); vi.mocked(api.agent.getStatus) .mockResolvedValueOnce({ - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: true, supportsToolExecution: true, - detail: 'Ready' + detail: "Ready", }) .mockResolvedValueOnce({ - id: 'model', - label: 'sonnet-5', + id: "model", + label: "sonnet-5", available: true, supportsToolExecution: false, - detail: 'Ready' - }) - render() + detail: "Ready", + }); + render(); - const mode = await screen.findByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - expect(mode).toBeEnabled() - selectComposerOption('工作模式', 'Execute · 完全权限') - expect(mode).toHaveAccessibleName( - '工作模式:Execute · 完全权限' - ) + const mode = await screen.findByRole("button", { + name: "工作模式:Ask · 只读问答", + }); + expect(mode).toBeEnabled(); + selectComposerOption("工作模式", "Execute · 完全权限"); + expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限"); - fireEvent.click(await screen.findByRole('button', { name: /OpenCode/u })) + fireEvent.click(await screen.findByRole("button", { name: /OpenCode/u })); fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^默认模型.*sonnet-5$/u - }) - ) + screen.getByRole("menuitemradio", { + name: /^默认模型.*sonnet-5$/u, + }), + ); await waitFor(() => { - expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答') - expect(mode).toBeEnabled() - }) - }) + expect(mode).toHaveAccessibleName("工作模式:Ask · 只读问答"); + expect(mode).toBeEnabled(); + }); + }); - it('disables Execute for a runtime without tool support', async () => { + it("disables Execute for a runtime without tool support", async () => { vi.mocked(api.agent.getStatus).mockResolvedValue({ - id: 'model', - label: 'legacy-model', + id: "model", + label: "legacy-model", available: true, supportsToolExecution: false, - detail: 'Ready' - }) - render() + detail: "Ready", + }); + render(); - const mode = await screen.findByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - const modeMenu = openComposerMenu('工作模式') + const mode = await screen.findByRole("button", { + name: "工作模式:Ask · 只读问答", + }); + const modeMenu = openComposerMenu("工作模式"); expect( - within(modeMenu).getByRole('menuitemradio', { - name: /^Execute · 完全权限/u - }) - ).toBeDisabled() - expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答') - }) + within(modeMenu).getByRole("menuitemradio", { + name: /^Execute · 完全权限/u, + }), + ).toBeDisabled(); + expect(mode).toHaveAccessibleName("工作模式:Ask · 只读问答"); + }); - it('allows a direct model to submit Execute with GoodBuddy approvals', async () => { + it("allows a direct model to submit Execute with GoodBuddy approvals", async () => { vi.mocked(api.agent.getStatus).mockResolvedValue({ - id: 'model', - label: 'sonnet-5', + id: "model", + label: "sonnet-5", available: true, supportsToolExecution: true, - detail: 'Ready' - }) - render() + detail: "Ready", + }); + render(); - const mode = await screen.findByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - selectComposerOption('工作模式', 'Execute · 完全权限') - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '读取项目文件' } - }) - fireEvent.click(await screen.findByLabelText('发送')) + const mode = await screen.findByRole("button", { + name: "工作模式:Ask · 只读问答", + }); + selectComposerOption("工作模式", "Execute · 完全权限"); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "读取项目文件" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); await waitFor(() => expect(run).toHaveBeenCalledWith( expect.objectContaining({ - prompt: '读取项目文件', - workMode: 'execute' - }) - ) - ) - expect(mode).toBeDisabled() - }) + prompt: "读取项目文件", + workMode: "execute", + }), + ), + ); + expect(mode).toBeDisabled(); + }); - it('terminalizes tools and activity when a request is cancelled', async () => { + it("terminalizes tools and activity when a request is cancelled", async () => { vi.mocked(api.agent.getStatus).mockResolvedValue({ - id: 'opencode', - label: 'OpenCode', + id: "opencode", + label: "OpenCode", available: true, supportsToolExecution: true, - detail: 'Ready' - }) - render() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '执行长任务' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + detail: "Ready", + }); + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "执行长任务" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'tool', - callId: 'call-1', - name: 'bash', - state: 'running', - summary: 'OpenCode 工具:bash' - }) + type: "tool", + callId: "call-1", + name: "bash", + state: "running", + summary: "OpenCode 工具:bash", + }); agentListener?.({ requestId: request.requestId, - type: 'error', - status: 'cancelled', - message: '请求已取消' - }) - }) + type: "error", + status: "cancelled", + message: "请求已取消", + }); + }); - expect(await screen.findByText('已取消')).toBeInTheDocument() + expect(await screen.findByText("已取消")).toBeInTheDocument(); const cancelledStatus = screen - .getAllByText('请求已取消') - .find((element) => element.classList.contains('message__status')) - expect(cancelledStatus).toBeDefined() - const cancelledDot = cancelledStatus?.querySelector( - '.message__status-dot' - ) - expect(cancelledDot).toHaveClass('message__status-dot') - expect(cancelledDot).not.toHaveClass('message__status-dot--active') - fireEvent.click(screen.getByText('运行记录')) - expect((await screen.findAllByText('已取消')).length).toBeGreaterThan(0) - fireEvent.click( - screen.getByRole('button', { name: /^进行中 \d+$/u }) - ) + .getAllByText("请求已取消") + .find((element) => element.classList.contains("message__status")); + expect(cancelledStatus).toBeDefined(); + const cancelledDot = cancelledStatus?.querySelector(".message__status-dot"); + expect(cancelledDot).toHaveClass("message__status-dot"); + expect(cancelledDot).not.toHaveClass("message__status-dot--active"); + fireEvent.click(screen.getByText("运行记录")); + expect((await screen.findAllByText("已取消")).length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole("button", { name: /^进行中 \d+$/u })); expect( - screen.getByText('当前没有等待中或正在运行的活动。') - ).toBeInTheDocument() - }) + screen.getByText("当前没有等待中或正在运行的活动。"), + ).toBeInTheDocument(); + }); - it('keeps persisted completed message statuses static', async () => { + it("keeps persisted completed message statuses static", async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000210', + id: "00000000-0000-4000-8000-000000000210", projectId, - title: '已完成会话', + title: "已完成会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000211', - role: 'assistant', - content: '任务结果', + id: "00000000-0000-4000-8000-000000000211", + role: "assistant", + content: "任务结果", createdAt: 1_775_000_000_000, - state: 'complete', - status: '任务已完成' - } - ] - } - ]) + state: "complete", + status: "任务已完成", + }, + ], + }, + ]); - render() + render(); - const completedStatus = await screen.findByText('任务已完成') + const completedStatus = await screen.findByText("任务已完成"); + expect(completedStatus.querySelector(".message__status-dot")).toHaveClass( + "message__status-dot", + ); expect( - completedStatus.querySelector('.message__status-dot') - ).toHaveClass('message__status-dot') - expect( - completedStatus.querySelector('.message__status-dot') - ).not.toHaveClass('message__status-dot--active') - }) + completedStatus.querySelector(".message__status-dot"), + ).not.toHaveClass("message__status-dot--active"); + }); - it('switches runtime profiles from the composer dropdown', async () => { - render() + it("switches runtime profiles from the composer dropdown", async () => { + render(); - const runtimeButton = await screen.findByRole('button', { - name: /sonnet-5/u - }) - fireEvent.click(runtimeButton) + const runtimeButton = await screen.findByRole("button", { + name: /sonnet-5/u, + }); + fireEvent.click(runtimeButton); expect( - await screen.findByRole('menu', { name: 'Runtime 和模型' }) - ).toBeInTheDocument() + await screen.findByRole("menu", { name: "Runtime 和模型" }), + ).toBeInTheDocument(); + expect(screen.queryByText("自动选择")).not.toBeInTheDocument(); expect( - screen.queryByText('自动选择') - ).not.toBeInTheDocument() + screen.getByRole("menuitemradio", { + name: /^默认模型.*sonnet-5$/u, + }), + ).toBeInTheDocument(); expect( - screen.getByRole('menuitemradio', { - name: /^默认模型.*sonnet-5$/u - }) - ).toBeInTheDocument() + screen.getByRole("menuitemradio", { + name: /^OpenCode · 默认模型.*sonnet-5$/u, + }), + ).toBeInTheDocument(); expect( - screen.getByRole('menuitemradio', { - name: /^OpenCode · 默认模型.*sonnet-5$/u - }) - ).toBeInTheDocument() + screen.getByRole("menuitemradio", { + name: /^Continue · 默认模型.*sonnet-5$/u, + }), + ).toBeInTheDocument(); expect( - screen.getByRole('menuitemradio', { - name: /^Continue · 默认模型.*sonnet-5$/u - }) - ).toBeInTheDocument() - expect( - screen.getByRole('menuitemradio', { - name: /^DeepSeek Harness · 自身配置/u - }) - ).toBeInTheDocument() + screen.getByRole("menuitemradio", { + name: /^DeepSeek Harness · 自身配置/u, + }), + ).toBeInTheDocument(); fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^默认模型.*sonnet-5$/u - }) - ) + screen.getByRole("menuitemradio", { + name: /^默认模型.*sonnet-5$/u, + }), + ); await waitFor(() => expect(api.agent.getStatus).toHaveBeenLastCalledWith({ - provider: 'model', - profileId: modelProfileId - }) - ) - expect(api.settings.updateRuntime).not.toHaveBeenCalled() + provider: "model", + profileId: modelProfileId, + }), + ); + expect(api.settings.updateRuntime).not.toHaveBeenCalled(); expect( - screen.queryByRole('heading', { name: '设置中心' }) - ).not.toBeInTheDocument() - }) + screen.queryByRole("heading", { name: "设置中心" }), + ).not.toBeInTheDocument(); + }); - it('shows Runtime switches globally without replacing composer guidance', async () => { - render() - const runtimeButton = await screen.findByRole('button', { - name: /sonnet-5/u - }) - vi.useFakeTimers() + it("shows Runtime switches globally without replacing composer guidance", async () => { + render(); + const runtimeButton = await screen.findByRole("button", { + name: /sonnet-5/u, + }); + vi.useFakeTimers(); try { - fireEvent.click(runtimeButton) + fireEvent.click(runtimeButton); fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^OpenCode · 默认模型.*sonnet-5$/u - }) - ) + screen.getByRole("menuitemradio", { + name: /^OpenCode · 默认模型.*sonnet-5$/u, + }), + ); await act(async () => { - await Promise.resolve() - }) + await Promise.resolve(); + }); - const notification = screen.getByRole('status') + const notification = screen.getByRole("status"); expect(notification).toHaveTextContent( - '当前对话已切换到 OpenCode · 默认模型' - ) + "当前对话已切换到 OpenCode · 默认模型", + ); expect( - screen.getByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - ).toBeInTheDocument() + screen.getByRole("button", { + name: "工作模式:Ask · 只读问答", + }), + ).toBeInTheDocument(); fireEvent.click( - screen.getByRole('button', { - name: /OpenCode · 默认模型/u - }) - ) + screen.getByRole("button", { + name: /OpenCode · 默认模型/u, + }), + ); fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^Continue · 默认模型.*sonnet-5$/u - }) - ) + screen.getByRole("menuitemradio", { + name: /^Continue · 默认模型.*sonnet-5$/u, + }), + ); await act(async () => { - await Promise.resolve() - }) - expect(screen.getAllByRole('status')).toHaveLength(1) - expect(screen.getByRole('status')).toHaveTextContent( - '当前对话已切换到 Continue · 默认模型' - ) + await Promise.resolve(); + }); + expect(screen.getAllByRole("status")).toHaveLength(1); + expect(screen.getByRole("status")).toHaveTextContent( + "当前对话已切换到 Continue · 默认模型", + ); - act(() => vi.advanceTimersByTime(4_500)) + act(() => vi.advanceTimersByTime(4_500)); expect( - screen.queryByText( - '当前对话已切换到 Continue · 默认模型' - ) - ).not.toBeInTheDocument() + screen.queryByText("当前对话已切换到 Continue · 默认模型"), + ).not.toBeInTheDocument(); expect( - screen.getByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - ).toBeInTheDocument() + screen.getByRole("button", { + name: "工作模式:Ask · 只读问答", + }), + ).toBeInTheDocument(); } finally { - vi.useRealTimers() + vi.useRealTimers(); } - }) + }); - it('shows one configured choice per Agent Runtime in a flat keyboard menu', async () => { - const settings = await api.settings.getRuntime() - const secondProfileId = - '00000000-0000-4000-8000-000000000002' + it("shows one configured choice per Agent Runtime in a flat keyboard menu", async () => { + const settings = await api.settings.getRuntime(); + const secondProfileId = "00000000-0000-4000-8000-000000000002"; vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'model', + provider: "model", modelProfiles: [ ...settings.modelProfiles, { id: secondProfileId, - name: '第二模型', - baseUrl: 'http://127.0.0.1:11434/v1', - modelName: 'qwen3', - protocol: 'openai-chat-completions', - authentication: 'none', - imageGenerationQuality: 'auto', + name: "第二模型", + baseUrl: "http://127.0.0.1:11434/v1", + modelName: "qwen3", + protocol: "openai-chat-completions", + authentication: "none", + imageGenerationQuality: "auto", apiKeyConfigured: false, - credentialSource: 'none' - } - ] - }) - render() + credentialSource: "none", + }, + ], + }); + render(); - const runtimeButton = await screen.findByRole('button', { - name: /sonnet-5/u - }) - fireEvent.click(runtimeButton) - const runtimeMenu = screen.getByRole('menu', { - name: 'Runtime 和模型' - }) - const directModel = screen.getByRole('menuitemradio', { - name: /^默认模型.*sonnet-5$/u - }) - const secondDirectModel = screen.getByRole('menuitemradio', { - name: /^第二模型.*qwen3$/u - }) - const openCodeModel = screen.getByRole('menuitemradio', { - name: /^OpenCode · 默认模型.*sonnet-5$/u - }) - const continueModel = screen.getByRole('menuitemradio', { - name: /^Continue · 默认模型.*sonnet-5$/u - }) - const deepseekHarness = screen.getByRole('menuitemradio', { - name: /^DeepSeek Harness · 自身配置/u - }) - expect(directModel).toBeEnabled() - expect(secondDirectModel).toBeEnabled() - expect(openCodeModel).toBeEnabled() - expect(continueModel).toBeEnabled() - expect(deepseekHarness).toBeEnabled() - expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) - expect(within(runtimeMenu).getAllByRole('separator')).toHaveLength(4) - expect(within(runtimeMenu).queryByRole('menu')).not.toBeInTheDocument() + const runtimeButton = await screen.findByRole("button", { + name: /sonnet-5/u, + }); + fireEvent.click(runtimeButton); + const runtimeMenu = screen.getByRole("menu", { + name: "Runtime 和模型", + }); + const directModel = screen.getByRole("menuitemradio", { + name: /^默认模型.*sonnet-5$/u, + }); + const secondDirectModel = screen.getByRole("menuitemradio", { + name: /^第二模型.*qwen3$/u, + }); + const openCodeModel = screen.getByRole("menuitemradio", { + name: /^OpenCode · 默认模型.*sonnet-5$/u, + }); + const continueModel = screen.getByRole("menuitemradio", { + name: /^Continue · 默认模型.*sonnet-5$/u, + }); + const deepseekHarness = screen.getByRole("menuitemradio", { + name: /^DeepSeek Harness · 自身配置/u, + }); + expect(directModel).toBeEnabled(); + expect(secondDirectModel).toBeEnabled(); + expect(openCodeModel).toBeEnabled(); + expect(continueModel).toBeEnabled(); + expect(deepseekHarness).toBeEnabled(); + expect(screen.getAllByRole("menuitemradio")).toHaveLength(5); + expect(within(runtimeMenu).getAllByRole("separator")).toHaveLength(4); + expect(within(runtimeMenu).queryByRole("menu")).not.toBeInTheDocument(); expect( - screen.queryByRole('menuitemradio', { - name: /^OpenCode · 第二模型/u - }) - ).not.toBeInTheDocument() + screen.queryByRole("menuitemradio", { + name: /^OpenCode · 第二模型/u, + }), + ).not.toBeInTheDocument(); expect( - screen.queryByRole('menuitemradio', { - name: /^Continue · 第二模型/u - }) - ).not.toBeInTheDocument() + screen.queryByRole("menuitemradio", { + name: /^Continue · 第二模型/u, + }), + ).not.toBeInTheDocument(); expect( - screen.queryByRole('menuitem', { name: /Agent Runtime/u }) - ).not.toBeInTheDocument() + screen.queryByRole("menuitem", { name: /Agent Runtime/u }), + ).not.toBeInTheDocument(); - await waitFor(() => expect(directModel).toHaveFocus()) - expect(directModel).toHaveAttribute('tabindex', '0') - expect(secondDirectModel).toHaveAttribute('tabindex', '-1') - fireEvent.keyDown(directModel, { key: 'ArrowDown' }) - expect(secondDirectModel).toHaveFocus() - expect(directModel).toHaveAttribute('tabindex', '-1') - expect(secondDirectModel).toHaveAttribute('tabindex', '0') - fireEvent.keyDown(secondDirectModel, { key: 'ArrowDown' }) - expect(openCodeModel).toHaveFocus() - fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) - expect(runtimeButton).toHaveFocus() + await waitFor(() => expect(directModel).toHaveFocus()); + expect(directModel).toHaveAttribute("tabindex", "0"); + expect(secondDirectModel).toHaveAttribute("tabindex", "-1"); + fireEvent.keyDown(directModel, { key: "ArrowDown" }); + expect(secondDirectModel).toHaveFocus(); + expect(directModel).toHaveAttribute("tabindex", "-1"); + expect(secondDirectModel).toHaveAttribute("tabindex", "0"); + fireEvent.keyDown(secondDirectModel, { key: "ArrowDown" }); + expect(openCodeModel).toHaveFocus(); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + expect(runtimeButton).toHaveFocus(); expect( - screen.queryByRole('menu', { name: 'Runtime 和模型' }) - ).not.toBeInTheDocument() + screen.queryByRole("menu", { name: "Runtime 和模型" }), + ).not.toBeInTheDocument(); - fireEvent.click(runtimeButton) + fireEvent.click(runtimeButton); fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^OpenCode · 默认模型.*sonnet-5$/u - }) - ) - expect(runtimeButton).toHaveFocus() + screen.getByRole("menuitemradio", { + name: /^OpenCode · 默认模型.*sonnet-5$/u, + }), + ); + expect(runtimeButton).toHaveFocus(); await waitFor(() => expect(api.agent.getStatus).toHaveBeenLastCalledWith({ - provider: 'opencode', - profileId: modelProfileId - }) - ) + provider: "opencode", + profileId: modelProfileId, + }), + ); - const selectedRuntimeButton = await screen.findByRole('button', { - name: /OpenCode · 默认模型/u - }) - fireEvent.click(selectedRuntimeButton) - const selectedOpenCodeModel = screen.getByRole('menuitemradio', { - name: /^OpenCode · 默认模型.*sonnet-5$/u - }) - await waitFor(() => expect(selectedOpenCodeModel).toHaveFocus()) - expect(selectedOpenCodeModel).toHaveAttribute('aria-checked', 'true') - expect(selectedOpenCodeModel).toHaveAttribute('tabindex', '0') + const selectedRuntimeButton = await screen.findByRole("button", { + name: /OpenCode · 默认模型/u, + }); + fireEvent.click(selectedRuntimeButton); + const selectedOpenCodeModel = screen.getByRole("menuitemradio", { + name: /^OpenCode · 默认模型.*sonnet-5$/u, + }); + await waitFor(() => expect(selectedOpenCodeModel).toHaveFocus()); + expect(selectedOpenCodeModel).toHaveAttribute("aria-checked", "true"); + expect(selectedOpenCodeModel).toHaveAttribute("tabindex", "0"); fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^Continue · 默认模型.*sonnet-5$/u - }) - ) + screen.getByRole("menuitemradio", { + name: /^Continue · 默认模型.*sonnet-5$/u, + }), + ); await waitFor(() => expect(api.agent.getStatus).toHaveBeenLastCalledWith({ - provider: 'continue', - profileId: modelProfileId - }) - ) - }) + provider: "continue", + profileId: modelProfileId, + }), + ); + }); - it('dismisses the Runtime menu on outside pointer and focus changes', async () => { - render() + it("dismisses the Runtime menu on outside pointer and focus changes", async () => { + render(); - const runtimeButton = await screen.findByRole('button', { - name: /sonnet-5/u - }) - const composer = screen.getByLabelText('向 GoodBuddy 提问') + const runtimeButton = await screen.findByRole("button", { + name: /sonnet-5/u, + }); + const composer = screen.getByLabelText("向 GoodBuddy 提问"); - fireEvent.click(runtimeButton) + fireEvent.click(runtimeButton); expect( - screen.getByRole('menu', { name: 'Runtime 和模型' }) - ).toBeInTheDocument() - fireEvent.pointerDown(composer) + screen.getByRole("menu", { name: "Runtime 和模型" }), + ).toBeInTheDocument(); + fireEvent.pointerDown(composer); expect( - screen.queryByRole('menu', { name: 'Runtime 和模型' }) - ).not.toBeInTheDocument() + screen.queryByRole("menu", { name: "Runtime 和模型" }), + ).not.toBeInTheDocument(); - fireEvent.click(runtimeButton) - const selectedModel = screen.getByRole('menuitemradio', { - name: /^默认模型.*sonnet-5$/u - }) - await waitFor(() => expect(selectedModel).toHaveFocus()) - fireEvent.keyDown(selectedModel, { key: 'Tab' }) - composer.focus() - expect(composer).toHaveFocus() + fireEvent.click(runtimeButton); + const selectedModel = screen.getByRole("menuitemradio", { + name: /^默认模型.*sonnet-5$/u, + }); + await waitFor(() => expect(selectedModel).toHaveFocus()); + fireEvent.keyDown(selectedModel, { key: "Tab" }); + composer.focus(); + expect(composer).toHaveFocus(); await waitFor(() => expect( - screen.queryByRole('menu', { name: 'Runtime 和模型' }) - ).not.toBeInTheDocument() - ) - }) + screen.queryByRole("menu", { name: "Runtime 和模型" }), + ).not.toBeInTheDocument(), + ); + }); - it('labels explicitly configured Runtime-owned model sources', async () => { - const settings = await api.settings.getRuntime() + it("labels explicitly configured Runtime-owned model sources", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'model', - opencodeModelSource: { kind: 'platform' }, - continueModelSource: { kind: 'platform' } - }) - render() + provider: "model", + opencodeModelSource: { kind: "platform" }, + continueModelSource: { kind: "platform" }, + }); + render(); - fireEvent.click( - await screen.findByRole('button', { name: /sonnet-5/u }) - ) + fireEvent.click(await screen.findByRole("button", { name: /sonnet-5/u })); expect( - screen.getByRole('menuitemradio', { - name: /^OpenCode · 自身配置.*使用 OpenCode 自身配置$/u - }) - ).toBeInTheDocument() - const continueChoice = screen.getByRole('menuitemradio', { - name: /^Continue · 自身配置.*使用 Continue 自身配置$/u - }) - fireEvent.click(continueChoice) + screen.getByRole("menuitemradio", { + name: /^OpenCode · 自身配置.*使用 OpenCode 自身配置$/u, + }), + ).toBeInTheDocument(); + const continueChoice = screen.getByRole("menuitemradio", { + name: /^Continue · 自身配置.*使用 Continue 自身配置$/u, + }); + fireEvent.click(continueChoice); await waitFor(() => expect(api.agent.getStatus).toHaveBeenLastCalledWith({ - provider: 'continue' - }) - ) - }) + provider: "continue", + }), + ); + }); - it('persists metadata-only retrieval and Runtime changes without rewriting messages', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000451' - const existingMessageId = - '00000000-0000-4000-8000-000000000452' - const libraryId = '11111111-1111-4111-8111-111111111111' - const secondProfileId = - '00000000-0000-4000-8000-000000000453' - const settings = await api.settings.getRuntime() + it("persists metadata-only retrieval and Runtime changes without rewriting messages", async () => { + const conversationId = "00000000-0000-4000-8000-000000000451"; + const existingMessageId = "00000000-0000-4000-8000-000000000452"; + const libraryId = "11111111-1111-4111-8111-111111111111"; + const secondProfileId = "00000000-0000-4000-8000-000000000453"; + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, modelProfiles: [ ...settings.modelProfiles, { id: secondProfileId, - name: '仅元数据模型', - baseUrl: 'http://127.0.0.1:11434/v1', - modelName: 'qwen3', - protocol: 'openai-chat-completions', - authentication: 'none', - imageGenerationQuality: 'auto', + name: "仅元数据模型", + baseUrl: "http://127.0.0.1:11434/v1", + modelName: "qwen3", + protocol: "openai-chat-completions", + authentication: "none", + imageGenerationQuality: "auto", apiKeyConfigured: false, - credentialSource: 'none' - } - ] - }) + credentialSource: "none", + }, + ], + }); vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({ libraries: [ { id: libraryId, - name: '产品知识', - description: '', - storageMode: 'managed', + name: "产品知识", + description: "", + storageMode: "managed", graphEnabled: false, - graphStrategy: 'rules', + graphStrategy: "rules", sourceCount: 1, documentCount: 1, - indexedDocumentCount: 1 - } + indexedDocumentCount: 1, + }, ], sources: [], documents: [], graphNodes: [], graphRelations: [], - evidence: [] - }) + evidence: [], + }); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: conversationId, projectId, runtimeSelection: { - provider: 'model', - profileId: modelProfileId + provider: "model", + profileId: modelProfileId, }, knowledgeLibraryIds: [libraryId], - knowledgeRetrievalMode: 'auto', - title: '元数据会话', + knowledgeRetrievalMode: "auto", + title: "元数据会话", updatedAt: 1_775_000_000_000, messages: [ { id: existingMessageId, - role: 'assistant', - content: '现有消息不应重写', + role: "assistant", + content: "现有消息不应重写", createdAt: 1_775_000_000_000, - state: 'complete' - } - ] - } - ]) - vi.mocked(api.agent.getStatus).mockImplementation( - async (selection) => ({ - id: 'model', - label: - selection?.provider === 'model' && - selection.profileId === secondProfileId - ? 'qwen3' - : 'sonnet-5', - available: true, - supportsToolExecution: true, - detail: 'Ready' - }) - ) - render() + state: "complete", + }, + ], + }, + ]); + vi.mocked(api.agent.getStatus).mockImplementation(async (selection) => ({ + id: "model", + label: + selection?.provider === "model" && + selection.profileId === secondProfileId + ? "qwen3" + : "sonnet-5", + available: true, + supportsToolExecution: true, + detail: "Ready", + })); + render(); - const knowledgeScope = await screen.findByRole('button', { - name: '选择知识库,本次已启用 1 个' - }) - fireEvent.click(knowledgeScope) + const knowledgeScope = await screen.findByRole("button", { + name: "选择知识库,本次已启用 1 个", + }); + fireEvent.click(knowledgeScope); fireEvent.click( - within( - screen.getByRole('group', { name: '知识检索方式' }) - ).getByRole('button', { name: '每次先检索' }) - ) + within(screen.getByRole("group", { name: "知识检索方式" })).getByRole( + "button", + { name: "每次先检索" }, + ), + ); await waitFor( () => expect(api.conversations.saveLocal).toHaveBeenCalledWith([ { header: expect.objectContaining({ id: conversationId, - knowledgeRetrievalMode: 'always' + knowledgeRetrievalMode: "always", }), - messages: [] - } + messages: [], + }, ]), - { timeout: 2_000 } - ) + { timeout: 2_000 }, + ); - vi.mocked(api.conversations.saveLocal).mockClear() + vi.mocked(api.conversations.saveLocal).mockClear(); + fireEvent.click(screen.getByRole("button", { name: /sonnet-5/u })); fireEvent.click( - screen.getByRole('button', { name: /sonnet-5/u }) - ) - fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^仅元数据模型.*qwen3$/u - }) - ) + screen.getByRole("menuitemradio", { + name: /^仅元数据模型.*qwen3$/u, + }), + ); await waitFor( () => expect(api.conversations.saveLocal).toHaveBeenCalledWith([ @@ -8811,364 +8458,347 @@ describe('App', () => { header: expect.objectContaining({ id: conversationId, runtimeSelection: { - provider: 'model', - profileId: secondProfileId + provider: "model", + profileId: secondProfileId, }, - knowledgeRetrievalMode: 'always' + knowledgeRetrievalMode: "always", }), - messages: [] - } + messages: [], + }, ]), - { timeout: 2_000 } - ) + { timeout: 2_000 }, + ); const savedMessages = vi .mocked(api.conversations.saveLocal) .mock.calls.flatMap(([batch]) => - batch.flatMap((entry) => entry.messages) - ) + batch.flatMap((entry) => entry.messages), + ); expect( - savedMessages.some((message) => message.id === existingMessageId) - ).toBe(false) - }) + savedMessages.some((message) => message.id === existingMessageId), + ).toBe(false); + }); - it('migrates legacy startup conversations with replace when SQLite has no local conversation', async () => { - const settings = await api.settings.getRuntime() + it("migrates legacy startup conversations with replace when SQLite has no local conversation", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, - contextWindowTokens: undefined + contextWindowTokens: undefined, })), contextCompression: { enabled: true, triggerTokens: 12_000, recentRawTokens: 4_000, - modelSource: { kind: 'current' }, - summaryPrompt: 'Preserve important facts.' - } - }) + modelSource: { kind: "current" }, + summaryPrompt: "Preserve important facts.", + }, + }); const normalizedContextMetrics = { runtimeSelectionKey: `model:${settings.defaultModelProfileId}`, contextTokens: 9_000, - source: 'provider' as const, - basis: 'model-call' as const - } + source: "provider" as const, + basis: "model-call" as const, + }; const legacyConversation = { - id: '00000000-0000-4000-8000-000000000461', + id: "00000000-0000-4000-8000-000000000461", runtimeSelection: { - provider: 'model' as const, - profileId: settings.defaultModelProfileId + provider: "model" as const, + profileId: settings.defaultModelProfileId, }, contextMetrics: { ...normalizedContextMetrics, effectiveTriggerTokens: 20_000, contextWindowTokens: 32_000, - compressionEnabled: true + compressionEnabled: true, }, - title: '待迁移旧会话', + title: "待迁移旧会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000462', - role: 'assistant' as const, - content: '旧版浏览器存储消息', + id: "00000000-0000-4000-8000-000000000462", + role: "assistant" as const, + content: "旧版浏览器存储消息", createdAt: 1_775_000_000_000, - state: 'complete' as const - } - ] - } + state: "complete" as const, + }, + ], + }; localStorage.setItem( - 'goodbuddy.conversations.v1', - JSON.stringify([legacyConversation]) - ) - render() + "goodbuddy.conversations.v1", + JSON.stringify([legacyConversation]), + ); + render(); + expect(await screen.findByText("旧版浏览器存储消息")).toBeInTheDocument(); expect( - await screen.findByText('旧版浏览器存储消息') - ).toBeInTheDocument() - expect( - screen.getByText('本次调用 9.0K · 压缩线 12.0K') - ).toBeInTheDocument() + screen.getByText("本次调用 9.0K · 压缩线 12.0K"), + ).toBeInTheDocument(); await waitFor(() => expect(api.conversations.replace).toHaveBeenCalledWith([ expect.objectContaining({ ...legacyConversation, contextMetrics: normalizedContextMetrics, - projectId - }) - ]) - ) - expect(api.conversations.saveLocal).not.toHaveBeenCalled() - }) + projectId, + }), + ]), + ); + expect(api.conversations.saveLocal).not.toHaveBeenCalled(); + }); - it('does not let remote rows displace legacy local conversations during migration', async () => { + it("does not let remote rows displace legacy local conversations during migration", async () => { const legacyConversations = [0, 1].map((index) => ({ - id: `00000000-0000-4000-8000-${String(470 + index).padStart(12, '0')}`, + id: `00000000-0000-4000-8000-${String(470 + index).padStart(12, "0")}`, title: `待迁移本地会话 ${index}`, updatedAt: 1_775_000_000_000 + index, messages: [ { - id: `00000000-0000-4000-8001-${String(470 + index).padStart(12, '0')}`, - role: 'assistant' as const, + id: `00000000-0000-4000-8001-${String(470 + index).padStart(12, "0")}`, + role: "assistant" as const, content: `待迁移消息 ${index}`, createdAt: 1_775_000_000_000 + index, - state: 'complete' as const - } - ] - })) + state: "complete" as const, + }, + ], + })); localStorage.setItem( - 'goodbuddy.conversations.v1', - JSON.stringify(legacyConversations) - ) - const remoteProjectId = - '00000000-0000-4000-8000-000000000499' + "goodbuddy.conversations.v1", + JSON.stringify(legacyConversations), + ); + const remoteProjectId = "00000000-0000-4000-8000-000000000499"; vi.mocked(api.conversations.list).mockResolvedValueOnce( Array.from({ length: 100 }, (_, index) => ({ - id: `00000000-0000-4000-8002-${String(index).padStart(12, '0')}`, + id: `00000000-0000-4000-8002-${String(index).padStart(12, "0")}`, projectId: remoteProjectId, remote: { - channel: 'weixin' as const, + channel: "weixin" as const, accountDisplay: `远程联系人 ${index}`, - conversationType: 'direct' as const + conversationType: "direct" as const, }, title: `远程会话 ${index}`, updatedAt: 1_775_000_100_000 + index, - messages: [] - })) - ) - render() + messages: [], + })), + ); + render(); - await waitFor(() => - expect(api.conversations.saveLocal).toHaveBeenCalled() - ) - expect(api.conversations.replace).not.toHaveBeenCalled() + await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalled()); + expect(api.conversations.replace).not.toHaveBeenCalled(); const migrated = - vi.mocked(api.conversations.saveLocal).mock.calls[0]?.[0] ?? [] + vi.mocked(api.conversations.saveLocal).mock.calls[0]?.[0] ?? []; expect(migrated.map((conversation) => conversation.header.id)).toEqual( expect.arrayContaining( - legacyConversations.map((conversation) => conversation.id) - ) - ) - }) + legacyConversations.map((conversation) => conversation.id), + ), + ); + }); - it('preserves a legacy Auto conversation without silently persisting a replacement', async () => { - const settings = await api.settings.getRuntime() + it("preserves a legacy Auto conversation without silently persisting a replacement", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - provider: 'auto', - opencodeBaseUrl: '', + provider: "auto", + opencodeBaseUrl: "", opencodeEmbedded: false, modelProfiles: settings.modelProfiles.map((profile) => ({ ...profile, - contextWindowTokens: 32_000 + contextWindowTokens: 32_000, })), contextCompression: { enabled: true, triggerTokens: 20_000, recentRawTokens: 4_000, - modelSource: { kind: 'current' }, - summaryPrompt: 'Preserve important facts.' - } - }) + modelSource: { kind: "current" }, + summaryPrompt: "Preserve important facts.", + }, + }); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000020', + id: "00000000-0000-4000-8000-000000000020", projectId, - runtimeSelection: { provider: 'auto' }, + runtimeSelection: { provider: "auto" }, contextMetrics: { - runtimeSelectionKey: 'auto:default', + runtimeSelectionKey: "auto:default", contextTokens: 9_000, - source: 'provider', - basis: 'model-call' + source: "provider", + basis: "model-call", }, - title: '旧自动对话', + title: "旧自动对话", updatedAt: 1, messages: [ { - id: '00000000-0000-4000-8000-000000000021', - role: 'assistant', - content: '旧消息', + id: "00000000-0000-4000-8000-000000000021", + role: "assistant", + content: "旧消息", createdAt: 1, - state: 'complete' - } - ] - } - ]) - render() + state: "complete", + }, + ], + }, + ]); + render(); expect( - await screen.findByRole('button', { name: /自动.*sonnet-5/u }) - ).toBeInTheDocument() - expect( - screen.queryByText(/本次调用 9\.0K/u) - ).not.toBeInTheDocument() - expect(screen.queryByText(/压缩线/u)).not.toBeInTheDocument() - expect(api.conversations.replace).not.toHaveBeenCalled() - expect(api.conversations.saveLocal).not.toHaveBeenCalled() + await screen.findByRole("button", { name: /自动.*sonnet-5/u }), + ).toBeInTheDocument(); + expect(screen.queryByText(/本次调用 9\.0K/u)).not.toBeInTheDocument(); + expect(screen.queryByText(/压缩线/u)).not.toBeInTheDocument(); + expect(api.conversations.replace).not.toHaveBeenCalled(); + expect(api.conversations.saveLocal).not.toHaveBeenCalled(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '刷新自动 Runtime 用量' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "刷新自动 Runtime 用量" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'context-metrics', + type: "context-metrics", contextTokens: 9_000, effectiveTriggerTokens: 20_000, compressionEnabled: false, - source: 'provider' - }) - }) - expect(screen.getByText(/本次调用 9\.0K/u)).toBeInTheDocument() - expect(screen.queryByText(/压缩线/u)).not.toBeInTheDocument() + source: "provider", + }); + }); + expect(screen.getByText(/本次调用 9\.0K/u)).toBeInTheDocument(); + expect(screen.queryByText(/压缩线/u)).not.toBeInTheDocument(); const currentRuntimeSelectionKey = - settings.opencodeModelSource.kind === 'profile' + settings.opencodeModelSource.kind === "profile" ? `opencode:${settings.opencodeModelSource.profileId}` - : 'opencode:platform' + : "opencode:platform"; await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith([ expect.objectContaining({ header: expect.objectContaining({ contextMetrics: expect.objectContaining({ - runtimeSelectionKey: currentRuntimeSelectionKey - }) - }) - }) - ]) - ) - }) + runtimeSelectionKey: currentRuntimeSelectionKey, + }), + }), + }), + ]), + ); + }); - it('keeps a removed model selection visible until the user replaces it', async () => { - const removedProfileId = - '00000000-0000-4000-8000-000000000099' + it("keeps a removed model selection visible until the user replaces it", async () => { + const removedProfileId = "00000000-0000-4000-8000-000000000099"; vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000022', + id: "00000000-0000-4000-8000-000000000022", projectId, runtimeSelection: { - provider: 'model', - profileId: removedProfileId + provider: "model", + profileId: removedProfileId, }, - title: '旧模型对话', + title: "旧模型对话", updatedAt: 1, messages: [ { - id: '00000000-0000-4000-8000-000000000023', - role: 'assistant', - content: '旧消息', + id: "00000000-0000-4000-8000-000000000023", + role: "assistant", + content: "旧消息", createdAt: 1, - state: 'complete' - } - ] - } - ]) - render() + state: "complete", + }, + ], + }, + ]); + render(); + expect(await screen.findByText("旧消息")).toBeInTheDocument(); expect( - await screen.findByText('旧消息') - ).toBeInTheDocument() - expect( - screen.getByRole('button', { name: /模型配置不可用/u }) - ).toBeInTheDocument() - expect(api.conversations.replace).not.toHaveBeenCalled() - expect(api.conversations.saveLocal).not.toHaveBeenCalled() - }) + screen.getByRole("button", { name: /模型配置不可用/u }), + ).toBeInTheDocument(); + expect(api.conversations.replace).not.toHaveBeenCalled(); + expect(api.conversations.saveLocal).not.toHaveBeenCalled(); + }); - it('keeps model Runtime selection scoped to its conversation', async () => { - const secondProfileId = - '00000000-0000-4000-8000-000000000002' - const settings = await api.settings.getRuntime() + it("keeps model Runtime selection scoped to its conversation", async () => { + const secondProfileId = "00000000-0000-4000-8000-000000000002"; + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, modelProfiles: [ ...settings.modelProfiles, { id: secondProfileId, - name: '第二模型', - baseUrl: 'http://127.0.0.1:11434/v1', - modelName: 'qwen3', - protocol: 'openai-chat-completions', - authentication: 'none', - imageGenerationQuality: 'auto', + name: "第二模型", + baseUrl: "http://127.0.0.1:11434/v1", + modelName: "qwen3", + protocol: "openai-chat-completions", + authentication: "none", + imageGenerationQuality: "auto", apiKeyConfigured: false, - credentialSource: 'none' - } - ] - }) - vi.mocked(api.agent.getStatus).mockImplementation( - async (selection) => ({ - id: 'model', - label: - selection?.provider === 'model' && - selection.profileId === secondProfileId - ? 'qwen3' - : 'sonnet-5', - available: true, - supportsToolExecution: true, - detail: 'Ready' - }) - ) - render() + credentialSource: "none", + }, + ], + }); + vi.mocked(api.agent.getStatus).mockImplementation(async (selection) => ({ + id: "model", + label: + selection?.provider === "model" && + selection.profileId === secondProfileId + ? "qwen3" + : "sonnet-5", + available: true, + supportsToolExecution: true, + detail: "Ready", + })); + render(); + fireEvent.click(await screen.findByRole("button", { name: /sonnet-5/u })); fireEvent.click( - await screen.findByRole('button', { name: /sonnet-5/u }) - ) - fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^第二模型.*qwen3$/u - }) - ) + screen.getByRole("menuitemradio", { + name: /^第二模型.*qwen3$/u, + }), + ); expect( - await screen.findByRole('button', { name: /第二模型/u }) - ).toBeInTheDocument() + await screen.findByRole("button", { name: /第二模型/u }), + ).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '第二模型对话' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "第二模型对话" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; expect(request?.runtimeSelection).toEqual({ - provider: 'model', - profileId: secondProfileId - }) + provider: "model", + profileId: secondProfileId, + }); if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); - fireEvent.click( - screen.getByRole('button', { name: /新建对话/u }) - ) + fireEvent.click(screen.getByRole("button", { name: /新建对话/u })); expect( - await screen.findByRole('button', { name: /默认模型/u }) - ).toBeInTheDocument() + await screen.findByRole("button", { name: /默认模型/u }), + ).toBeInTheDocument(); const previousConversation = screen - .getAllByText('第二模型对话') - .map((element) => element.closest('button')) - .find((button) => button?.classList.contains('conversation-item')) + .getAllByText("第二模型对话") + .map((element) => element.closest("button")) + .find((button) => button?.classList.contains("conversation-item")); if (!previousConversation) { - throw new Error('Missing previous conversation') + throw new Error("Missing previous conversation"); } - fireEvent.click(previousConversation) + fireEvent.click(previousConversation); await waitFor(() => expect( screen - .getAllByRole('button', { name: /第二模型/u }) - .find((button) => button.classList.contains('model-button')) - ).toBeInTheDocument() - ) + .getAllByRole("button", { name: /第二模型/u }) + .find((button) => button.classList.contains("model-button")), + ).toBeInTheDocument(), + ); await waitFor( () => @@ -9176,510 +8806,524 @@ describe('App', () => { expect.arrayContaining([ expect.objectContaining({ header: expect.objectContaining({ - title: '第二模型对话', + title: "第二模型对话", runtimeSelection: { - provider: 'model', - profileId: secondProfileId - } + provider: "model", + profileId: secondProfileId, + }, }), - messages: expect.any(Array) - }) - ]) + messages: expect.any(Array), + }), + ]), ), - { timeout: 2_000 } - ) - }) + { timeout: 2_000 }, + ); + }); - it('ignores a stale picker status after rapidly changing conversations', async () => { - const secondProfileId = - '00000000-0000-4000-8000-000000000002' - const thirdProfileId = - '00000000-0000-4000-8000-000000000003' - const settings = await api.settings.getRuntime() + it("ignores a stale picker status after rapidly changing conversations", async () => { + const secondProfileId = "00000000-0000-4000-8000-000000000002"; + const thirdProfileId = "00000000-0000-4000-8000-000000000003"; + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, modelProfiles: [ ...settings.modelProfiles, { id: secondProfileId, - name: '第二模型', - baseUrl: 'http://127.0.0.1:11434/v1', - modelName: 'qwen3', - protocol: 'openai-chat-completions', - authentication: 'none', - imageGenerationQuality: 'auto', + name: "第二模型", + baseUrl: "http://127.0.0.1:11434/v1", + modelName: "qwen3", + protocol: "openai-chat-completions", + authentication: "none", + imageGenerationQuality: "auto", apiKeyConfigured: false, - credentialSource: 'none' + credentialSource: "none", }, { id: thirdProfileId, - name: '第三模型', - baseUrl: 'http://127.0.0.1:11435/v1', - modelName: 'llama3', - protocol: 'openai-chat-completions', - authentication: 'none', - imageGenerationQuality: 'auto', + name: "第三模型", + baseUrl: "http://127.0.0.1:11435/v1", + modelName: "llama3", + protocol: "openai-chat-completions", + authentication: "none", + imageGenerationQuality: "auto", apiKeyConfigured: false, - credentialSource: 'none' - } - ] - }) + credentialSource: "none", + }, + ], + }); let resolveThird!: (status: { - id: 'model' - label: string - available: boolean - supportsToolExecution: boolean - detail: string - }) => void + id: "model"; + label: string; + available: boolean; + supportsToolExecution: boolean; + detail: string; + }) => void; const thirdStatus = new Promise<{ - id: 'model' - label: string - available: boolean - supportsToolExecution: boolean - detail: string + id: "model"; + label: string; + available: boolean; + supportsToolExecution: boolean; + detail: string; }>((resolve) => { - resolveThird = resolve - }) + resolveThird = resolve; + }); vi.mocked(api.agent.getStatus).mockImplementation(async (selection) => { if ( - selection?.provider === 'model' && + selection?.provider === "model" && selection.profileId === thirdProfileId ) { - return thirdStatus + return thirdStatus; } return { - id: 'model', + id: "model", label: - selection?.provider === 'model' && + selection?.provider === "model" && selection.profileId === secondProfileId - ? 'qwen3' - : 'sonnet-5', + ? "qwen3" + : "sonnet-5", available: true, supportsToolExecution: true, - detail: 'Ready' - } - }) - render() + detail: "Ready", + }; + }); + render(); + fireEvent.click(await screen.findByRole("button", { name: /sonnet-5/u })); fireEvent.click( - await screen.findByRole('button', { name: /sonnet-5/u }) - ) - fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^第二模型.*qwen3$/u - }) - ) - await screen.findByRole('button', { name: /第二模型/u }) - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '保留第二模型会话' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + screen.getByRole("menuitemradio", { + name: /^第二模型.*qwen3$/u, + }), + ); + await screen.findByRole("button", { name: /第二模型/u }); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "保留第二模型会话" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { - agentListener?.({ requestId: request.requestId, type: 'done' }) - }) + agentListener?.({ requestId: request.requestId, type: "done" }); + }); const secondModelButton = screen - .getAllByRole('button', { name: /第二模型/u }) - .find((button) => button.classList.contains('model-button')) + .getAllByRole("button", { name: /第二模型/u }) + .find((button) => button.classList.contains("model-button")); if (!secondModelButton) { - throw new Error('Missing second model picker') + throw new Error("Missing second model picker"); } - fireEvent.click(secondModelButton) + fireEvent.click(secondModelButton); fireEvent.click( - screen.getByRole('menuitemradio', { - name: /^第三模型.*llama3$/u - }) - ) + screen.getByRole("menuitemradio", { + name: /^第三模型.*llama3$/u, + }), + ); await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith({ - provider: 'model', - profileId: thirdProfileId - }) - ) - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '状态未完成时不能发送' } - }) - expect(screen.getByLabelText('发送')).toBeDisabled() - fireEvent.click(screen.getByRole('button', { name: /新建对话/u })) - await screen.findByRole('button', { name: /默认模型/u }) + provider: "model", + profileId: thirdProfileId, + }), + ); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "状态未完成时不能发送" }, + }); + expect(screen.getByLabelText("发送")).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: /新建对话/u })); + await screen.findByRole("button", { name: /默认模型/u }); await act(async () => { resolveThird({ - id: 'model', - label: 'llama3', + id: "model", + label: "llama3", available: true, supportsToolExecution: true, - detail: 'Ready' - }) - await thirdStatus - }) + detail: "Ready", + }); + await thirdStatus; + }); expect( - screen.getByRole('button', { name: /默认模型/u }) - ).toBeInTheDocument() + screen.getByRole("button", { name: /默认模型/u }), + ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: /第三模型/u }) - ).not.toBeInTheDocument() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '新对话仍可发送' } - }) - await waitFor(() => expect(screen.getByLabelText('发送')).toBeEnabled()) - }) + screen.queryByRole("button", { name: /第三模型/u }), + ).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "新对话仍可发送" }, + }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + }); - it('opens project creation as an unobscured dialog', async () => { - render() + it.each(["resolved", "rejected", "returned"] as const)("ignores a %s deferred picker switch after same-runtime conversation navigation", async (outcome) => { + const settings = await api.settings.getRuntime(); + const selection = { provider: "model" as const, profileId: settings.defaultModelProfileId! }; + const rows = [1, 2].map((index) => ({ + id: `00000000-0000-4000-8000-00000000078${index}`, + projectId, + runtimeSelection: selection, + title: `Picker conversation ${index}`, + updatedAt: 1_775_000_000_000 - index, + messages: [], + })); + vi.mocked(api.conversations.list).mockResolvedValueOnce(rows); + render(); + const picker = await screen.findByRole("button", { name: /sonnet-5/u }); + const pending = deferred>>(); + vi.mocked(api.agent.getStatus).mockReturnValueOnce(pending.promise); + fireEvent.click(picker); + fireEvent.click(screen.getByRole("menuitemradio", { name: /^OpenCode/u })); + fireEvent.click(screen.getByText(rows[1]!.title).closest("button")!); + if (outcome === "returned") { + fireEvent.click(screen.getByText(rows[0]!.title).closest("button")!); + } + const activeRow = rows[outcome === "returned" ? 0 : 1]!; + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "still usable" } }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + await act(async () => { + if (outcome === "rejected") { + pending.reject(new Error("obsolete picker failure")); + } else { + pending.resolve({ id: "opencode", label: "OpenCode", available: true, supportsToolExecution: true, detail: "Ready" }); + } + await pending.promise.catch(() => undefined); + }); + expect(screen.queryByText("obsolete picker failure")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sonnet-5/u })).toBeInTheDocument(); + expect(screen.getByLabelText("发送")).toBeEnabled(); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + expect(run.mock.calls[0]?.[0]).toMatchObject({ conversationId: activeRow.id, runtimeSelection: selection }); + }); - const newProjectButton = await screen.findByLabelText('新建项目') - fireEvent.click(newProjectButton) - let dialog = screen.getByRole('dialog', { name: '新建项目' }) - expect(dialog).toHaveClass('project-create-card') - expect(within(dialog).getByRole('button', { name: '创建' })) - .toBeDisabled() - expect(within(dialog).getByLabelText('名称')).toHaveFocus() + it("opens project creation as an unobscured dialog", async () => { + render(); - fireEvent.keyDown(document, { key: 'Escape' }) + const newProjectButton = await screen.findByLabelText("新建项目"); + fireEvent.click(newProjectButton); + let dialog = screen.getByRole("dialog", { name: "新建项目" }); + expect(dialog).toHaveClass("project-create-card"); + expect(within(dialog).getByRole("button", { name: "创建" })).toBeDisabled(); + expect(within(dialog).getByLabelText("名称")).toHaveFocus(); + + fireEvent.keyDown(document, { key: "Escape" }); expect( - screen.queryByRole('dialog', { name: '新建项目' }) - ).not.toBeInTheDocument() - expect(newProjectButton).toHaveFocus() + screen.queryByRole("dialog", { name: "新建项目" }), + ).not.toBeInTheDocument(); + expect(newProjectButton).toHaveFocus(); - fireEvent.click(newProjectButton) - dialog = screen.getByRole('dialog', { name: '新建项目' }) + fireEvent.click(newProjectButton); + dialog = screen.getByRole("dialog", { name: "新建项目" }); - fireEvent.change(within(dialog).getByLabelText('名称'), { - target: { value: '新项目' } - }) - fireEvent.click(within(dialog).getByRole('button', { name: '创建' })) + fireEvent.change(within(dialog).getByLabelText("名称"), { + target: { value: "新项目" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "创建" })); await waitFor(() => expect(api.projects.create).toHaveBeenCalledWith( expect.objectContaining({ - name: '新项目', - rootPath: '' - }) - ) - ) - }) + name: "新项目", + rootPath: "", + }), + ), + ); + }); - it('keeps project input when creation fails', async () => { + it("keeps project input when creation fails", async () => { vi.mocked(api.projects.create).mockRejectedValueOnce( - new Error('项目目录不可用') - ) - render() + new Error("项目目录不可用"), + ); + render(); - fireEvent.click(await screen.findByLabelText('新建项目')) - const dialog = screen.getByRole('dialog', { name: '新建项目' }) - const nameInput = within(dialog).getByLabelText('名称') + fireEvent.click(await screen.findByLabelText("新建项目")); + const dialog = screen.getByRole("dialog", { name: "新建项目" }); + const nameInput = within(dialog).getByLabelText("名称"); fireEvent.change(nameInput, { - target: { value: '保留的项目名称' } - }) - fireEvent.click( - within(dialog).getByRole('button', { name: '创建' }) - ) + target: { value: "保留的项目名称" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "创建" })); - expect(await within(dialog).findByRole('alert')).toHaveTextContent( - '项目目录不可用' - ) - expect(nameInput).toHaveValue('保留的项目名称') + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "项目目录不可用", + ); + expect(nameInput).toHaveValue("保留的项目名称"); expect( - screen.getByRole('dialog', { name: '新建项目' }) - ).toBeInTheDocument() - }) + screen.getByRole("dialog", { name: "新建项目" }), + ).toBeInTheDocument(); + }); - it('edits and safely deletes the current project from project settings', async () => { + it("edits and safely deletes the current project from project settings", async () => { const hiddenRemoteProject = { ...project, - id: '00000000-0000-4000-8000-000000000119', - name: '隐藏远程项目', - rootPath: '/srv/hidden', - runtimeSelection: { provider: 'opencode' as const }, + id: "00000000-0000-4000-8000-000000000119", + name: "隐藏远程项目", + rootPath: "/srv/hidden", + runtimeSelection: { provider: "opencode" as const }, executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000219', - remoteRootPath: '/srv/hidden' + kind: "ssh" as const, + hostId: "00000000-0000-4000-8000-000000000219", + remoteRootPath: "/srv/hidden", }, - builtInDefault: false - } + builtInDefault: false, + }; const secondProject = { ...project, - id: '00000000-0000-4000-8000-000000000102', - name: '第二项目', - rootPath: 'C:\\Second', + id: "00000000-0000-4000-8000-000000000102", + name: "第二项目", + rootPath: "C:\\Second", executionSpace: { - kind: 'local' as const, - rootPath: 'C:\\Second' - } - } + kind: "local" as const, + rootPath: "C:\\Second", + }, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ hiddenRemoteProject, project, - secondProject - ]) - render() + secondProject, + ]); + render(); - fireEvent.click(await screen.findByLabelText('项目设置')) - let dialog = screen.getByRole('dialog', { name: '项目设置' }) - expect(within(dialog).getByLabelText('名称')).toHaveValue( - project.name - ) - expect(within(dialog).getByLabelText('根目录')).toHaveValue( - project.rootPath - ) - expect( - within(dialog).getByLabelText('新对话默认 Runtime') - ).toHaveValue( + fireEvent.click(await screen.findByLabelText("项目设置")); + let dialog = screen.getByRole("dialog", { name: "项目设置" }); + expect(within(dialog).getByLabelText("名称")).toHaveValue(project.name); + expect(within(dialog).getByLabelText("根目录")).toHaveValue( + project.rootPath, + ); + expect(within(dialog).getByLabelText("新对话默认 Runtime")).toHaveValue( agentRuntimeSelectionKey({ - provider: 'model', - profileId: modelProfileId - }) - ) - fireEvent.change(within(dialog).getByLabelText('说明'), { - target: { value: '更新后的说明' } - }) - fireEvent.change( - within(dialog).getByLabelText('新对话默认 Runtime'), - { - target: { - value: agentRuntimeSelectionKey({ - provider: 'continue', - profileId: modelProfileId - }) - } - } - ) - fireEvent.click( - within(dialog).getByRole('button', { name: '保存项目' }) - ) + provider: "model", + profileId: modelProfileId, + }), + ); + fireEvent.change(within(dialog).getByLabelText("说明"), { + target: { value: "更新后的说明" }, + }); + fireEvent.change(within(dialog).getByLabelText("新对话默认 Runtime"), { + target: { + value: agentRuntimeSelectionKey({ + provider: "continue", + profileId: modelProfileId, + }), + }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "保存项目" })); await waitFor(() => expect(api.projects.update).toHaveBeenCalledWith( project.id, expect.objectContaining({ - description: '更新后的说明', + description: "更新后的说明", rootPath: project.rootPath, runtimeSelection: { - provider: 'continue', - profileId: modelProfileId - } - }) - ) - ) + provider: "continue", + profileId: modelProfileId, + }, + }), + ), + ); - fireEvent.click(screen.getByLabelText('项目设置')) - dialog = screen.getByRole('dialog', { name: '项目设置' }) - expect(dialog).toHaveTextContent('不会删除磁盘上的项目目录或文件') - fireEvent.click( - within(dialog).getByRole('button', { name: '删除项目' }) - ) + fireEvent.click(screen.getByLabelText("项目设置")); + dialog = screen.getByRole("dialog", { name: "项目设置" }); + expect(dialog).toHaveTextContent("不会删除磁盘上的项目目录或文件"); + fireEvent.click(within(dialog).getByRole("button", { name: "删除项目" })); const confirmation = within(dialog).getByLabelText( - `输入“${project.name}”确认删除` - ) - const deleteButton = within(dialog).getByRole('button', { - name: '永久删除项目' - }) - expect(deleteButton).toBeDisabled() + `输入“${project.name}”确认删除`, + ); + const deleteButton = within(dialog).getByRole("button", { + name: "永久删除项目", + }); + expect(deleteButton).toBeDisabled(); fireEvent.change(confirmation, { - target: { value: project.name } - }) - expect(deleteButton).toBeEnabled() - fireEvent.click(deleteButton) + target: { value: project.name }, + }); + expect(deleteButton).toBeEnabled(); + fireEvent.click(deleteButton); await waitFor(() => expect(api.projects.delete).toHaveBeenCalledWith( project.id, - project.name - ) - ) - expect(screen.getByRole('button', { name: '当前项目' })).toHaveTextContent( - secondProject.name - ) - }) + project.name, + ), + ); + expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent( + secondProject.name, + ); + }); - it('uses the project default Runtime for new conversations', async () => { + it("uses the project default Runtime for new conversations", async () => { vi.mocked(api.projects.list).mockResolvedValueOnce([ { ...project, runtimeSelection: { - provider: 'opencode', - profileId: modelProfileId - } - } - ]) + provider: "opencode", + profileId: modelProfileId, + }, + }, + ]); vi.mocked(api.conversations.list).mockResolvedValueOnce([ { - id: '00000000-0000-4000-8000-000000000220', + id: "00000000-0000-4000-8000-000000000220", projectId, runtimeSelection: { - provider: 'model', - profileId: modelProfileId + provider: "model", + profileId: modelProfileId, }, - title: '已有对话', + title: "已有对话", updatedAt: 1, - messages: [] - } - ]) - render() + messages: [], + }, + ]); + render(); - await screen.findAllByText('已有对话') - fireEvent.click( - screen.getByRole('button', { name: /新建对话/u }) - ) + await screen.findAllByText("已有对话"); + fireEvent.click(screen.getByRole("button", { name: /新建对话/u })); await waitFor(() => expect(api.agent.getStatus).toHaveBeenLastCalledWith({ - provider: 'opencode', - profileId: modelProfileId - }) - ) + provider: "opencode", + profileId: modelProfileId, + }), + ); expect( - screen.getByRole('button', { - name: /OpenCode · 默认模型/u - }) - ).toBeInTheDocument() - }) + screen.getByRole("button", { + name: /OpenCode · 默认模型/u, + }), + ).toBeInTheDocument(); + }); - it('uses a message icon for conversation navigation', async () => { - render() + it("uses a message icon for conversation navigation", async () => { + render(); - const conversationNavigation = await screen.findByRole('button', { - name: '对话' - }) + const conversationNavigation = await screen.findByRole("button", { + name: "对话", + }); expect( - conversationNavigation.querySelector('.lucide-message-square') - ).not.toBeNull() - expect( - conversationNavigation.querySelector('.lucide-history') - ).toBeNull() - }) + conversationNavigation.querySelector(".lucide-message-square"), + ).not.toBeNull(); + expect(conversationNavigation.querySelector(".lucide-history")).toBeNull(); + }); - it('marks an image model and renders its generated artifact', async () => { + it("marks an image model and renders its generated artifact", async () => { const anchorClick = vi - .spyOn(HTMLAnchorElement.prototype, 'click') - .mockImplementation(() => {}) + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); vi.mocked(api.agent.getStatus).mockResolvedValueOnce({ - id: 'model', - label: 'gpt-image-2', + id: "model", + label: "gpt-image-2", available: true, supportsToolExecution: false, - detail: 'OpenAI Images Generations', - capability: 'image-generation' - }) - render() + detail: "OpenAI Images Generations", + capability: "image-generation", + }); + render(); - expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0) - expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute( - 'placeholder', - '描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本' - ) - await waitFor(() => - expect(api.artifacts.list).toHaveBeenCalled() - ) + expect((await screen.findAllByText("生图")).length).toBeGreaterThan(0); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( + "placeholder", + "描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本", + ); + await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled()); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '生成一只蓝色的猫' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "生成一只蓝色的猫" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } - const artifactId = '00000000-0000-4000-8000-000000000301' - vi.mocked(api.artifacts.get).mockResolvedValueOnce( - { - id: artifactId, - projectId, - taskId: request.requestId, - kind: 'image', - title: '生成一只蓝色的猫', - mimeType: 'image/png', - content: - 'data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAA', - byteSize: 42, - createdAt: '2026-08-01T00:00:00.000Z', - updatedAt: '2026-08-01T00:00:00.000Z' - } - ) + const artifactId = "00000000-0000-4000-8000-000000000301"; + vi.mocked(api.artifacts.get).mockResolvedValueOnce({ + id: artifactId, + projectId, + taskId: request.requestId, + kind: "image", + title: "生成一只蓝色的猫", + mimeType: "image/png", + content: "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAA", + byteSize: 42, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }); act(() => { agentListener?.({ requestId: request.requestId, - type: 'artifact', + type: "artifact", artifactId, - kind: 'image', - title: '生成一只蓝色的猫' - }) - }) + kind: "image", + title: "生成一只蓝色的猫", + }); + }); expect( - await screen.findByRole('img', { name: '生成一只蓝色的猫' }) - ).toHaveAttribute('src', expect.stringMatching(/^data:image\/png/u)) + await screen.findByRole("img", { name: "生成一只蓝色的猫" }), + ).toHaveAttribute("src", expect.stringMatching(/^data:image\/png/u)); fireEvent.click( - screen.getByRole('button', { - name: '下载图片 生成一只蓝色的猫' - }) - ) - expect(anchorClick).toHaveBeenCalledOnce() + screen.getByRole("button", { + name: "下载图片 生成一只蓝色的猫", + }), + ); + expect(anchorClick).toHaveBeenCalledOnce(); fireEvent.click( - screen.getByRole('button', { - name: '查看图片 生成一只蓝色的猫' - }) - ) - const imageDialog = await screen.findByRole('dialog', { - name: '生成一只蓝色的猫' - }) + screen.getByRole("button", { + name: "查看图片 生成一只蓝色的猫", + }), + ); + const imageDialog = await screen.findByRole("dialog", { + name: "生成一只蓝色的猫", + }); expect( - within(imageDialog).getByRole('img', { - name: '生成一只蓝色的猫' - }) - ).toHaveAttribute('src', expect.stringMatching(/^data:image\/png/u)) + within(imageDialog).getByRole("img", { + name: "生成一只蓝色的猫", + }), + ).toHaveAttribute("src", expect.stringMatching(/^data:image\/png/u)); fireEvent.click( - within(imageDialog).getByRole('button', { name: '下载图片' }) - ) - expect(anchorClick).toHaveBeenCalledTimes(2) - fireEvent.keyDown(imageDialog, { key: 'Escape' }) + within(imageDialog).getByRole("button", { name: "下载图片" }), + ); + expect(anchorClick).toHaveBeenCalledTimes(2); + fireEvent.keyDown(imageDialog, { key: "Escape" }); expect( - screen.queryByRole('dialog', { name: '生成一只蓝色的猫' }) - ).not.toBeInTheDocument() - anchorClick.mockRestore() - }) + screen.queryByRole("dialog", { name: "生成一只蓝色的猫" }), + ).not.toBeInTheDocument(); + anchorClick.mockRestore(); + }); - it('can dispatch a request to the parallel expert team', async () => { - render() + it("can dispatch a request to the parallel expert team", async () => { + render(); - selectComposerOption('专家角色', '专家团队(并行)') - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '制定发布计划' } - }) - fireEvent.click(await screen.findByLabelText('发送')) + selectComposerOption("专家角色", "专家团队(并行)"); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "制定发布计划" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); await waitFor(() => expect(run).toHaveBeenCalledWith( expect.objectContaining({ teamMode: true, expertId: undefined, - prompt: '制定发布计划' - }) - ) - ) - }) + prompt: "制定发布计划", + }), + ), + ); + }); - it('requests smart routing only when enabled without an explicit expert', async () => { - const settings = await api.settings.getRuntime() + it("requests smart routing only when enabled without an explicit expert", async () => { + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - subagentSmartRoutingEnabled: true - }) - render() + subagentSmartRoutingEnabled: true, + }); + render(); - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '分析发布风险' } - }) - fireEvent.click(screen.getByLabelText('发送')) + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "分析发布风险" }, + }); + fireEvent.click(screen.getByLabelText("发送")); await waitFor(() => expect(run).toHaveBeenCalledWith( @@ -9687,1342 +9331,1295 @@ describe('App', () => { smartRouting: true, expertId: undefined, teamMode: false, - workMode: 'ask' - }) - ) - ) - }) + workMode: "ask", + }), + ), + ); + }); - it('gives an explicitly selected expert priority over smart routing', async () => { - const expertId = '00000000-0000-4000-8000-000000000501' - const settings = await api.settings.getRuntime() + it("gives an explicitly selected expert priority over smart routing", async () => { + const expertId = "00000000-0000-4000-8000-000000000501"; + const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ ...settings, - subagentSmartRoutingEnabled: true - }) + subagentSmartRoutingEnabled: true, + }); vi.mocked(api.experts.list).mockResolvedValueOnce([ { id: expertId, - name: '发布专家', - description: '检查发布风险', - systemInstructions: 'Review release risks.', - routingKeywords: ['发布', '风险'], + name: "发布专家", + description: "检查发布风险", + systemInstructions: "Review release risks.", + routingKeywords: ["发布", "风险"], enabled: true, - createdAt: '2026-08-01T00:00:00.000Z', - updatedAt: '2026-08-01T00:00:00.000Z' - } - ]) - render() + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + ]); + render(); - await waitFor(() => expect(api.experts.list).toHaveBeenCalled()) - const expertMenu = openComposerMenu('专家角色') + await waitFor(() => expect(api.experts.list).toHaveBeenCalled()); + const expertMenu = openComposerMenu("专家角色"); fireEvent.click( - (await within(expertMenu).findByText('发布专家', { - selector: 'span' - })) - .closest('button')! - ) - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '检查发布方案' } - }) - fireEvent.click(screen.getByLabelText('发送')) + ( + await within(expertMenu).findByText("发布专家", { + selector: "span", + }) + ).closest("button")!, + ); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "检查发布方案" }, + }); + fireEvent.click(screen.getByLabelText("发送")); await waitFor(() => expect(run).toHaveBeenCalledWith( expect.objectContaining({ expertId, smartRouting: undefined, - teamMode: false - }) - ) - ) - }) + teamMode: false, + }), + ), + ); + }); - it('shows Subagent states and records child expert activity', async () => { - render() - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '分析复杂问题' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + it("shows Subagent states and records child expert activity", async () => { + render(); + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "分析复杂问题" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } const events = [ { - childTaskId: '00000000-0000-4000-8000-000000000601', - expertId: '00000000-0000-4000-8000-000000000701', - expertName: '研究专家', - routingMode: 'smart' as const, - state: 'queued' as const + childTaskId: "00000000-0000-4000-8000-000000000601", + expertId: "00000000-0000-4000-8000-000000000701", + expertName: "研究专家", + routingMode: "smart" as const, + state: "queued" as const, }, { - childTaskId: '00000000-0000-4000-8000-000000000602', - expertId: '00000000-0000-4000-8000-000000000702', - expertName: '代码专家', - routingMode: 'manual' as const, - state: 'running' as const + childTaskId: "00000000-0000-4000-8000-000000000602", + expertId: "00000000-0000-4000-8000-000000000702", + expertName: "代码专家", + routingMode: "manual" as const, + state: "running" as const, }, { - childTaskId: '00000000-0000-4000-8000-000000000603', - expertId: '00000000-0000-4000-8000-000000000703', - expertName: '安全专家', - routingMode: 'smart' as const, - state: 'failed' as const, - error: '无法读取必要上下文' + childTaskId: "00000000-0000-4000-8000-000000000603", + expertId: "00000000-0000-4000-8000-000000000703", + expertName: "安全专家", + routingMode: "smart" as const, + state: "failed" as const, + error: "无法读取必要上下文", }, { - childTaskId: '00000000-0000-4000-8000-000000000604', - expertId: '00000000-0000-4000-8000-000000000704', - expertName: '第四位专家', - routingMode: 'smart' as const, - state: 'completed' as const - } - ] + childTaskId: "00000000-0000-4000-8000-000000000604", + expertId: "00000000-0000-4000-8000-000000000704", + expertName: "第四位专家", + routingMode: "smart" as const, + state: "completed" as const, + }, + ]; act(() => { for (const event of events) { agentListener?.({ requestId: request.requestId, - type: 'subagent', - ...event - }) + type: "subagent", + ...event, + }); } - }) + }); - const statusRegion = await screen.findByLabelText('子代理状态') - expect(within(statusRegion).getByText('研究专家')).toBeInTheDocument() - expect(within(statusRegion).getByText('等待中')).toBeInTheDocument() - expect(within(statusRegion).getByText('代码专家')).toBeInTheDocument() - expect(within(statusRegion).getByText('进行中')).toBeInTheDocument() - expect(within(statusRegion).getByText('安全专家')).toBeInTheDocument() - expect(within(statusRegion).getByText('失败')).toBeInTheDocument() - const subagentCards = within(statusRegion).getAllByRole('group') + const statusRegion = await screen.findByLabelText("子代理状态"); + expect(within(statusRegion).getByText("研究专家")).toBeInTheDocument(); + expect(within(statusRegion).getByText("等待中")).toBeInTheDocument(); + expect(within(statusRegion).getByText("代码专家")).toBeInTheDocument(); + expect(within(statusRegion).getByText("进行中")).toBeInTheDocument(); + expect(within(statusRegion).getByText("安全专家")).toBeInTheDocument(); + expect(within(statusRegion).getByText("失败")).toBeInTheDocument(); + const subagentCards = within(statusRegion).getAllByRole("group"); fireEvent.click( - within(subagentCards[2]!).getByText('安全专家', { - selector: 'strong' - }) - ) - fireEvent(subagentCards[2]!, new Event('toggle')) + within(subagentCards[2]!).getByText("安全专家", { + selector: "strong", + }), + ); + fireEvent(subagentCards[2]!, new Event("toggle")); expect( - within(statusRegion).getByText('无法读取必要上下文') - ).toBeInTheDocument() - expect( - within(statusRegion).getByText('第四位专家') - ).toBeInTheDocument() + within(statusRegion).getByText("无法读取必要上下文"), + ).toBeInTheDocument(); + expect(within(statusRegion).getByText("第四位专家")).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'subagent', + type: "subagent", ...events[0]!, - state: 'completed', - output: '研究专家的独立结论' - }) + state: "completed", + output: "研究专家的独立结论", + }); agentListener?.({ requestId: request.requestId, - type: 'subagent', + type: "subagent", ...events[1]!, - state: 'cancelled', - reason: '父任务已停止', - output: '代码专家的部分结论' - }) - }) - expect(within(statusRegion).getAllByText('已完成')).toHaveLength(2) - expect(within(statusRegion).getByText('已取消')).toBeInTheDocument() - expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument() - for (const [index, expertName] of [ - '研究专家', - '代码专家' - ].entries()) { + state: "cancelled", + reason: "父任务已停止", + output: "代码专家的部分结论", + }); + }); + expect(within(statusRegion).getAllByText("已完成")).toHaveLength(2); + expect(within(statusRegion).getByText("已取消")).toBeInTheDocument(); + expect(within(statusRegion).getByText("父任务已停止")).toBeInTheDocument(); + for (const [index, expertName] of ["研究专家", "代码专家"].entries()) { fireEvent.click( within(subagentCards[index]!).getByText(expertName, { - selector: 'strong' - }) - ) - fireEvent(subagentCards[index]!, new Event('toggle')) + selector: "strong", + }), + ); + fireEvent(subagentCards[index]!, new Event("toggle")); } expect( - within(statusRegion).getByText('研究专家的独立结论') - ).toBeInTheDocument() + within(statusRegion).getByText("研究专家的独立结论"), + ).toBeInTheDocument(); expect( - within(statusRegion).getByText('代码专家的部分结论') - ).toBeInTheDocument() + within(statusRegion).getByText("代码专家的部分结论"), + ).toBeInTheDocument(); await waitFor(() => { const persistedMessages = vi .mocked(api.conversations.saveLocal) .mock.calls.flatMap(([batch]) => - batch.flatMap((conversation) => conversation.messages) - ) + batch.flatMap((conversation) => conversation.messages), + ); expect( persistedMessages.some( (message) => - message.subagents?.[0]?.output === - '研究专家的独立结论' && - message.subagents?.[1]?.output === '代码专家的部分结论' - ) - ).toBe(true) - }) + message.subagents?.[0]?.output === "研究专家的独立结论" && + message.subagents?.[1]?.output === "代码专家的部分结论", + ), + ).toBe(true); + }); - fireEvent.click(screen.getByText('运行记录')) - expect(await screen.findAllByText('子专家')).toHaveLength(4) - expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0) - expect(screen.getAllByText(/指定专家/u).length).toBeGreaterThan(0) - expect( - screen.getByText(/研究专家的独立结论/u) - ).toBeInTheDocument() - expect( - screen.getByText(/代码专家的部分结论/u) - ).toBeInTheDocument() - }) + fireEvent.click(screen.getByText("运行记录")); + expect(await screen.findAllByText("子专家")).toHaveLength(4); + expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0); + expect(screen.getAllByText(/指定专家/u).length).toBeGreaterThan(0); + expect(screen.getByText(/研究专家的独立结论/u)).toBeInTheDocument(); + expect(screen.getByText(/代码专家的部分结论/u)).toBeInTheDocument(); + }); - it('replaces an incremental OpenCode Task tool with a native subagent card', async () => { - render() - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '检查架构' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + it("replaces an incremental OpenCode Task tool with a native subagent card", async () => { + render(); + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "检查架构" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'tool', - callId: 'call-task-1', - name: 'Review application architecture', - state: 'running', - summary: '远端 Runtime 工具:Review application architecture' - }) + type: "tool", + callId: "call-task-1", + name: "Review application architecture", + state: "running", + summary: "远端 Runtime 工具:Review application architecture", + }); agentListener?.({ requestId: request.requestId, - type: 'reasoning', - delta: '等待子代理结果' - }) - }) - expect(await screen.findByText('等待子代理结果')).toBeInTheDocument() + type: "reasoning", + delta: "等待子代理结果", + }); + }); + expect(await screen.findByText("等待子代理结果")).toBeInTheDocument(); act(() => { agentListener?.({ requestId: request.requestId, - type: 'subagent', - childTaskId: '00000000-0000-4000-8000-000000000611', - expertId: '00000000-0000-4000-8000-000000000711', - expertName: 'explorer', - routingMode: 'native', - runtimeCallId: 'call-task-1', - state: 'running', - reason: 'Review application architecture' - }) + type: "subagent", + childTaskId: "00000000-0000-4000-8000-000000000611", + expertId: "00000000-0000-4000-8000-000000000711", + expertName: "explorer", + routingMode: "native", + runtimeCallId: "call-task-1", + state: "running", + reason: "Review application architecture", + }); agentListener?.({ requestId: request.requestId, - type: 'text', - delta: '父 Agent 最终回复' - }) - }) + type: "text", + delta: "父 Agent 最终回复", + }); + }); - const region = await screen.findByLabelText('子代理状态') - expect(within(region).getByText('explorer')).toBeInTheDocument() - expect(within(region).getByText('OpenCode 原生')) - .toBeInTheDocument() + const region = await screen.findByLabelText("子代理状态"); + expect(within(region).getByText("explorer")).toBeInTheDocument(); + expect(within(region).getByText("OpenCode 原生")).toBeInTheDocument(); expect( - screen.queryByRole('region', { - name: '工具执行,共 1 项' - }) - ).not.toBeInTheDocument() + screen.queryByRole("region", { + name: "工具执行,共 1 项", + }), + ).not.toBeInTheDocument(); const assistantArticle = screen - .getByText('父 Agent 最终回复') - .closest('article') + .getByText("父 Agent 最终回复") + .closest("article"); const orderedBlocks = [ - ...assistantArticle!.querySelectorAll('.message-blocks > *') - ].map((element) => element.textContent) + ...assistantArticle!.querySelectorAll(".message-blocks > *"), + ].map((element) => element.textContent); expect(orderedBlocks).toEqual([ - expect.stringContaining('Review application architecture'), - expect.stringContaining('等待子代理结果'), - expect.stringContaining('父 Agent 最终回复') - ]) - fireEvent.click(screen.getByText('运行记录')) - expect(await screen.findAllByText('子专家')).toHaveLength(1) + expect.stringContaining("Review application architecture"), + expect.stringContaining("等待子代理结果"), + expect.stringContaining("父 Agent 最终回复"), + ]); + fireEvent.click(screen.getByText("运行记录")); + expect(await screen.findAllByText("子专家")).toHaveLength(1); expect( - screen.queryByText( - '远端 Runtime 工具:Review application architecture' - ) - ).not.toBeInTheDocument() - }) + screen.queryByText("远端 Runtime 工具:Review application architecture"), + ).not.toBeInTheDocument(); + }); - it('records direct-model child runs without adding Assistant Tasks', async () => { - render() - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '修复并验证聚焦变更' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + it("records direct-model child runs without adding Assistant Tasks", async () => { + render(); + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "修复并验证聚焦变更" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } - const childRunId = - '00000000-0000-4000-8000-000000000613' + const childRunId = "00000000-0000-4000-8000-000000000613"; act(() => { agentListener?.({ requestId: request.requestId, - type: 'subagent', + type: "subagent", childTaskId: childRunId, actor: { - kind: 'direct-model', - label: '编程 Subagent' + kind: "direct-model", + label: "编程 Subagent", }, - routingMode: 'native', - workMode: 'execute', - state: 'completed', - reason: '修复并验证聚焦变更', - output: '验证通过' - }) - }) + routingMode: "native", + workMode: "execute", + state: "completed", + reason: "修复并验证聚焦变更", + output: "验证通过", + }); + }); - const region = await screen.findByLabelText('子代理状态') + const region = await screen.findByLabelText("子代理状态"); expect( - within(region).getByText('编程 Subagent', { - selector: 'strong' - }) - ).toBeInTheDocument() - expect(within(region).getByText('直连模型 · Execute')) - .toBeInTheDocument() + within(region).getByText("编程 Subagent", { + selector: "strong", + }), + ).toBeInTheDocument(); + expect(within(region).getByText("直连模型 · Execute")).toBeInTheDocument(); await waitFor(() => { const persistedActivities = vi .mocked(api.conversations.saveLocal) .mock.calls.flatMap(([batch]) => batch.flatMap((conversation) => - conversation.messages.flatMap( - (message) => message.subagents ?? [] - ) - ) - ) + conversation.messages.flatMap((message) => message.subagents ?? []), + ), + ); expect(persistedActivities).toContainEqual( expect.objectContaining({ childTaskId: childRunId, actor: { - kind: 'direct-model', - label: '编程 Subagent' + kind: "direct-model", + label: "编程 Subagent", }, - workMode: 'execute', - output: '验证通过' - }) - ) - }) + workMode: "execute", + output: "验证通过", + }), + ); + }); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) + fireEvent.click(screen.getByLabelText("切换助手工作栏")); expect( - await screen.findByText('明确创建的任务会显示在这里。') - ).toBeInTheDocument() - }) + await screen.findByText("明确创建的任务会显示在这里。"), + ).toBeInTheDocument(); + }); - it('fails an unterminated subagent when the parent reports done', async () => { - render() - fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { - target: { value: '检查未完成子任务' } - }) - fireEvent.click(screen.getByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + it("fails an unterminated subagent when the parent reports done", async () => { + render(); + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "检查未完成子任务" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'subagent', - childTaskId: '00000000-0000-4000-8000-000000000612', - expertId: '00000000-0000-4000-8000-000000000712', - expertName: 'explorer', - routingMode: 'native', - runtimeCallId: 'call-task-2', - state: 'running', - reason: '检查未完成路径' - }) + type: "subagent", + childTaskId: "00000000-0000-4000-8000-000000000612", + expertId: "00000000-0000-4000-8000-000000000712", + expertName: "explorer", + routingMode: "native", + runtimeCallId: "call-task-2", + state: "running", + reason: "检查未完成路径", + }); agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) + type: "done", + }); + }); - const region = await screen.findByLabelText('子代理状态') - expect(within(region).getByText('失败')).toBeInTheDocument() - const subagentCard = within(region).getByRole('group') + const region = await screen.findByLabelText("子代理状态"); + expect(within(region).getByText("失败")).toBeInTheDocument(); + const subagentCard = within(region).getByRole("group"); fireEvent.click( - within(subagentCard).getByText('explorer', { - selector: 'strong' - }) - ) - fireEvent(subagentCard, new Event('toggle')) + within(subagentCard).getByText("explorer", { + selector: "strong", + }), + ); + fireEvent(subagentCard, new Event("toggle")); expect( - within(region).getByText( - '父请求已结束,但子 Agent 未报告完成状态。' - ) - ).toBeInTheDocument() + within(region).getByText("父请求已结束,但子 Agent 未报告完成状态。"), + ).toBeInTheDocument(); await waitFor(() => { const persistedMessages = vi .mocked(api.conversations.saveLocal) .mock.calls.flatMap(([batch]) => - batch.flatMap((conversation) => conversation.messages) - ) + batch.flatMap((conversation) => conversation.messages), + ); expect( persistedMessages.some( (message) => - message.subagents?.[0]?.state === 'failed' && + message.subagents?.[0]?.state === "failed" && message.subagents[0].error === - '父请求已结束,但子 Agent 未报告完成状态。' - ) - ).toBe(true) - }) - }) + "父请求已结束,但子 Agent 未报告完成状态。", + ), + ).toBe(true); + }); + }); - it('offers once, session, permanent, and deny for a tool call', async () => { - render() + it("offers once, session, permanent, and deny for a tool call", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '运行工具' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "运行工具" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'approval', + type: "approval", approvalId: crypto.randomUUID(), - title: 'Continue 请求调用 Bash', - description: '确认工具调用', - toolName: 'Bash', - argumentSummary: 'echo safe', - allowPermanent: true - }) - }) + title: "Continue 请求调用 Bash", + description: "确认工具调用", + toolName: "Bash", + argumentSummary: "echo safe", + allowPermanent: true, + }); + }); - expect(await screen.findByText('仅此次')).toBeInTheDocument() - expect(screen.getByText('此会话')).toBeInTheDocument() - expect(screen.getByText('永久允许')).toBeInTheDocument() - expect(screen.getAllByText('拒绝')).toHaveLength(2) - fireEvent.click(screen.getByText('此会话')) + expect(await screen.findByText("仅此次")).toBeInTheDocument(); + expect(screen.getByText("此会话")).toBeInTheDocument(); + expect(screen.getByText("永久允许")).toBeInTheDocument(); + expect(screen.getAllByText("拒绝")).toHaveLength(2); + fireEvent.click(screen.getByText("此会话")); await waitFor(() => expect(api.agent.respondApproval).toHaveBeenCalledWith( expect.any(String), - 'session' - ) - ) - }) + "session", + ), + ); + }); - it('renders and answers an OpenCode question request', async () => { - render() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '需要确认的任务' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + it("renders and answers an OpenCode question request", async () => { + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "需要确认的任务" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'question', - questionId: 'question-1', + type: "question", + questionId: "question-1", questions: [ { - header: '实现方式', - question: '请选择实现方式', + header: "实现方式", + question: "请选择实现方式", options: [ { - label: '直接修改', - description: '立即更新现有实现' + label: "直接修改", + description: "立即更新现有实现", }, { - label: '先写测试', - description: '先增加回归测试' - } + label: "先写测试", + description: "先增加回归测试", + }, ], multiple: false, - custom: true - } - ] - }) - }) + custom: true, + }, + ], + }); + }); expect( - await screen.findByText('OpenCode 需要补充信息') - ).toBeInTheDocument() - fireEvent.click(screen.getByLabelText(/先写测试/u)) - fireEvent.click(screen.getByRole('button', { name: '提交回答' })) + await screen.findByText("OpenCode 需要补充信息"), + ).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/先写测试/u)); + fireEvent.click(screen.getByRole("button", { name: "提交回答" })); await waitFor(() => - expect(api.agent.respondQuestion).toHaveBeenCalledWith( - 'question-1', - [['先写测试']] - ) - ) - expect( - screen.queryByText('OpenCode 需要补充信息') - ).not.toBeInTheDocument() - }) + expect(api.agent.respondQuestion).toHaveBeenCalledWith("question-1", [ + ["先写测试"], + ]), + ); + expect(screen.queryByText("OpenCode 需要补充信息")).not.toBeInTheDocument(); + }); - it('configures a runtime without reading an existing API key', async () => { - render() + it("configures a runtime without reading an existing API key", async () => { + render(); - fireEvent.click(await screen.findByText('本地工作区')) + fireEvent.click(await screen.findByText("本地工作区")); expect( - await screen.findByRole('heading', { - name: '设置中心' - }) - ).toBeInTheDocument() - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - expect(screen.getByRole('region', { name: '设置中心' })) - .toBeInTheDocument() + await screen.findByRole("heading", { + name: "设置中心", + }), + ).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect( - screen.getByRole('tab', { name: 'Agent Runtime' }) - ).toBeInTheDocument() + screen.getByRole("region", { name: "设置中心" }), + ).toBeInTheDocument(); expect( - screen.getByRole('tab', { name: '安全与数据' }) - ).toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: '模型连接' })) + screen.getByRole("tab", { name: "Agent Runtime" }), + ).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "安全与数据" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "模型连接" })); - const apiKeyInput = screen.getByLabelText('API Key') - expect(apiKeyInput).toHaveValue('') + const apiKeyInput = screen.getByLabelText("API Key"); + expect(apiKeyInput).toHaveValue(""); fireEvent.change(apiKeyInput, { - target: { value: 'test-api-key' } - }) - fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + target: { value: "test-api-key" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存设置" })); await waitFor(() => expect(api.settings.updateRuntime).toHaveBeenCalledWith( expect.objectContaining({ apiKey: { - action: 'replace', - value: 'test-api-key' - } - }) - ) - ) - await waitFor(() => expect(apiKeyInput).toHaveValue('')) - }) + action: "replace", + value: "test-api-key", + }, + }), + ), + ); + await waitFor(() => expect(apiKeyInput).toHaveValue("")); + }); - it('opens the global assistant sidebar and switches work tabs', async () => { - const { container } = render() + it("opens the global assistant sidebar and switches work tabs", async () => { + const { container } = render(); - const sidebar = screen.getByLabelText('助手工作栏') - expect(sidebar).not.toHaveClass('assistant-sidebar--open') - const assistantTrigger = - screen.getByLabelText('切换助手工作栏') + const sidebar = screen.getByLabelText("助手工作栏"); + expect(sidebar).not.toHaveClass("assistant-sidebar--open"); + const assistantTrigger = screen.getByLabelText("切换助手工作栏"); expect(assistantTrigger.parentElement).toBe( - container.querySelector('.app-content') - ) - expect(container.querySelector('.sidebar')?.parentElement).toBe( - container.querySelector('.app-shell') - ) - expect(container.querySelector('.app-frame')?.parentElement).toBe( - container.querySelector('.app-shell') - ) - expect(container.querySelector('.topbar')?.parentElement).toBe( - container.querySelector('.app-frame') - ) - expect(assistantTrigger).toHaveClass('assistant-sidebar-toggle') - expect(assistantTrigger).not.toHaveClass('icon-button--active') - expect(assistantTrigger).toHaveAttribute('aria-expanded', 'false') + container.querySelector(".app-content"), + ); + expect(container.querySelector(".sidebar")?.parentElement).toBe( + container.querySelector(".app-shell"), + ); + expect(container.querySelector(".app-frame")?.parentElement).toBe( + container.querySelector(".app-shell"), + ); + expect(container.querySelector(".topbar")?.parentElement).toBe( + container.querySelector(".app-frame"), + ); + expect(assistantTrigger).toHaveClass("assistant-sidebar-toggle"); + expect(assistantTrigger).not.toHaveClass("icon-button--active"); + expect(assistantTrigger).toHaveAttribute("aria-expanded", "false"); expect(assistantTrigger).toHaveAttribute( - 'aria-controls', - 'assistant-sidebar' - ) + "aria-controls", + "assistant-sidebar", + ); expect( - assistantTrigger.querySelector('.lucide-panel-right-open') - ).toBeInTheDocument() + assistantTrigger.querySelector(".lucide-panel-right-open"), + ).toBeInTheDocument(); expect( - assistantTrigger.querySelector('.lucide-panel-right-close') - ).not.toBeInTheDocument() + assistantTrigger.querySelector(".lucide-panel-right-close"), + ).not.toBeInTheDocument(); + expect(container.querySelector(".topbar")).toContainElement( + screen.getByLabelText("关闭窗口"), + ); + expect(container.querySelector(".topbar")).toContainElement( + container.querySelector(".conversation-title"), + ); + expect(container.querySelector(".topbar")).not.toContainElement( + assistantTrigger, + ); + expect(screen.queryByLabelText("关闭助手工作栏")).not.toBeInTheDocument(); + fireEvent.click(assistantTrigger); + expect(sidebar).toHaveClass("assistant-sidebar--open"); + expect(sidebar).toHaveAttribute("id", "assistant-sidebar"); + expect(sidebar).toHaveAttribute("role", "complementary"); + expect(sidebar).not.toHaveAttribute("aria-modal"); + expect(assistantTrigger).toHaveAttribute("aria-expanded", "true"); + expect(assistantTrigger).not.toHaveClass("icon-button--active"); expect( - container.querySelector('.topbar') - ).toContainElement(screen.getByLabelText('关闭窗口')) - expect(container.querySelector('.topbar')).toContainElement( - container.querySelector('.conversation-title') - ) + assistantTrigger.querySelector(".lucide-panel-right-close"), + ).toBeInTheDocument(); expect( - container.querySelector('.topbar') - ).not.toContainElement(assistantTrigger) + assistantTrigger.querySelector(".lucide-panel-right-open"), + ).not.toBeInTheDocument(); + const main = document.querySelector("main"); + expect(main).not.toHaveAttribute("inert"); expect( - screen.queryByLabelText('关闭助手工作栏') - ).not.toBeInTheDocument() - fireEvent.click(assistantTrigger) - expect(sidebar).toHaveClass('assistant-sidebar--open') - expect(sidebar).toHaveAttribute('id', 'assistant-sidebar') - expect(sidebar).toHaveAttribute('role', 'complementary') - expect(sidebar).not.toHaveAttribute('aria-modal') - expect(assistantTrigger).toHaveAttribute('aria-expanded', 'true') - expect(assistantTrigger).not.toHaveClass('icon-button--active') - expect( - assistantTrigger.querySelector('.lucide-panel-right-close') - ).toBeInTheDocument() - expect( - assistantTrigger.querySelector('.lucide-panel-right-open') - ).not.toBeInTheDocument() - const main = document.querySelector('main') - expect(main).not.toHaveAttribute('inert') - expect( - container.querySelector('.assistant-sidebar-backdrop') - ).not.toBeInTheDocument() + container.querySelector(".assistant-sidebar-backdrop"), + ).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "任务中心" })).toHaveAttribute( + "aria-selected", + "true", + ); + const taskIndexHeading = screen.getByRole("heading", { + name: "任务索引", + }); + const newTaskButton = screen.getByRole("button", { + name: "新建任务", + }); + expect(taskIndexHeading.parentElement).toContainElement(newTaskButton); expect( - screen.getByRole('tab', { name: '任务中心' }) - ).toHaveAttribute('aria-selected', 'true') - const taskIndexHeading = screen.getByRole('heading', { - name: '任务索引' - }) - const newTaskButton = screen.getByRole('button', { - name: '新建任务' - }) - expect(taskIndexHeading.parentElement).toContainElement( - newTaskButton - ) + screen.queryByRole("region", { name: "当前会话的任务" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("最近任务")).not.toBeInTheDocument(); expect( - screen.queryByRole('region', { name: '当前会话的任务' }) - ).not.toBeInTheDocument() - expect(screen.queryByText('最近任务')).not.toBeInTheDocument() + screen.queryByRole("tab", { name: "上下文" }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "工作区" })); + expect(screen.getByRole("tab", { name: "工作区" })).toHaveAttribute( + "title", + "浏览项目文件、Git 变更与文件内容", + ); expect( - screen.queryByRole('tab', { name: '上下文' }) - ).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: '工作区' })) + screen.queryByRole("heading", { name: "项目工作区" }), + ).not.toBeInTheDocument(); expect( - screen.getByRole('tab', { name: '工作区' }) - ).toHaveAttribute('title', '浏览项目文件、Git 变更与文件内容') + screen.getByRole("button", { name: "刷新工作区文件" }), + ).toBeInTheDocument(); expect( - screen.queryByRole('heading', { name: '项目工作区' }) - ).not.toBeInTheDocument() + screen.queryByText(/选择文件后在当前工作区内预览/), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); + expect(screen.getByText("实时浏览器")).toBeInTheDocument(); + expect(screen.getByText(/Agent 打开网页后/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "成果" })); expect( - screen.getByRole('button', { name: '刷新工作区文件' }) - ).toBeInTheDocument() + screen.getByRole("button", { name: "导入 PDF、图片或网页" }), + ).toBeInTheDocument(); expect( - screen.queryByText(/选择文件后在当前工作区内预览/) - ).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: '浏览器' })) - expect(screen.getByText('实时浏览器')).toBeInTheDocument() + screen.getByRole("heading", { name: "生成与导入成果" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "预览" })).not.toBeInTheDocument(); + fireEvent.click(assistantTrigger); + expect(sidebar).not.toHaveClass("assistant-sidebar--open"); + expect(assistantTrigger).toHaveAttribute("aria-expanded", "false"); expect( - screen.getByText(/Agent 打开网页后/) - ).toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: '成果' })) - expect( - screen.getByRole('button', { name: '导入 PDF、图片或网页' }) - ).toBeInTheDocument() - expect( - screen.getByRole('heading', { name: '生成与导入成果' }) - ).toBeInTheDocument() - expect( - screen.queryByRole('tab', { name: '预览' }) - ).not.toBeInTheDocument() - fireEvent.click(assistantTrigger) - expect(sidebar).not.toHaveClass('assistant-sidebar--open') - expect(assistantTrigger).toHaveAttribute('aria-expanded', 'false') - expect( - assistantTrigger.querySelector('.lucide-panel-right-open') - ).toBeInTheDocument() - expect(main).not.toHaveAttribute('inert') - await waitFor(() => expect(assistantTrigger).toHaveFocus()) - }) + assistantTrigger.querySelector(".lucide-panel-right-open"), + ).toBeInTheDocument(); + expect(main).not.toHaveAttribute("inert"); + await waitFor(() => expect(assistantTrigger).toHaveFocus()); + }); - it('keeps completed chat replies out of the results sidebar', async () => { - render() + it("keeps completed chat replies out of the results sidebar", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '普通聊天问题' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - const request = run.mock.calls[0]?.[0] + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "普通聊天问题" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]?.[0]; if (!request) { - throw new Error('Missing request') + throw new Error("Missing request"); } act(() => { agentListener?.({ requestId: request.requestId, - type: 'text', - delta: '这是一条普通聊天回复' - }) + type: "text", + delta: "这是一条普通聊天回复", + }); agentListener?.({ requestId: request.requestId, - type: 'done' - }) - }) - expect( - await screen.findByText('这是一条普通聊天回复') - ).toBeInTheDocument() + type: "done", + }); + }); + expect(await screen.findByText("这是一条普通聊天回复")).toBeInTheDocument(); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - fireEvent.click(screen.getByRole('tab', { name: '成果' })) - const sidebar = screen.getByLabelText('助手工作栏') + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + fireEvent.click(screen.getByRole("tab", { name: "成果" })); + const sidebar = screen.getByLabelText("助手工作栏"); expect( - within(sidebar).queryByText('这是一条普通聊天回复') - ).not.toBeInTheDocument() + within(sidebar).queryByText("这是一条普通聊天回复"), + ).not.toBeInTheDocument(); expect( within(sidebar).getByText( - '生成的文件、图片、报告和手动导入内容会显示在这里。' - ) - ).toBeInTheDocument() - }) + "生成的文件、图片、报告和手动导入内容会显示在这里。", + ), + ).toBeInTheDocument(); + }); - it('shows the browser toolbar and starts an empty session by address', async () => { - render() + it("shows the browser toolbar and starts an empty session by address", async () => { + render(); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - fireEvent.click(screen.getByRole('tab', { name: '浏览器' })) + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); - const toolbar = screen.getByRole('form', { - name: '浏览器工具栏' - }) - const address = within(toolbar).getByRole('textbox', { - name: '浏览器地址' - }) - const go = within(toolbar).getByRole('button', { name: '前往' }) - expect(within(toolbar).getByRole('button', { name: '返回' })) - .toBeDisabled() - expect(within(toolbar).getByRole('button', { name: '刷新' })) - .toBeDisabled() - expect(within(toolbar).getByRole('button', { name: '交互' })) - .toBeDisabled() + const toolbar = screen.getByRole("form", { + name: "浏览器工具栏", + }); + const address = within(toolbar).getByRole("textbox", { + name: "浏览器地址", + }); + const go = within(toolbar).getByRole("button", { name: "前往" }); expect( - within(toolbar).getByRole('button', { name: '关闭浏览器' }) - ).toBeDisabled() - expect(go).toBeDisabled() - expect(screen.getByText(/输入地址并前往可打开页面/u)) - .toBeInTheDocument() + within(toolbar).getByRole("button", { name: "返回" }), + ).toBeDisabled(); + expect( + within(toolbar).getByRole("button", { name: "刷新" }), + ).toBeDisabled(); + expect( + within(toolbar).getByRole("button", { name: "交互" }), + ).toBeDisabled(); + expect( + within(toolbar).getByRole("button", { name: "关闭浏览器" }), + ).toBeDisabled(); + expect(go).toBeDisabled(); + expect(screen.getByText(/输入地址并前往可打开页面/u)).toBeInTheDocument(); fireEvent.change(address, { - target: { value: 'https://example.com/start' } - }) - expect(go).toBeEnabled() - fireEvent.click(go) + target: { value: "https://example.com/start" }, + }); + expect(go).toBeEnabled(); + fireEvent.click(go); await waitFor(() => expect(api.browser.navigate).toHaveBeenLastCalledWith( expect.any(String), - 'https://example.com/start' - ) - ) - expect(address).toHaveValue('https://example.com/start') + "https://example.com/start", + ), + ); + expect(address).toHaveValue("https://example.com/start"); fireEvent.change(address, { - target: { value: 'example.com/enter' } - }) - fireEvent.keyDown(address, { key: 'Enter' }) + target: { value: "example.com/enter" }, + }); + fireEvent.keyDown(address, { key: "Enter" }); await waitFor(() => expect(api.browser.navigate).toHaveBeenLastCalledWith( expect.any(String), - 'http://example.com/enter' - ) - ) - expect(address).toHaveValue('http://example.com/enter') + "http://example.com/enter", + ), + ); + expect(address).toHaveValue("http://example.com/enter"); fireEvent.change(address, { - target: { value: 'mailto:user@example.com' } - }) - fireEvent.keyDown(address, { key: 'Enter' }) + target: { value: "mailto:user@example.com" }, + }); + fireEvent.keyDown(address, { key: "Enter" }); await waitFor(() => expect(api.browser.navigate).toHaveBeenLastCalledWith( expect.any(String), - 'mailto:user@example.com' - ) - ) + "mailto:user@example.com", + ), + ); fireEvent.change(address, { - target: { value: 'localhost:3000/path' } - }) - fireEvent.keyDown(address, { key: 'Enter' }) + target: { value: "localhost:3000/path" }, + }); + fireEvent.keyDown(address, { key: "Enter" }); await waitFor(() => expect(api.browser.navigate).toHaveBeenLastCalledWith( expect.any(String), - 'http://localhost:3000/path' - ) - ) - expect(api.browser.navigate).toHaveBeenCalledTimes(4) - }) + "http://localhost:3000/path", + ), + ); + expect(api.browser.navigate).toHaveBeenCalledTimes(4); + }); - it('suppresses duplicate browser actions while keeping stop loading available', async () => { - const navigation = deferred() - vi.mocked(api.browser.navigate).mockReturnValueOnce( - navigation.promise - ) - render() + it("suppresses duplicate browser actions while keeping stop loading available", async () => { + const navigation = deferred(); + vi.mocked(api.browser.navigate).mockReturnValueOnce(navigation.promise); + render(); - fireEvent.click(screen.getByLabelText('切换助手工作栏')) - fireEvent.click(screen.getByRole('tab', { name: '浏览器' })) + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); - const toolbar = screen.getByRole('form', { - name: '浏览器工具栏' - }) - const address = within(toolbar).getByRole('textbox', { - name: '浏览器地址' - }) - const go = within(toolbar).getByRole('button', { name: '前往' }) + const toolbar = screen.getByRole("form", { + name: "浏览器工具栏", + }); + const address = within(toolbar).getByRole("textbox", { + name: "浏览器地址", + }); + const go = within(toolbar).getByRole("button", { name: "前往" }); fireEvent.change(address, { - target: { value: 'https://example.com/pending' } - }) - fireEvent.click(go) - fireEvent.click(go) - fireEvent.keyDown(address, { key: 'Enter' }) + target: { value: "https://example.com/pending" }, + }); + fireEvent.click(go); + fireEvent.click(go); + fireEvent.keyDown(address, { key: "Enter" }); - expect(api.browser.navigate).toHaveBeenCalledOnce() + expect(api.browser.navigate).toHaveBeenCalledOnce(); const conversationId = - vi.mocked(api.browser.navigate).mock.calls[0]?.[0] ?? '' + vi.mocked(api.browser.navigate).mock.calls[0]?.[0] ?? ""; act(() => { - browserListener?.(createBrowserState(conversationId, { - status: 'loading', - isLoading: true, - url: 'https://example.com/pending', - updatedAt: Date.now() - })) - }) + browserListener?.( + createBrowserState(conversationId, { + status: "loading", + isLoading: true, + url: "https://example.com/pending", + updatedAt: Date.now(), + }), + ); + }); expect( - within(toolbar).getByRole('button', { name: '返回' }) - ).toBeDisabled() - expect(address).toBeDisabled() - expect(go).toBeDisabled() + within(toolbar).getByRole("button", { name: "返回" }), + ).toBeDisabled(); + expect(address).toBeDisabled(); + expect(go).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '交互' }) - ).toBeDisabled() + within(toolbar).getByRole("button", { name: "交互" }), + ).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '关闭浏览器' }) - ).toBeDisabled() - const stopLoading = within(toolbar).getByRole('button', { - name: '停止加载' - }) - expect(stopLoading).toBeEnabled() - fireEvent.click(stopLoading) - expect(api.browser.stopLoading).toHaveBeenCalledOnce() + within(toolbar).getByRole("button", { name: "关闭浏览器" }), + ).toBeDisabled(); + const stopLoading = within(toolbar).getByRole("button", { + name: "停止加载", + }); + expect(stopLoading).toBeEnabled(); + fireEvent.click(stopLoading); + expect(api.browser.stopLoading).toHaveBeenCalledOnce(); await act(async () => { - navigation.resolve() - await navigation.promise - }) - const back = within(toolbar).getByRole('button', { name: '返回' }) - const backNavigation = deferred() - vi.mocked(api.browser.back).mockReturnValueOnce( - backNavigation.promise - ) + navigation.resolve(); + await navigation.promise; + }); + const back = within(toolbar).getByRole("button", { name: "返回" }); + const backNavigation = deferred(); + vi.mocked(api.browser.back).mockReturnValueOnce(backNavigation.promise); act(() => { - browserListener?.(createBrowserState(conversationId, { - canGoBack: true, - url: 'https://example.com/pending', - updatedAt: Date.now() + 1 - })) - }) - expect(back).toBeEnabled() - fireEvent.click(back) - fireEvent.click(back) - expect(api.browser.back).toHaveBeenCalledOnce() + browserListener?.( + createBrowserState(conversationId, { + canGoBack: true, + url: "https://example.com/pending", + updatedAt: Date.now() + 1, + }), + ); + }); + expect(back).toBeEnabled(); + fireEvent.click(back); + fireEvent.click(back); + expect(api.browser.back).toHaveBeenCalledOnce(); await act(async () => { - backNavigation.resolve() - await backNavigation.promise - }) - }) + backNavigation.resolve(); + await backNavigation.promise; + }); + }); - it('controls the active live browser without overwriting its address draft', async () => { - render() + it("controls the active live browser without overwriting its address draft", async () => { + render(); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '打开示例网页' } - }) - fireEvent.click(await screen.findByLabelText('发送')) - await waitFor( - () => expect(run).toHaveBeenCalledOnce(), - { timeout: 3000 } - ) - const conversationId = run.mock.calls[0]?.[0].conversationId - expect(conversationId).toBeTruthy() + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "打开示例网页" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce(), { timeout: 3000 }); + const conversationId = run.mock.calls[0]?.[0].conversationId; + expect(conversationId).toBeTruthy(); act(() => { - browserListener?.(createBrowserState(conversationId ?? '', { - canGoBack: false, - url: 'https://example.com/', - frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==', - updatedAt: Date.now() - })) - }) + browserListener?.( + createBrowserState(conversationId ?? "", { + canGoBack: false, + url: "https://example.com/", + frameDataUrl: "data:image/jpeg;base64,/9j/2Q==", + updatedAt: Date.now(), + }), + ); + }); - expect(screen.getByLabelText('助手工作栏')).toHaveClass( - 'assistant-sidebar--open' - ) - expect( - screen.getByRole('tab', { name: '浏览器' }) - ).toHaveAttribute('aria-selected', 'true') - expect( - screen.getByAltText('Agent 实时浏览器画面') - ).toHaveAttribute( - 'src', - 'data:image/jpeg;base64,/9j/2Q==' - ) - const toolbar = screen.getByRole('form', { - name: '浏览器工具栏' - }) - const address = within(toolbar).getByRole('textbox', { - name: '浏览器地址' - }) - const back = within(toolbar).getByRole('button', { name: '返回' }) - expect(address).toHaveValue('https://example.com/') - expect(back).toBeDisabled() + expect(screen.getByLabelText("助手工作栏")).toHaveClass( + "assistant-sidebar--open", + ); + expect(screen.getByRole("tab", { name: "浏览器" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByAltText("Agent 实时浏览器画面")).toHaveAttribute( + "src", + "data:image/jpeg;base64,/9j/2Q==", + ); + const toolbar = screen.getByRole("form", { + name: "浏览器工具栏", + }); + const address = within(toolbar).getByRole("textbox", { + name: "浏览器地址", + }); + const back = within(toolbar).getByRole("button", { name: "返回" }); + expect(address).toHaveValue("https://example.com/"); + expect(back).toBeDisabled(); - fireEvent.focus(address) + fireEvent.focus(address); fireEvent.change(address, { - target: { value: 'https://typed.example/draft' } - }) + target: { value: "https://typed.example/draft" }, + }); act(() => { - browserListener?.(createBrowserState(conversationId ?? '', { - canGoBack: true, - url: 'https://example.com/redirected', - updatedAt: Date.now() + 1 - })) - }) - expect(address).toHaveValue('https://typed.example/draft') - expect( - screen.getByAltText('Agent 实时浏览器画面') - ).toHaveAttribute( - 'src', - 'data:image/jpeg;base64,/9j/2Q==' - ) + browserListener?.( + createBrowserState(conversationId ?? "", { + canGoBack: true, + url: "https://example.com/redirected", + updatedAt: Date.now() + 1, + }), + ); + }); + expect(address).toHaveValue("https://typed.example/draft"); + expect(screen.getByAltText("Agent 实时浏览器画面")).toHaveAttribute( + "src", + "data:image/jpeg;base64,/9j/2Q==", + ); - fireEvent.keyDown(address, { key: 'Escape' }) - expect(address).toHaveValue('https://example.com/redirected') - expect(address).not.toHaveFocus() - expect(back).toBeEnabled() - fireEvent.click(back) + fireEvent.keyDown(address, { key: "Escape" }); + expect(address).toHaveValue("https://example.com/redirected"); + expect(address).not.toHaveFocus(); + expect(back).toBeEnabled(); + fireEvent.click(back); await waitFor(() => - expect(api.browser.back).toHaveBeenCalledWith(conversationId) - ) + expect(api.browser.back).toHaveBeenCalledWith(conversationId), + ); - fireEvent.click( - within(toolbar).getByRole('button', { name: '刷新' }) - ) + fireEvent.click(within(toolbar).getByRole("button", { name: "刷新" })); await waitFor(() => - expect(api.browser.reload).toHaveBeenCalledWith(conversationId) - ) + expect(api.browser.reload).toHaveBeenCalledWith(conversationId), + ); fireEvent.change(address, { - target: { value: 'https://example.com/manual' } - }) - fireEvent.click( - within(toolbar).getByRole('button', { name: '前往' }) - ) + target: { value: "https://example.com/manual" }, + }); + fireEvent.click(within(toolbar).getByRole("button", { name: "前往" })); await waitFor(() => expect(api.browser.navigate).toHaveBeenCalledWith( conversationId, - 'https://example.com/manual' - ) - ) - expect(address).toHaveValue('https://example.com/redirected') + "https://example.com/manual", + ), + ); + expect(address).toHaveValue("https://example.com/redirected"); act(() => { - browserListener?.(createBrowserState(conversationId ?? '', { - canGoBack: true, - url: 'https://example.com/canonical/', - updatedAt: Date.now() + 2 - })) - }) - expect(address).toHaveValue('https://example.com/canonical/') - fireEvent.focus(address) + browserListener?.( + createBrowserState(conversationId ?? "", { + canGoBack: true, + url: "https://example.com/canonical/", + updatedAt: Date.now() + 2, + }), + ); + }); + expect(address).toHaveValue("https://example.com/canonical/"); + fireEvent.focus(address); fireEvent.change(address, { - target: { value: 'https://example.com/canonical' } - }) - fireEvent.click( - within(toolbar).getByRole('button', { name: '前往' }) - ) + target: { value: "https://example.com/canonical" }, + }); + fireEvent.click(within(toolbar).getByRole("button", { name: "前往" })); await waitFor(() => expect(api.browser.navigate).toHaveBeenCalledWith( conversationId, - 'https://example.com/canonical' - ) - ) - expect(address).toHaveValue('https://example.com/canonical/') - fireEvent.click( - within(toolbar).getByRole('button', { name: '交互' }) - ) + "https://example.com/canonical", + ), + ); + expect(address).toHaveValue("https://example.com/canonical/"); + fireEvent.click(within(toolbar).getByRole("button", { name: "交互" })); await waitFor(() => - expect(api.browser.interact).toHaveBeenCalledWith(conversationId) - ) + expect(api.browser.interact).toHaveBeenCalledWith(conversationId), + ); act(() => { - browserListener?.(createBrowserState(conversationId ?? '', { - status: 'loading', - isLoading: false, - canGoBack: true, - url: 'https://example.com/canonical/', - updatedAt: Date.now() + 3 - })) - }) - expect(back).toBeDisabled() + browserListener?.( + createBrowserState(conversationId ?? "", { + status: "loading", + isLoading: false, + canGoBack: true, + url: "https://example.com/canonical/", + updatedAt: Date.now() + 3, + }), + ); + }); + expect(back).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '刷新' }) - ).toBeDisabled() - expect(address).toBeDisabled() + within(toolbar).getByRole("button", { name: "刷新" }), + ).toBeDisabled(); + expect(address).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '交互' }) - ).toBeDisabled() + within(toolbar).getByRole("button", { name: "交互" }), + ).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '关闭浏览器' }) - ).toBeDisabled() + within(toolbar).getByRole("button", { name: "关闭浏览器" }), + ).toBeDisabled(); act(() => { - browserListener?.(createBrowserState(conversationId ?? '', { - status: 'acting', - canGoBack: true, - url: 'https://example.com/canonical/', - updatedAt: Date.now() + 4 - })) - }) - expect(back).toBeDisabled() + browserListener?.( + createBrowserState(conversationId ?? "", { + status: "acting", + canGoBack: true, + url: "https://example.com/canonical/", + updatedAt: Date.now() + 4, + }), + ); + }); + expect(back).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '刷新' }) - ).toBeDisabled() - expect(address).toBeDisabled() + within(toolbar).getByRole("button", { name: "刷新" }), + ).toBeDisabled(); + expect(address).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '前往' }) - ).toBeDisabled() + within(toolbar).getByRole("button", { name: "前往" }), + ).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '交互' }) - ).toBeDisabled() + within(toolbar).getByRole("button", { name: "交互" }), + ).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '关闭浏览器' }) - ).toBeDisabled() + within(toolbar).getByRole("button", { name: "关闭浏览器" }), + ).toBeDisabled(); act(() => { - browserListener?.(createBrowserState(conversationId ?? '', { - status: 'acting', - isLoading: true, - canGoBack: true, - url: 'https://example.com/canonical/', - updatedAt: Date.now() + 5 - })) - }) + browserListener?.( + createBrowserState(conversationId ?? "", { + status: "acting", + isLoading: true, + canGoBack: true, + url: "https://example.com/canonical/", + updatedAt: Date.now() + 5, + }), + ); + }); expect( - within(toolbar).queryByRole('button', { name: '刷新' }) - ).not.toBeInTheDocument() - const stopLoading = within(toolbar).getByRole('button', { - name: '停止加载' - }) - expect(stopLoading).toBeEnabled() + within(toolbar).queryByRole("button", { name: "刷新" }), + ).not.toBeInTheDocument(); + const stopLoading = within(toolbar).getByRole("button", { + name: "停止加载", + }); + expect(stopLoading).toBeEnabled(); expect( - within(toolbar).getByRole('button', { name: '关闭浏览器' }) - ).toBeDisabled() - fireEvent.click(stopLoading) + within(toolbar).getByRole("button", { name: "关闭浏览器" }), + ).toBeDisabled(); + fireEvent.click(stopLoading); await waitFor(() => - expect(api.browser.stopLoading).toHaveBeenCalledWith( - conversationId - ) - ) + expect(api.browser.stopLoading).toHaveBeenCalledWith(conversationId), + ); act(() => { - browserListener?.(createBrowserState(conversationId ?? '', { - status: 'interactive', - canGoBack: true, - url: 'https://example.com/canonical/', - updatedAt: Date.now() + 6 - })) - }) - expect(back).toBeDisabled() + browserListener?.( + createBrowserState(conversationId ?? "", { + status: "interactive", + canGoBack: true, + url: "https://example.com/canonical/", + updatedAt: Date.now() + 6, + }), + ); + }); + expect(back).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '刷新' }) - ).toBeDisabled() - expect(address).toBeDisabled() + within(toolbar).getByRole("button", { name: "刷新" }), + ).toBeDisabled(); + expect(address).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '交互' }) - ).toBeDisabled() + within(toolbar).getByRole("button", { name: "交互" }), + ).toBeDisabled(); expect( - within(toolbar).getByRole('button', { name: '关闭浏览器' }) - ).toBeDisabled() + within(toolbar).getByRole("button", { name: "关闭浏览器" }), + ).toBeDisabled(); act(() => { - browserListener?.(createBrowserState(conversationId ?? '', { - canGoBack: true, - url: 'https://example.com/canonical/', - updatedAt: Date.now() + 7 - })) - }) + browserListener?.( + createBrowserState(conversationId ?? "", { + canGoBack: true, + url: "https://example.com/canonical/", + updatedAt: Date.now() + 7, + }), + ); + }); fireEvent.click( - within(toolbar).getByRole('button', { name: '关闭浏览器' }) - ) + within(toolbar).getByRole("button", { name: "关闭浏览器" }), + ); await waitFor(() => - expect(api.browser.stop).toHaveBeenCalledWith(conversationId) - ) - }) + expect(api.browser.stop).toHaveBeenCalledWith(conversationId), + ); + }); - it('opens Smart Heartbeat as a first-class workspace', async () => { - render() + it("opens Smart Heartbeat as a first-class workspace", async () => { + render(); - fireEvent.click( - screen.getByRole('button', { name: '智能心跳' }) - ) + fireEvent.click(screen.getByRole("button", { name: "智能心跳" })); expect( - await screen.findByRole('heading', { name: '智能心跳' }) - ).toBeInTheDocument() + await screen.findByRole("heading", { name: "智能心跳" }), + ).toBeInTheDocument(); expect( - await screen.findByRole('tab', { name: '运行概览' }) - ).toBeInTheDocument() + await screen.findByRole("tab", { name: "运行概览" }), + ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: '配置智能心跳' }) - ).toBeInTheDocument() - expect( - screen.queryByLabelText('切换助手工作栏') - ).toBeInTheDocument() - }) + screen.getByRole("button", { name: "配置智能心跳" }), + ).toBeInTheDocument(); + expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument(); + }); - it('excludes ignored heartbeat suggestions from the navigation badge', async () => { - const heartbeatId = '00000000-0000-4000-8000-000000000701' - const cancelledTaskId = '00000000-0000-4000-8000-000000000801' - const pendingTaskId = '00000000-0000-4000-8000-000000000802' + it("excludes ignored heartbeat suggestions from the navigation badge", async () => { + const heartbeatId = "00000000-0000-4000-8000-000000000701"; + const cancelledTaskId = "00000000-0000-4000-8000-000000000801"; + const pendingTaskId = "00000000-0000-4000-8000-000000000802"; vi.mocked(api.heartbeats.list).mockResolvedValue([ { id: heartbeatId, - scope: { kind: 'projects', projectIds: [projectId] }, - name: '每日回顾', - timezone: 'Asia/Shanghai', - recurrence: { type: 'daily', localTime: '09:00' }, + scope: { kind: "projects", projectIds: [projectId] }, + name: "每日回顾", + timezone: "Asia/Shanghai", + recurrence: { type: "daily", localTime: "09:00" }, enabled: true, lookbackHours: 24, retentionDays: 30, - nextRunAt: '2026-08-05T01:00:00.000Z', - createdAt: '2026-08-01T00:00:00.000Z', - updatedAt: '2026-08-01T00:00:00.000Z' - } - ]) + nextRunAt: "2026-08-05T01:00:00.000Z", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + ]); vi.mocked(api.heartbeats.history).mockResolvedValue({ runs: [], entries: [ { - id: '00000000-0000-4000-8000-000000000901', + id: "00000000-0000-4000-8000-000000000901", configId: heartbeatId, - runId: '00000000-0000-4000-8000-000000000902', - scheduledFor: '2026-08-04T01:00:00.000Z', - summary: '建议处理两个后续行动。', + runId: "00000000-0000-4000-8000-000000000902", + scheduledFor: "2026-08-04T01:00:00.000Z", + summary: "建议处理两个后续行动。", highlights: [], proposedMemoryIds: [], followUpTaskIds: [cancelledTaskId, pendingTaskId], - createdAt: '2026-08-04T01:00:00.000Z' - } - ] - }) + createdAt: "2026-08-04T01:00:00.000Z", + }, + ], + }); vi.mocked(api.tasks.list).mockResolvedValue([ { id: cancelledTaskId, projectId, - title: '已忽略建议', - instructions: '无需继续处理。', - origin: 'assistant', - status: 'cancelled', - createdAt: '2026-08-04T01:00:00.000Z' + title: "已忽略建议", + instructions: "无需继续处理。", + origin: "assistant", + status: "cancelled", + createdAt: "2026-08-04T01:00:00.000Z", }, { id: pendingTaskId, projectId, - title: '待处理建议', - instructions: '继续处理此建议。', - origin: 'assistant', - status: 'paused', - createdAt: '2026-08-04T01:00:00.000Z' - } - ]) + title: "待处理建议", + instructions: "继续处理此建议。", + origin: "assistant", + status: "paused", + createdAt: "2026-08-04T01:00:00.000Z", + }, + ]); try { - render() + render(); expect( - await screen.findByLabelText('1 条待处理建议') - ).toBeInTheDocument() - expect( - screen.queryByLabelText('2 条待处理建议') - ).not.toBeInTheDocument() + await screen.findByLabelText("1 条待处理建议"), + ).toBeInTheDocument(); + expect(screen.queryByLabelText("2 条待处理建议")).not.toBeInTheDocument(); } finally { - cleanup() - vi.mocked(api.heartbeats.list).mockResolvedValue([]) + cleanup(); + vi.mocked(api.heartbeats.list).mockResolvedValue([]); vi.mocked(api.heartbeats.history).mockResolvedValue({ runs: [], - entries: [] - }) - vi.mocked(api.tasks.list).mockResolvedValue([]) + entries: [], + }); + vi.mocked(api.tasks.list).mockResolvedValue([]); } - }) + }); - it('shows retryable page-local knowledge errors without an empty-state flash', async () => { + it("shows retryable page-local knowledge errors without an empty-state flash", async () => { vi.mocked(api.knowledge.getSnapshot).mockRejectedValueOnce( - new Error('知识数据库暂时不可用') - ) - render() + new Error("知识数据库暂时不可用"), + ); + render(); - fireEvent.click(screen.getByRole('button', { name: '知识库' })) - expect( - await screen.findByText('知识库加载失败') - ).toBeInTheDocument() - expect(screen.getAllByText('知识数据库暂时不可用')).toHaveLength(1) - expect(screen.queryByText('建立第一个知识库')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "知识库" })); + expect(await screen.findByText("知识库加载失败")).toBeInTheDocument(); + expect(screen.getAllByText("知识数据库暂时不可用")).toHaveLength(1); + expect(screen.queryByText("建立第一个知识库")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '重试' })) - expect( - await screen.findByText('建立第一个知识库') - ).toBeInTheDocument() - }) + fireEvent.click(screen.getByRole("button", { name: "重试" })); + expect(await screen.findByText("建立第一个知识库")).toBeInTheDocument(); + }); - it('retries the knowledge library whose selection failed', async () => { - const firstLibraryId = '11111111-1111-4111-8111-111111111111' - const secondLibraryId = '22222222-2222-4222-8222-222222222222' + it("retries the knowledge library whose selection failed", async () => { + const firstLibraryId = "11111111-1111-4111-8111-111111111111"; + const secondLibraryId = "22222222-2222-4222-8222-222222222222"; const libraries = [ { id: firstLibraryId, - name: '产品知识', - description: '', - storageMode: 'managed' as const, + name: "产品知识", + description: "", + storageMode: "managed" as const, graphEnabled: false, - graphStrategy: 'rules' as const, + graphStrategy: "rules" as const, sourceCount: 0, documentCount: 0, - indexedDocumentCount: 0 + indexedDocumentCount: 0, }, { id: secondLibraryId, - name: '工程知识', - description: '', - storageMode: 'managed' as const, + name: "工程知识", + description: "", + storageMode: "managed" as const, graphEnabled: false, - graphStrategy: 'rules' as const, + graphStrategy: "rules" as const, sourceCount: 0, documentCount: 0, - indexedDocumentCount: 0 - } - ] + indexedDocumentCount: 0, + }, + ]; const snapshot = { libraries, sources: [], documents: [], graphNodes: [], graphRelations: [], - evidence: [] - } + evidence: [], + }; vi.mocked(api.knowledge.getSnapshot) .mockResolvedValueOnce({ ...snapshot, - selectedLibraryId: firstLibraryId + selectedLibraryId: firstLibraryId, }) - .mockRejectedValueOnce(new Error('工程知识暂时不可用')) + .mockRejectedValueOnce(new Error("工程知识暂时不可用")) .mockResolvedValueOnce({ ...snapshot, - selectedLibraryId: secondLibraryId - }) - render() + selectedLibraryId: secondLibraryId, + }); + render(); - fireEvent.click(screen.getByRole('button', { name: '知识库' })) + fireEvent.click(screen.getByRole("button", { name: "知识库" })); fireEvent.click( - await screen.findByRole('button', { - name: /^工程知识/u - }, { timeout: 5_000 }) - ) - expect( - await screen.findByText('知识库刷新失败') - ).toBeInTheDocument() + await screen.findByRole( + "button", + { + name: /^工程知识/u, + }, + { timeout: 5_000 }, + ), + ); + expect(await screen.findByText("知识库刷新失败")).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '重试' })) + fireEvent.click(screen.getByRole("button", { name: "重试" })); await waitFor(() => expect(api.knowledge.getSnapshot).toHaveBeenLastCalledWith( - secondLibraryId - ) - ) + secondLibraryId, + ), + ); expect( - screen.getByRole('button', { - name: /^工程知识/u - }) - ).toHaveAttribute('aria-current', 'page') - }) + screen.getByRole("button", { + name: /^工程知识/u, + }), + ).toHaveAttribute("aria-current", "page"); + }); - it('shows retryable page-local heartbeat errors without first-time guidance', async () => { + it("shows retryable page-local heartbeat errors without first-time guidance", async () => { vi.mocked(api.heartbeats.list).mockRejectedValue( - new Error('心跳数据库暂时不可用') - ) - render() + new Error("心跳数据库暂时不可用"), + ); + render(); - fireEvent.click( - screen.getByRole('button', { name: '智能心跳' }) - ) + fireEvent.click(screen.getByRole("button", { name: "智能心跳" })); + expect(await screen.findByText("智能心跳加载失败")).toBeInTheDocument(); expect( - await screen.findByText('智能心跳加载失败') - ).toBeInTheDocument() - expect( - screen.queryByRole('button', { name: '配置智能心跳' }) - ).not.toBeInTheDocument() + screen.queryByRole("button", { name: "配置智能心跳" }), + ).not.toBeInTheDocument(); - const retry = await screen.findByRole('button', { name: '重试' }) - vi.mocked(api.heartbeats.list).mockResolvedValue([]) - fireEvent.click(retry) + const retry = await screen.findByRole("button", { name: "重试" }); + vi.mocked(api.heartbeats.list).mockResolvedValue([]); + fireEvent.click(retry); expect( - await screen.findByRole('button', { name: '配置智能心跳' }) - ).toBeInTheDocument() - }) + await screen.findByRole("button", { name: "配置智能心跳" }), + ).toBeInTheDocument(); + }); - it('keeps heartbeat plans independent of the active project', async () => { + it("keeps heartbeat plans independent of the active project", async () => { const secondProject = { ...project, - id: '00000000-0000-4000-8000-000000000102', - name: '第二项目', - rootPath: 'C:\\Second', + id: "00000000-0000-4000-8000-000000000102", + name: "第二项目", + rootPath: "C:\\Second", executionSpace: { - kind: 'local' as const, - rootPath: 'C:\\Second' - } - } + kind: "local" as const, + rootPath: "C:\\Second", + }, + }; vi.mocked(api.projects.list).mockResolvedValueOnce([ project, - secondProject - ]) + secondProject, + ]); vi.mocked(api.heartbeats.list).mockResolvedValue([ { - id: '00000000-0000-4000-8000-000000000701', - scope: { kind: 'projects', projectIds: [projectId] }, - name: '旧项目心跳', - timezone: 'Asia/Shanghai', - recurrence: { type: 'daily', localTime: '09:00' }, + id: "00000000-0000-4000-8000-000000000701", + scope: { kind: "projects", projectIds: [projectId] }, + name: "旧项目心跳", + timezone: "Asia/Shanghai", + recurrence: { type: "daily", localTime: "09:00" }, enabled: true, lookbackHours: 24, retentionDays: 30, - nextRunAt: '2026-08-05T01:00:00.000Z', - createdAt: '2026-08-01T00:00:00.000Z', - updatedAt: '2026-08-01T00:00:00.000Z' - } - ]) - render() + nextRunAt: "2026-08-05T01:00:00.000Z", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + ]); + render(); - fireEvent.click( - screen.getByRole('button', { name: '智能心跳' }) - ) - fireEvent.click( - await screen.findByRole('tab', { name: '心跳计划' }) - ) - expect(await screen.findAllByText('旧项目心跳')).not.toHaveLength(0) - selectProjectOption(secondProject.name) - fireEvent.click( - screen.getByRole('button', { name: '智能心跳' }) - ) + fireEvent.click(screen.getByRole("button", { name: "智能心跳" })); + fireEvent.click(await screen.findByRole("tab", { name: "心跳计划" })); + expect(await screen.findAllByText("旧项目心跳")).not.toHaveLength(0); + selectProjectOption(secondProject.name); + fireEvent.click(screen.getByRole("button", { name: "智能心跳" })); - expect(await screen.findAllByText('旧项目心跳')).not.toHaveLength(0) - }) + expect(await screen.findAllByText("旧项目心跳")).not.toHaveLength(0); + }); - it('automatically snapshots the conversation project on new activity', async () => { - render() - await screen.findByText('项目:默认项目') + it("automatically snapshots the conversation project on new activity", async () => { + render(); + await screen.findByText("项目:默认项目"); - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '记录项目范围' } - }) - fireEvent.click(screen.getByLabelText('发送')) + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "记录项目范围" }, + }); + fireEvent.click(screen.getByLabelText("发送")); await waitFor(() => expect( vi @@ -11030,651 +10627,621 @@ describe('App', () => { .mock.calls.at(-1)?.[0] .find( (record) => - record.kind === 'request' && - record.title === '记录项目范围' - )?.scope + record.kind === "request" && record.title === "记录项目范围", + )?.scope, ).toEqual({ - kind: 'project', + kind: "project", projectId, - projectName: project.name - }) - ) - }) + projectName: project.name, + }), + ); + }); - it('migrates legacy activity and keeps its possible-loss warning visible', async () => { + it("migrates legacy activity and keeps its possible-loss warning visible", async () => { localStorage.setItem( ACTIVITY_STORAGE_KEY, JSON.stringify([ { - id: 'legacy-activity', - conversationId: 'legacy-conversation', - requestId: 'legacy-request', - scope: { kind: 'global' }, - kind: 'tool', - title: '旧版工具记录', - detail: 'x'.repeat(4_000), - status: 'completed', - createdAt: 1 - } - ]) - ) + id: "legacy-activity", + conversationId: "legacy-conversation", + requestId: "legacy-request", + scope: { kind: "global" }, + kind: "tool", + title: "旧版工具记录", + detail: "x".repeat(4_000), + status: "completed", + createdAt: 1, + }, + ]), + ); - render() - await screen.findByText('项目:默认项目') + render(); + await screen.findByText("项目:默认项目"); await waitFor(() => { expect(api.activityHistory.replace).toHaveBeenCalledWith( [ expect.objectContaining({ - id: 'legacy-activity', - detail: 'x'.repeat(4_000) - }) + id: "legacy-activity", + detail: "x".repeat(4_000), + }), ], - true - ) - expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull() - }) + true, + ); + expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull(); + }); - fireEvent.click(screen.getByRole('button', { name: '运行记录' })) + fireEvent.click(screen.getByRole("button", { name: "运行记录" })); expect( await screen.findByText( - '旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。', + "旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。", {}, - { timeout: 3_000 } - ) - ).toBeInTheDocument() - }) + { timeout: 3_000 }, + ), + ).toBeInTheDocument(); + }); - it('marks the current primary navigation page and hides decorative icons', async () => { - render() + it("marks the current primary navigation page and hides decorative icons", async () => { + render(); - const navigation = screen.getByRole('navigation', { - name: '主导航' - }) - const chat = within(navigation).getByRole('button', { name: '对话' }) - const knowledge = within(navigation).getByRole('button', { - name: '知识库' - }) + const navigation = screen.getByRole("navigation", { + name: "主导航", + }); + const chat = within(navigation).getByRole("button", { name: "对话" }); + const knowledge = within(navigation).getByRole("button", { + name: "知识库", + }); - expect(chat).toHaveAttribute('aria-current', 'page') - expect(knowledge).not.toHaveAttribute('aria-current') - for (const button of within(navigation).getAllByRole('button')) { - expect(button.querySelector('svg')).toHaveAttribute( - 'aria-hidden', - 'true' - ) + expect(chat).toHaveAttribute("aria-current", "page"); + expect(knowledge).not.toHaveAttribute("aria-current"); + for (const button of within(navigation).getAllByRole("button")) { + expect(button.querySelector("svg")).toHaveAttribute( + "aria-hidden", + "true", + ); } - fireEvent.click(knowledge) - expect(knowledge).toHaveAttribute('aria-current', 'page') - expect(chat).not.toHaveAttribute('aria-current') - }) + fireEvent.click(knowledge); + expect(knowledge).toHaveAttribute("aria-current", "page"); + expect(chat).not.toHaveAttribute("aria-current"); + }); - it('collapses the primary sidebar into an inert narrow-window overlay', async () => { - const originalWidth = window.innerWidth - const { container } = render() - const sidebar = container.querySelector('.sidebar') - const workspace = container.querySelector('.workspace') - expect(sidebar).not.toBeNull() - expect(workspace).not.toBeNull() + it("collapses the primary sidebar into an inert narrow-window overlay", async () => { + const originalWidth = window.innerWidth; + const { container } = render(); + const sidebar = container.querySelector(".sidebar"); + const workspace = container.querySelector(".workspace"); + expect(sidebar).not.toBeNull(); + expect(workspace).not.toBeNull(); if (!sidebar || !workspace) { - return + return; } try { - Object.defineProperty(window, 'innerWidth', { + Object.defineProperty(window, "innerWidth", { configurable: true, - value: 680 - }) - window.dispatchEvent(new Event('resize')) + value: 680, + }); + window.dispatchEvent(new Event("resize")); - await waitFor(() => - expect(sidebar).toHaveClass('sidebar--closed') - ) - expect(sidebar).toHaveAttribute('aria-hidden', 'true') - expect(sidebar).toHaveAttribute('inert') + await waitFor(() => expect(sidebar).toHaveClass("sidebar--closed")); + expect(sidebar).toHaveAttribute("aria-hidden", "true"); + expect(sidebar).toHaveAttribute("inert"); - const toggle = screen.getByRole('button', { name: '切换侧栏' }) - fireEvent.click(toggle) - expect(sidebar).not.toHaveClass('sidebar--closed') - expect(sidebar).toHaveAttribute('aria-hidden', 'false') - expect(sidebar).toHaveAttribute('aria-modal', 'true') - expect(sidebar).not.toHaveAttribute('inert') - expect(workspace).toHaveAttribute('aria-hidden', 'true') - expect(workspace).toHaveAttribute('inert') + const toggle = screen.getByRole("button", { name: "切换侧栏" }); + fireEvent.click(toggle); + expect(sidebar).not.toHaveClass("sidebar--closed"); + expect(sidebar).toHaveAttribute("aria-hidden", "false"); + expect(sidebar).toHaveAttribute("aria-modal", "true"); + expect(sidebar).not.toHaveAttribute("inert"); + expect(workspace).toHaveAttribute("aria-hidden", "true"); + expect(workspace).toHaveAttribute("inert"); await waitFor(() => expect( - within(sidebar).getByRole('button', { name: '对话' }) - ).toHaveFocus() - ) + within(sidebar).getByRole("button", { name: "对话" }), + ).toHaveFocus(), + ); - fireEvent.click( - within(sidebar).getByRole('button', { name: '知识库' }) - ) - await waitFor(() => expect(toggle).toHaveFocus()) - expect(sidebar).toHaveClass('sidebar--closed') - expect(workspace).not.toHaveAttribute('aria-hidden') - expect(workspace).not.toHaveAttribute('inert') + fireEvent.click(within(sidebar).getByRole("button", { name: "知识库" })); + await waitFor(() => expect(toggle).toHaveFocus()); + expect(sidebar).toHaveClass("sidebar--closed"); + expect(workspace).not.toHaveAttribute("aria-hidden"); + expect(workspace).not.toHaveAttribute("inert"); } finally { - Object.defineProperty(window, 'innerWidth', { + Object.defineProperty(window, "innerWidth", { configurable: true, - value: originalWidth - }) + value: originalWidth, + }); } - }) + }); - it('resizes and remembers the docked primary sidebar width', () => { - const originalWidth = window.innerWidth - Object.defineProperty(window, 'innerWidth', { + it("resizes and remembers the docked primary sidebar width", () => { + const originalWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { configurable: true, - value: 1200 - }) - const { container, unmount } = render() + value: 1200, + }); + const { container, unmount } = render(); try { - const sidebar = - container.querySelector('.sidebar') - const separator = screen.getByRole('separator', { - name: '调整主侧栏宽度' - }) - const setPointerCapture = vi.fn() - const releasePointerCapture = vi.fn() + const sidebar = container.querySelector(".sidebar"); + const separator = screen.getByRole("separator", { + name: "调整主侧栏宽度", + }); + const setPointerCapture = vi.fn(); + const releasePointerCapture = vi.fn(); Object.defineProperties(separator, { setPointerCapture: { value: setPointerCapture }, hasPointerCapture: { value: () => true }, - releasePointerCapture: { value: releasePointerCapture } - }) + releasePointerCapture: { value: releasePointerCapture }, + }); - expect( - sidebar?.style.getPropertyValue('--primary-sidebar-width') - ).toBe('278px') - expect(separator).toHaveAttribute('aria-valuemin', '220') - expect(separator).toHaveAttribute('aria-valuemax', '420') + expect(sidebar?.style.getPropertyValue("--primary-sidebar-width")).toBe( + "278px", + ); + expect(separator).toHaveAttribute("aria-valuemin", "220"); + expect(separator).toHaveAttribute("aria-valuemax", "420"); fireEvent.pointerDown(separator, { button: 0, clientX: 320, - pointerId: 17 - }) + pointerId: 17, + }); fireEvent.pointerMove(separator, { clientX: 400, - pointerId: 17 - }) + pointerId: 17, + }); - expect(setPointerCapture).toHaveBeenCalledWith(17) - expect(sidebar).toHaveClass('sidebar--resizing') - expect( - sidebar?.style.getPropertyValue('--primary-sidebar-width') - ).toBe('400px') + expect(setPointerCapture).toHaveBeenCalledWith(17); + expect(sidebar).toHaveClass("sidebar--resizing"); + expect(sidebar?.style.getPropertyValue("--primary-sidebar-width")).toBe( + "400px", + ); - fireEvent.pointerUp(separator, { pointerId: 17 }) - expect(releasePointerCapture).toHaveBeenCalledWith(17) - expect(sidebar).not.toHaveClass('sidebar--resizing') - expect( - localStorage.getItem('goodbuddy.primary-sidebar-width.v1') - ).toBe('400') + fireEvent.pointerUp(separator, { pointerId: 17 }); + expect(releasePointerCapture).toHaveBeenCalledWith(17); + expect(sidebar).not.toHaveClass("sidebar--resizing"); + expect(localStorage.getItem("goodbuddy.primary-sidebar-width.v1")).toBe( + "400", + ); - fireEvent.keyDown(separator, { key: 'Home' }) - expect( - sidebar?.style.getPropertyValue('--primary-sidebar-width') - ).toBe('220px') + fireEvent.keyDown(separator, { key: "Home" }); + expect(sidebar?.style.getPropertyValue("--primary-sidebar-width")).toBe( + "220px", + ); - fireEvent.keyDown(separator, { key: 'ArrowRight' }) - expect( - sidebar?.style.getPropertyValue('--primary-sidebar-width') - ).toBe('236px') + fireEvent.keyDown(separator, { key: "ArrowRight" }); + expect(sidebar?.style.getPropertyValue("--primary-sidebar-width")).toBe( + "236px", + ); - fireEvent.keyDown(separator, { key: 'End' }) - expect( - sidebar?.style.getPropertyValue('--primary-sidebar-width') - ).toBe('420px') - expect(separator).toHaveAttribute('aria-valuenow', '420') + fireEvent.keyDown(separator, { key: "End" }); + expect(sidebar?.style.getPropertyValue("--primary-sidebar-width")).toBe( + "420px", + ); + expect(separator).toHaveAttribute("aria-valuenow", "420"); - unmount() - const persistedRender = render() + unmount(); + const persistedRender = render(); expect( persistedRender.container - .querySelector('.sidebar') - ?.style.getPropertyValue('--primary-sidebar-width') - ).toBe('420px') + .querySelector(".sidebar") + ?.style.getPropertyValue("--primary-sidebar-width"), + ).toBe("420px"); } finally { - Object.defineProperty(window, 'innerWidth', { + Object.defineProperty(window, "innerWidth", { configurable: true, - value: originalWidth - }) + value: originalWidth, + }); } - }) + }); - it('docks the assistant workbar without obscuring a narrow workspace', async () => { - const originalWidth = window.innerWidth - const { container } = render() + it("docks the assistant workbar without obscuring a narrow workspace", async () => { + const originalWidth = window.innerWidth; + const { container } = render(); try { - Object.defineProperty(window, 'innerWidth', { + Object.defineProperty(window, "innerWidth", { configurable: true, - value: 680 - }) - window.dispatchEvent(new Event('resize')) + value: 680, + }); + window.dispatchEvent(new Event("resize")); await waitFor(() => - expect(container.querySelector('.sidebar')) - .toHaveClass('sidebar--closed') - ) - fireEvent.click(screen.getByLabelText('切换助手工作栏')) + expect(container.querySelector(".sidebar")).toHaveClass( + "sidebar--closed", + ), + ); + fireEvent.click(screen.getByLabelText("切换助手工作栏")); - const workbar = screen.getByRole('complementary', { - name: '助手工作栏' - }) - expect(workbar).toHaveClass('assistant-sidebar--open') - expect(workbar.parentElement).toHaveClass('app-content') + const workbar = screen.getByRole("complementary", { + name: "助手工作栏", + }); + expect(workbar).toHaveClass("assistant-sidebar--open"); + expect(workbar.parentElement).toHaveClass("app-content"); expect( - workbar.parentElement?.querySelector('main.workspace') - ).toBeInTheDocument() - expect(workbar).not.toHaveAttribute('aria-modal') - expect(container.querySelector('main')).not.toHaveAttribute( - 'inert' - ) + workbar.parentElement?.querySelector("main.workspace"), + ).toBeInTheDocument(); + expect(workbar).not.toHaveAttribute("aria-modal"); + expect(container.querySelector("main")).not.toHaveAttribute("inert"); expect( - container.querySelector('.assistant-sidebar-backdrop') - ).not.toBeInTheDocument() + container.querySelector(".assistant-sidebar-backdrop"), + ).not.toBeInTheDocument(); } finally { - Object.defineProperty(window, 'innerWidth', { + Object.defineProperty(window, "innerWidth", { configurable: true, - value: originalWidth - }) + value: originalWidth, + }); } - }) + }); - it('opens Magic Notes as a global first-class workspace', async () => { + it("opens Magic Notes as a global first-class workspace", async () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), check: vi.fn(), openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; try { - render() - await screen.findByText('项目:默认项目') - const magicNotesEntry = await screen.findByRole('button', { - name: '魔法笔记' - }) + render(); + await screen.findByText("项目:默认项目"); + const magicNotesEntry = await screen.findByRole("button", { + name: "魔法笔记", + }); - fireEvent.click(magicNotesEntry) + fireEvent.click(magicNotesEntry); expect( await screen.findByRole( - 'heading', - { name: '魔法笔记' }, - { timeout: 3000 } - ) - ).toBeInTheDocument() - expect(screen.getByText('全局')).toBeInTheDocument() + "heading", + { name: "魔法笔记" }, + { timeout: 3000 }, + ), + ).toBeInTheDocument(); + expect(screen.getByText("全局")).toBeInTheDocument(); expect( - screen.getByRole('button', { name: '新建笔记' }) - ).toBeInTheDocument() - await waitFor(() => - expect(api.magicNotes.list).toHaveBeenCalled() - ) - expect( - screen.queryByLabelText('切换助手工作栏') - ).toBeInTheDocument() + screen.getByRole("button", { name: "新建笔记" }), + ).toBeInTheDocument(); + await waitFor(() => expect(api.magicNotes.list).toHaveBeenCalled()); + expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument(); } finally { - delete api.updates + delete api.updates; } - }) + }); - it('shows and refreshes the incomplete Magic Notes todo count', async () => { + it("shows and refreshes the incomplete Magic Notes todo count", async () => { vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({ - incompleteCount: 4 - }) + incompleteCount: 4, + }); api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), updateSettings: vi.fn(), check: vi.fn(), openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; try { - render() + render(); - expect( - await screen.findByLabelText('4 个未完成待办') - ).toHaveTextContent('4') + expect(await screen.findByLabelText("4 个未完成待办")).toHaveTextContent( + "4", + ); vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({ - incompleteCount: 0 - }) + incompleteCount: 0, + }); await act(async () => { - magicTodoStatusChangedListener?.() - }) + magicTodoStatusChangedListener?.(); + }); await waitFor(() => expect( - screen.queryByLabelText('4 个未完成待办') - ).not.toBeInTheDocument() - ) + screen.queryByLabelText("4 个未完成待办"), + ).not.toBeInTheDocument(), + ); vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({ - incompleteCount: 125 - }) + incompleteCount: 125, + }); await act(async () => { - magicTodoStatusChangedListener?.() - }) + magicTodoStatusChangedListener?.(); + }); expect( - await screen.findByLabelText('125 个未完成待办') - ).toHaveTextContent('99+') + await screen.findByLabelText("125 个未完成待办"), + ).toHaveTextContent("99+"); } finally { - delete api.updates + delete api.updates; } - }) + }); - it('hides the todo count when its Magic Notes setting is off', async () => { + it("hides the todo count when its Magic Notes setting is off", async () => { vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValue({ - incompleteCount: 4 - }) + incompleteCount: 4, + }); api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: false, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), updateSettings: vi.fn(), check: vi.fn(), openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; try { - render() + render(); expect( - await screen.findByRole('button', { name: '魔法笔记' }) - ).toBeInTheDocument() - expect( - screen.queryByLabelText('4 个未完成待办') - ).not.toBeInTheDocument() - expect(api.magicNotes.getTodoStatus).not.toHaveBeenCalled() + await screen.findByRole("button", { name: "魔法笔记" }), + ).toBeInTheDocument(); + expect(screen.queryByLabelText("4 个未完成待办")).not.toBeInTheDocument(); + expect(api.magicNotes.getTodoStatus).not.toHaveBeenCalled(); } finally { - delete api.updates + delete api.updates; } - }) + }); - it('hides Magic Notes when the platform feature is disabled', async () => { + it("hides Magic Notes when the platform feature is disabled", async () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, - updateSource: 'github' as const, - modelDownloadSource: 'modelscope' as const, + updateSource: "github" as const, + modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate' as const, - magicNoteCommentFormat: 'combined' as const + magicNoteCommentMode: "immediate" as const, + magicNoteCommentFormat: "combined" as const, })), check: vi.fn(), openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; try { - render() + render(); await waitFor(() => expect( - screen.queryByRole('button', { name: '魔法笔记' }) - ).not.toBeInTheDocument() - ) + screen.queryByRole("button", { name: "魔法笔记" }), + ).not.toBeInTheDocument(), + ); } finally { - delete api.updates + delete api.updates; } - }) + }); - it('hides Magic Notes by default without an explicit setting', () => { - render() + it("hides Magic Notes by default without an explicit setting", () => { + render(); expect( - screen.queryByRole('button', { name: '魔法笔记' }) - ).not.toBeInTheDocument() - }) + screen.queryByRole("button", { name: "魔法笔记" }), + ).not.toBeInTheDocument(); + }); - it('renders Agent HTML by default and applies the platform switch immediately', async () => { - const conversationId = - '00000000-0000-4000-8000-000000000471' + it("renders Agent HTML by default and applies the platform switch immediately", async () => { + const conversationId = "00000000-0000-4000-8000-000000000471"; vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: conversationId, projectId, - title: 'HTML 预览会话', + title: "HTML 预览会话", updatedAt: 1_775_000_000_000, messages: [ { - id: '00000000-0000-4000-8000-000000000472', - role: 'assistant', + id: "00000000-0000-4000-8000-000000000472", + role: "assistant", content: `\`\`\`html

Rendered dashboard

\`\`\``, createdAt: 1_775_000_000_000, - state: 'complete' - } - ] - } - ]) + state: "complete", + }, + ], + }, + ]); let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: false, - updateSource: 'github', - modelDownloadSource: 'modelscope', + updateSource: "github", + modelDownloadSource: "modelscope", localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate', - magicNoteCommentFormat: 'combined' - } + magicNoteCommentMode: "immediate", + magicNoteCommentFormat: "combined", + }; const updateSettings = vi.fn< - NonNullable['updateSettings'] + NonNullable["updateSettings"] >(async (input) => { - applicationSettings = { ...applicationSettings, ...input } - return { ...applicationSettings } - }) + applicationSettings = { ...applicationSettings, ...input }; + return { ...applicationSettings }; + }); api.updates = { getSettings: vi.fn(async () => ({ ...applicationSettings })), updateSettings, check: vi.fn(), openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; - render() + render(); expect( - await screen.findByTitle('Agent 回复 HTML 静态预览') - ).toHaveAttribute('sandbox', '') + await screen.findByTitle("Agent 回复 HTML 静态预览"), + ).toHaveAttribute("sandbox", ""); - fireEvent.click( - await screen.findByRole('button', { name: /本地工作区/u }) - ) - fireEvent.click( - await screen.findByRole('tab', { name: '平台功能' }) - ) - const toggle = await screen.findByRole('switch', { - name: '在会话中渲染 HTML' - }) - expect(toggle).toBeChecked() - fireEvent.click(toggle) + fireEvent.click(await screen.findByRole("button", { name: /本地工作区/u })); + fireEvent.click(await screen.findByRole("tab", { name: "平台功能" })); + const toggle = await screen.findByRole("switch", { + name: "在会话中渲染 HTML", + }); + expect(toggle).toBeChecked(); + fireEvent.click(toggle); await waitFor(() => expect(updateSettings).toHaveBeenCalledWith({ - conversationHtmlRenderingEnabled: false - }) - ) + conversationHtmlRenderingEnabled: false, + }), + ); await waitFor(() => expect( - screen.queryByTitle('Agent 回复 HTML 静态预览') - ).not.toBeInTheDocument() - ) - expect(screen.getByText(/Rendered dashboard/u)).toBeInTheDocument() - }) + screen.queryByTitle("Agent 回复 HTML 静态预览"), + ).not.toBeInTheDocument(), + ); + expect(screen.getByText(/Rendered dashboard/u)).toBeInTheDocument(); + }); - it('keeps platform-feature switches in Settings without navigating', async () => { + it("keeps platform-feature switches in Settings without navigating", async () => { let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: false, - updateSource: 'github', - modelDownloadSource: 'modelscope', + updateSource: "github", + modelDownloadSource: "modelscope", localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, - magicNoteCommentMode: 'immediate', - magicNoteCommentFormat: 'combined' - } + magicNoteCommentMode: "immediate", + magicNoteCommentFormat: "combined", + }; api.updates = { getSettings: vi.fn(async () => ({ ...applicationSettings })), updateSettings: vi.fn(async (input) => { applicationSettings = { ...applicationSettings, - ...input - } - return { ...applicationSettings } + ...input, + }; + return { ...applicationSettings }; }), check: vi.fn(), openReleasePage: vi.fn(async () => {}), - onResult: vi.fn(() => () => {}) - } + onResult: vi.fn(() => () => {}), + }; try { - const { container } = render() + const { container } = render(); fireEvent.click( - await screen.findByRole('button', { - name: /本地工作区/u - }) - ) - await screen.findByRole('heading', { name: '设置中心' }) - fireEvent.click( - screen.getByRole('tab', { name: '平台功能' }) - ) - fireEvent.click( - await screen.findByRole('tab', { name: '魔法笔记' }) - ) - const toggle = await screen.findByRole('switch', { - name: '显示魔法笔记入口' - }) + await screen.findByRole("button", { + name: /本地工作区/u, + }), + ); + await screen.findByRole("heading", { name: "设置中心" }); + fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); + fireEvent.click(await screen.findByRole("tab", { name: "魔法笔记" })); + const toggle = await screen.findByRole("switch", { + name: "显示魔法笔记入口", + }); - fireEvent.click(toggle) - await waitFor(() => expect(toggle).toBeChecked()) + fireEvent.click(toggle); + await waitFor(() => expect(toggle).toBeChecked()); expect( - screen.getByRole('heading', { name: '设置中心' }) - ).toBeInTheDocument() + screen.getByRole("heading", { name: "设置中心" }), + ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: '魔法笔记' }) - ).toBeInTheDocument() + screen.getByRole("button", { name: "魔法笔记" }), + ).toBeInTheDocument(); expect( - container.querySelector('.magic-notes-page') - ).not.toBeInTheDocument() + container.querySelector(".magic-notes-page"), + ).not.toBeInTheDocument(); - fireEvent.click(toggle) - await waitFor(() => expect(toggle).not.toBeChecked()) + fireEvent.click(toggle); + await waitFor(() => expect(toggle).not.toBeChecked()); expect( - screen.getByRole('heading', { name: '设置中心' }) - ).toBeInTheDocument() + screen.getByRole("heading", { name: "设置中心" }), + ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: '魔法笔记' }) - ).not.toBeInTheDocument() + screen.queryByRole("button", { name: "魔法笔记" }), + ).not.toBeInTheDocument(); } finally { - delete api.updates + delete api.updates; } - }) + }); - it('gives the knowledge workspace the full content width', async () => { - render() + it("gives the knowledge workspace the full content width", async () => { + render(); - fireEvent.click(screen.getByRole('button', { name: '知识库' })) + fireEvent.click(screen.getByRole("button", { name: "知识库" })); + expect(await screen.findByLabelText("知识工作区")).toBeInTheDocument(); + expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument(); expect( - await screen.findByLabelText('知识工作区') - ).toBeInTheDocument() - expect( - screen.queryByLabelText('切换助手工作栏') - ).toBeInTheDocument() - expect( - screen.queryByRole('button', { name: /^专家角色:/u }) - ).not.toBeInTheDocument() - }) + screen.queryByRole("button", { name: /^专家角色:/u }), + ).not.toBeInTheDocument(); + }); - it('keeps Smart Heartbeat available when the runtime is not configured', async () => { + it("keeps Smart Heartbeat available when the runtime is not configured", async () => { vi.mocked(api.agent.getStatus).mockResolvedValue({ - id: 'setup', - label: '需要配置模型', + id: "setup", + label: "需要配置模型", available: false, supportsToolExecution: false, - detail: '请配置模型' - }) - render() + detail: "请配置模型", + }); + render(); expect( - await screen.findByRole('heading', { name: '设置中心' }) - ).toBeInTheDocument() - await waitFor(() => - expect(api.agent.getStatus).toHaveBeenCalledOnce() - ) - fireEvent.click( - screen.getByRole('button', { name: '智能心跳' }) - ) + await screen.findByRole("heading", { name: "设置中心" }), + ).toBeInTheDocument(); + await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole("button", { name: "智能心跳" })); expect( - await screen.findByRole('heading', { name: '智能心跳' }) - ).toBeInTheDocument() - expect(api.agent.getStatus).toHaveBeenCalledOnce() - }) -}) + await screen.findByRole("heading", { name: "智能心跳" }), + ).toBeInTheDocument(); + expect(api.agent.getStatus).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 54ae8a41..53163fb4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -38,8 +38,8 @@ import { Sun, TerminalSquare, Trash2, - X -} from 'lucide-react' + X, +} from "lucide-react"; import { Component, Suspense, @@ -52,10 +52,10 @@ import { useRef, useState, type ReactNode, - type SetStateAction -} from 'react' -import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' + type SetStateAction, +} from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import type { ApprovalDecision, AgentEvent, @@ -72,27 +72,27 @@ import type { RuntimeCustomizationSettings, RuntimeNativeSnapshot, RuntimeControl, - RuntimeSettings -} from '../../shared/contracts' + RuntimeSettings, +} from "../../shared/contracts"; import { defaultContextCompressionSettings, - maximumPastedImageBytes -} from '../../shared/contracts' + maximumPastedImageBytes, +} from "../../shared/contracts"; import { buildConversationSummaryHistory, estimatedContextRequestOverheadTokens, estimateMessagesTokens, - getEffectiveContextTriggerTokens -} from '../../shared/context-window' + getEffectiveContextTriggerTokens, +} from "../../shared/context-window"; import { agentRuntimeSelectionKey, agentRuntimeSelectionSchema, - type AgentRuntimeSelection -} from '../../shared/runtime-selection-contracts' + type AgentRuntimeSelection, +} from "../../shared/runtime-selection-contracts"; import { getDefaultRuntimeSelection, - getRuntimeSelectionForProvider -} from './runtime-selection' + getRuntimeSelectionForProvider, +} from "./runtime-selection"; import type { ActivityHistorySnapshot, AssistantProject, @@ -118,8 +118,8 @@ import type { ProjectCreateInput, InteractiveWorkMode, ProjectChannel, - WorkspaceChanges -} from '../../shared/assistant-contracts' + WorkspaceChanges, +} from "../../shared/assistant-contracts"; import { assistantIdSchema, conversationAttachmentSchema, @@ -130,153 +130,144 @@ import { conversationSubagentActivitySchema, interactiveWorkModes, normalizeInteractiveWorkMode, - projectChannelLabels -} from '../../shared/assistant-contracts' + projectChannelLabels, +} from "../../shared/assistant-contracts"; import { ChatTimeline, type ImageViewerItem, type Message, type SubagentActivity, - type ToolActivity -} from './ChatTimeline' + type ToolActivity, +} from "./ChatTimeline"; import { clearLegacyActivityHistory, loadLegacyActivityHistory, mergeActivityRecords, reconcileActivityRecords, upsertActivityRecord, - type ActivityRecord -} from './activity-store' + type ActivityRecord, +} from "./activity-store"; import { KnowledgeCitationDialog, - type KnowledgeCitationContextView -} from './KnowledgeCitationDialog' + type KnowledgeCitationContextView, +} from "./KnowledgeCitationDialog"; import { DestructiveConfirmActions, EmptyState, PageShell, SegmentedControl, - ScopeBadge -} from './WorkspacePrimitives' -import { - ProjectSwitcher -} from './ProjectSwitcher' + ScopeBadge, +} from "./WorkspacePrimitives"; +import { ProjectSwitcher } from "./ProjectSwitcher"; import { RightAssistantSidebar, type AssistantSidebarTab, type PendingSidebarApproval, - type SidebarArtifact -} from './RightAssistantSidebar' + type SidebarArtifact, +} from "./RightAssistantSidebar"; import { CustomTaskDialog, - type CustomTaskDestination -} from './CustomTaskDialog' -import { ConversationTaskStrip } from './ConversationTaskStrip' -import { ConversationInputQueue } from './ConversationInputQueue' -import { OverflowMarquee } from './OverflowMarquee' -import { findTaskSchedule } from './TaskScheduleActions' -import type { SettingsCategoryId } from './settings-categories' -import type { SettingsLeaveRequester } from './SettingsPanel' -import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut' -import goodbuddyDarkIcon from './assets/goodbuddy-dark.png' -import goodbuddyLightIcon from './assets/goodbuddy-light.png' -import { - loadBrandingPreferences, - saveBrandingPreferences -} from './branding' -import { BrandLockup } from './BrandLockup' + type CustomTaskDestination, +} from "./CustomTaskDialog"; +import { ConversationTaskStrip } from "./ConversationTaskStrip"; +import { ConversationInputQueue } from "./ConversationInputQueue"; +import { OverflowMarquee } from "./OverflowMarquee"; +import { findTaskSchedule } from "./TaskScheduleActions"; +import type { SettingsCategoryId } from "./settings-categories"; +import type { SettingsLeaveRequester } from "./SettingsPanel"; +import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut"; +import goodbuddyDarkIcon from "./assets/goodbuddy-dark.png"; +import goodbuddyLightIcon from "./assets/goodbuddy-light.png"; +import { loadBrandingPreferences, saveBrandingPreferences } from "./branding"; +import { BrandLockup } from "./BrandLockup"; import { applyAppearanceTheme, loadAppearanceTheme, resolveAppearanceTheme, saveAppearanceTheme, - type AppearanceTheme -} from './theme' + type AppearanceTheme, +} from "./theme"; import { describeSpeechRecognitionError, getSpeechRecognitionConstructor, prepareSpeechRecognition, startPcmRecording, - type PcmRecording -} from './speech-recognition' + type PcmRecording, +} from "./speech-recognition"; import type { AppNotificationInput, - AppNotificationTone -} from './notifications' -import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts' -import type { - RemoteProjectRecoveryState -} from '../../shared/remote-project-recovery-contracts' -import { ReleaseNotesDialog } from './ReleaseNotesDialog' -import { scheduleIdleRoutePreload } from './idle-route-preload' -import { createPreloadableComponent } from './preloadable-component' + AppNotificationTone, +} from "./notifications"; +import type { ReleaseNotesSnapshot } from "../../shared/release-notes-contracts"; +import type { RemoteProjectRecoveryState } from "../../shared/remote-project-recovery-contracts"; +import { ReleaseNotesDialog } from "./ReleaseNotesDialog"; +import { scheduleIdleRoutePreload } from "./idle-route-preload"; +import { createPreloadableComponent } from "./preloadable-component"; import { formatConversationListTime, - type TimeFormatLocale -} from './time-format' -import { formatMediumDateTime } from './locale-formatters' -import { formatCompactTokens } from './token-format' + type TimeFormatLocale, +} from "./time-format"; +import { formatMediumDateTime } from "./locale-formatters"; +import { formatCompactTokens } from "./token-format"; import { filterKeepAliveEntries, pruneKeepAliveEntries, touchAndPruneKeepAliveEntries, - type KeepAliveCacheEntry -} from './keep-alive-cache' -import { activateModalFocus, trapTabFocus } from './dialog-focus' + type KeepAliveCacheEntry, +} from "./keep-alive-cache"; +import { activateModalFocus, trapTabFocus } from "./dialog-focus"; import { displayErrorMessage, - displayNetworkAwareErrorMessage -} from './error-message' -import { getProjectDisplayText } from './project-display' + displayNetworkAwareErrorMessage, +} from "./error-message"; +import { getProjectDisplayText } from "./project-display"; const knowledgeWorkspaceRoute = createPreloadableComponent( - () => import('./KnowledgeWorkspace'), - (module) => module.KnowledgeWorkspace -) + () => import("./KnowledgeWorkspace"), + (module) => module.KnowledgeWorkspace, +); const heartbeatCenterRoute = createPreloadableComponent( - () => import('./HeartbeatCenter'), - (module) => module.HeartbeatCenter -) + () => import("./HeartbeatCenter"), + (module) => module.HeartbeatCenter, +); const magicNotesWorkspaceRoute = createPreloadableComponent( - () => import('./MagicNotesWorkspace'), - (module) => module.MagicNotesWorkspace -) + () => import("./MagicNotesWorkspace"), + (module) => module.MagicNotesWorkspace, +); const settingsPanelRoute = createPreloadableComponent( - () => import('./SettingsPanel'), - (module) => module.SettingsPanel -) + () => import("./SettingsPanel"), + (module) => module.SettingsPanel, +); const activityPanelRoute = createPreloadableComponent( - () => import('./ActivityPanel'), - (module) => module.ActivityPanel -) -const idleRouteModuleLoaders = [ - heartbeatCenterRoute.preload -] as const + () => import("./ActivityPanel"), + (module) => module.ActivityPanel, +); +const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const; -const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component -const HeartbeatCenter = heartbeatCenterRoute.Component -const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component -const SettingsPanel = settingsPanelRoute.Component -const ActivityPanel = activityPanelRoute.Component +const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component; +const HeartbeatCenter = heartbeatCenterRoute.Component; +const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component; +const SettingsPanel = settingsPanelRoute.Component; +const ActivityPanel = activityPanelRoute.Component; -const messageRenderBatchSize = 80 -const conversationPersistenceIntervalMs = 500 -const conversationSearchSnapshotDelayMs = 250 -const keepAliveExpirationMs = 60 * 60 * 1_000 -const keepAliveSweepIntervalMs = 5 * 60 * 1_000 -const maximumCachedConversations = 12 -const recentCachedConversations = 5 -const maximumCachedWorkspaceViews = 4 -const recentCachedWorkspaceViews = 3 +const messageRenderBatchSize = 80; +const conversationPersistenceIntervalMs = 500; +const conversationSearchSnapshotDelayMs = 250; +const keepAliveExpirationMs = 60 * 60 * 1_000; +const keepAliveSweepIntervalMs = 5 * 60 * 1_000; +const maximumCachedConversations = 12; +const recentCachedConversations = 5; +const maximumCachedWorkspaceViews = 4; +const recentCachedWorkspaceViews = 3; function sameConversationQueueItems( current: ConversationQueueItem[], - next: ConversationQueueItem[] + next: ConversationQueueItem[], ): boolean { return ( current.length === next.length && current.every((item, index) => { - const candidate = next[index] + const candidate = next[index]; return ( candidate !== undefined && item.id === candidate.id && @@ -287,40 +278,36 @@ function sameConversationQueueItems( item.scheduleRunId === candidate.scheduleRunId && item.scheduleId === candidate.scheduleId && item.taskId === candidate.taskId - ) + ); }) - ) + ); } type AppNotification = { - id: string - message: string - tone: AppNotificationTone - revision: number -} + id: string; + message: string; + tone: AppNotificationTone; + revision: number; +}; -type AppNotificationAction = AppNotificationInput | { dismiss: string } +type AppNotificationAction = AppNotificationInput | { dismiss: string }; function appNotificationReducer( current: AppNotification[], - action: AppNotificationAction + action: AppNotificationAction, ): AppNotification[] { - if ('dismiss' in action) { - return current.filter( - (notification) => notification.id !== action.dismiss - ) + if ("dismiss" in action) { + return current.filter((notification) => notification.id !== action.dismiss); } - const message = action.message.slice(0, 2_000) - const id = action.dedupeKey ?? `${action.tone}:${message}` - const existing = current.find( - (notification) => notification.id === id - ) + const message = action.message.slice(0, 2_000); + const id = action.dedupeKey ?? `${action.tone}:${message}`; + const existing = current.find((notification) => notification.id === id); if ( - existing?.tone === 'error' && - action.tone === 'error' && + existing?.tone === "error" && + action.tone === "error" && existing.message === message ) { - return current + return current; } const updated = [ ...current.filter((notification) => notification.id !== id), @@ -328,23 +315,19 @@ function appNotificationReducer( id, message, tone: action.tone, - revision: (existing?.revision ?? 0) + 1 - } - ] + revision: (existing?.revision ?? 0) + 1, + }, + ]; const errors = updated.filter( - (notification) => notification.tone === 'error' - ) + (notification) => notification.tone === "error", + ); const transient = updated - .filter((notification) => notification.tone !== 'error') - .slice(-4) - return [...errors, ...transient] + .filter((notification) => notification.tone !== "error") + .slice(-4); + return [...errors, ...transient]; } -function RouteLoadingStatus({ - label -}: { - label: string -}): React.JSX.Element { +function RouteLoadingStatus({ label }: { label: string }): React.JSX.Element { return ( - ) + ); } function KeepAliveRoute({ active, children, - route + route, }: { - active: boolean - children: ReactNode - route: string + active: boolean; + children: ReactNode; + route: string; }): React.JSX.Element { return ( - ) + ); } class RouteErrorBoundary extends Component< { children: ReactNode; fallback: ReactNode }, { failed: boolean } > { - state = { failed: false } + state = { failed: false }; static getDerivedStateFromError(): { failed: boolean } { - return { failed: true } + return { failed: true }; } render(): ReactNode { - return this.state.failed ? this.props.fallback : this.props.children + return this.state.failed ? this.props.fallback : this.props.children; } } function RouteLoadError({ message, - reloadLabel + reloadLabel, }: { - message: string - reloadLabel: string + message: string; + reloadLabel: string; }): React.JSX.Element { return (
@@ -411,50 +394,45 @@ function RouteLoadError({ {reloadLabel}
- ) + ); } function AppNotificationItem({ notification, - dispatch + dispatch, }: { - notification: AppNotification - dispatch: React.Dispatch + notification: AppNotification; + dispatch: React.Dispatch; }): React.JSX.Element { - const { t } = useTranslation('app') + const { t } = useTranslation("app"); useEffect(() => { - if (notification.tone === 'error') { - return + if (notification.tone === "error") { + return; } const timeout = window.setTimeout(() => { - dispatch({ dismiss: notification.id }) - }, 4_500) - return () => window.clearTimeout(timeout) - }, [ - dispatch, - notification.id, - notification.revision, - notification.tone - ]) + dispatch({ dismiss: notification.id }); + }, 4_500); + return () => window.clearTimeout(timeout); + }, [dispatch, notification.id, notification.revision, notification.tone]); const label = - notification.tone === 'success' - ? t('notifications.success') - : notification.tone === 'error' - ? t('notifications.error') - : t('notifications.info') + notification.tone === "success" + ? t("notifications.success") + : notification.tone === "error" + ? t("notifications.error") + : t("notifications.info"); const Icon = - notification.tone === 'success' + notification.tone === "success" ? CheckCircle2 - : notification.tone === 'error' + : notification.tone === "error" ? CircleAlert - : Info + : Info; return (
- ) + ); } function AppNotificationViewport({ notifications, - dispatch + dispatch, }: { - notifications: AppNotification[] - dispatch: React.Dispatch + notifications: AppNotification[]; + dispatch: React.Dispatch; }): React.JSX.Element | null { - const { t } = useTranslation('app') + const { t } = useTranslation("app"); if (notifications.length === 0) { - return null + return null; } return (
{notifications.map((notification) => ( @@ -497,49 +475,42 @@ function AppNotificationViewport({ /> ))}
- ) + ); } -function supportsSubagentSmartRouting( - workMode: string -): boolean { - return workMode === 'ask' +function supportsSubagentSmartRouting(workMode: string): boolean { + return workMode === "ask"; } -type Conversation = Omit & { - messages: Message[] -} +type Conversation = Omit & { + messages: Message[]; +}; type ActiveRun = { - conversationId: string - messageId: string - projectId?: string - runtimeSelectionKey: string -} + conversationId: string; + messageId: string; + projectId?: string; + runtimeSelectionKey: string; +}; type WorkspaceView = - | 'chat' - | 'magic-notes' - | 'knowledge' - | 'heartbeat' - | 'activity' - | 'settings' + "chat" | "magic-notes" | "knowledge" | "heartbeat" | "activity" | "settings"; const intentRoutePreloaders: Partial< Record Promise> > = { - 'magic-notes': magicNotesWorkspaceRoute.preload, + "magic-notes": magicNotesWorkspaceRoute.preload, knowledge: knowledgeWorkspaceRoute.preload, activity: activityPanelRoute.preload, - settings: settingsPanelRoute.preload -} + settings: settingsPanelRoute.preload, +}; function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void { - const preload = intentRoutePreloaders[view] + const preload = intentRoutePreloaders[view]; if (preload) { void preload().catch(() => { // Click and programmatic navigation retain their local retry boundary. - }) + }); } } @@ -550,261 +521,246 @@ const emptyTokenUsage: TokenUsageSummary = { output: 0, cacheRead: 0, cacheWrite: 0, - totalTokens: 0 + totalTokens: 0, }, - records: [] -} + records: [], +}; -const storageKey = 'goodbuddy.conversations.v1' +const storageKey = "goodbuddy.conversations.v1"; -const activeProjectStorageKey = 'goodbuddy.active-project.v1' +const activeProjectStorageKey = "goodbuddy.active-project.v1"; -const primarySidebarWidthStorageKey = - 'goodbuddy.primary-sidebar-width.v1' -const defaultPrimarySidebarWidth = 278 -const compactPrimarySidebarWidth = 236 -const minimumPrimarySidebarWidth = 220 -const maximumPrimarySidebarWidth = 420 -const minimumPrimaryWorkspaceWidth = 480 -const primarySidebarKeyboardResizeStep = 16 +const primarySidebarWidthStorageKey = "goodbuddy.primary-sidebar-width.v1"; +const defaultPrimarySidebarWidth = 278; +const compactPrimarySidebarWidth = 236; +const minimumPrimarySidebarWidth = 220; +const maximumPrimarySidebarWidth = 420; +const minimumPrimaryWorkspaceWidth = 480; +const primarySidebarKeyboardResizeStep = 16; -const maxMessageContentLength = 1_000_000 +const maxMessageContentLength = 1_000_000; function getPrimarySidebarWidthLimits(viewportWidth: number): { - minimum: number - maximum: number + minimum: number; + maximum: number; } { - const availableWidth = Math.max(0, Math.floor(viewportWidth)) + const availableWidth = Math.max(0, Math.floor(viewportWidth)); return { minimum: minimumPrimarySidebarWidth, maximum: Math.max( minimumPrimarySidebarWidth, Math.min( maximumPrimarySidebarWidth, - availableWidth - minimumPrimaryWorkspaceWidth - ) - ) - } + availableWidth - minimumPrimaryWorkspaceWidth, + ), + ), + }; } function clampPrimarySidebarWidth( width: number, - viewportWidth: number + viewportWidth: number, ): number { - const limits = getPrimarySidebarWidthLimits(viewportWidth) - return Math.min( - limits.maximum, - Math.max(limits.minimum, Math.round(width)) - ) + const limits = getPrimarySidebarWidthLimits(viewportWidth); + return Math.min(limits.maximum, Math.max(limits.minimum, Math.round(width))); } function loadPrimarySidebarWidth(): number { const fallback = window.innerWidth <= 1020 ? compactPrimarySidebarWidth - : defaultPrimarySidebarWidth + : defaultPrimarySidebarWidth; try { const persistedWidth = Number( - localStorage.getItem(primarySidebarWidthStorageKey) - ) + localStorage.getItem(primarySidebarWidthStorageKey), + ); return clampPrimarySidebarWidth( Number.isFinite(persistedWidth) && persistedWidth > 0 ? persistedWidth : fallback, - window.innerWidth - ) + window.innerWidth, + ); } catch { - return clampPrimarySidebarWidth(fallback, window.innerWidth) + return clampPrimarySidebarWidth(fallback, window.innerWidth); } } function appendMessageContentBlock( blocks: ConversationMessageBlock[] | undefined, - type: 'text' | 'reasoning', - delta: string + type: "text" | "reasoning", + delta: string, ): ConversationMessageBlock[] | undefined { if (!blocks || !delta) { - return blocks + return blocks; } - const current = [...blocks] - const previous = current.at(-1) + const current = [...blocks]; + const previous = current.at(-1); if (previous?.type === type) { current[current.length - 1] = { ...previous, - content: `${previous.content}${delta}`.slice( - 0, - maxMessageContentLength - ) - } - return current + content: `${previous.content}${delta}`.slice(0, maxMessageContentLength), + }; + return current; } current.push({ id: crypto.randomUUID(), type, - content: delta.slice(0, maxMessageContentLength) - }) - return current + content: delta.slice(0, maxMessageContentLength), + }); + return current; } function upsertMessageToolBlock( blocks: ConversationMessageBlock[] | undefined, - tool: ToolActivity + tool: ToolActivity, ): ConversationMessageBlock[] | undefined { if (!blocks) { - return blocks + return blocks; } - const callId = tool.callId + const callId = tool.callId; const index = callId ? blocks.findIndex( - (block) => - block.type === 'tool' && block.tool.callId === callId + (block) => block.type === "tool" && block.tool.callId === callId, ) - : -1 + : -1; if (index >= 0) { return blocks.map((block, blockIndex) => - blockIndex === index && block.type === 'tool' + blockIndex === index && block.type === "tool" ? { ...block, tool } - : block - ) + : block, + ); } return [ ...blocks, { id: crypto.randomUUID(), - type: 'tool', - tool - } - ] + type: "tool", + tool, + }, + ]; } function upsertMessageSubagentBlock( blocks: ConversationMessageBlock[] | undefined, childTaskId: string, - runtimeCallId?: string + runtimeCallId?: string, ): ConversationMessageBlock[] | undefined { if (!blocks) { - return blocks + return blocks; } if ( blocks.some( - (block) => - block.type === 'subagent' && - block.childTaskId === childTaskId + (block) => block.type === "subagent" && block.childTaskId === childTaskId, ) ) { - return blocks + return blocks; } const provisionalIndex = runtimeCallId ? blocks.findIndex( - (block) => - block.type === 'tool' && - block.tool.callId === runtimeCallId + (block) => block.type === "tool" && block.tool.callId === runtimeCallId, ) - : -1 + : -1; if (provisionalIndex >= 0) { return blocks.map((block, index) => index === provisionalIndex ? { id: block.id, - type: 'subagent' as const, - childTaskId + type: "subagent" as const, + childTaskId, } - : block - ) + : block, + ); } return [ ...blocks, { id: crypto.randomUUID(), - type: 'subagent', - childTaskId - } - ] + type: "subagent", + childTaskId, + }, + ]; } function terminalizeMessageToolBlocks( blocks: ConversationMessageBlock[] | undefined, - state: 'failed' | 'cancelled' | 'interrupted' + state: "failed" | "cancelled" | "interrupted", ): ConversationMessageBlock[] | undefined { return blocks?.map((block) => - block.type === 'tool' && - (block.tool.state === 'pending' || block.tool.state === 'running') + block.type === "tool" && + (block.tool.state === "pending" || block.tool.state === "running") ? { ...block, tool: { ...block.tool, - state - } + state, + }, } - : block - ) + : block, + ); } function isErrorRepresentedByFailedTool( tools: ToolActivity[] | undefined, - errorMessage: string + errorMessage: string, ): boolean { return Boolean( tools?.some( (tool) => - tool.state === 'failed' && + tool.state === "failed" && ((tool.error && errorMessage.includes(tool.error)) || - (tool.callId && errorMessage.includes(tool.callId))) - ) - ) + (tool.callId && errorMessage.includes(tool.callId))), + ), + ); } -const chatBottomProximity = 96 +const chatBottomProximity = 96; function createConversation( projectId?: string, runtimeSelection?: AgentRuntimeSelection, - greeting = - '你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。' + greeting = "你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。", ): Conversation { - const now = Date.now() + const now = Date.now(); return { id: crypto.randomUUID(), projectId, runtimeSelection, knowledgeLibraryIds: [], - knowledgeRetrievalMode: 'auto', - title: '新对话', + knowledgeRetrievalMode: "auto", + title: "新对话", updatedAt: now, messages: [ { id: crypto.randomUUID(), - role: 'assistant', + role: "assistant", content: greeting, createdAt: now, - state: 'complete' - } - ] - } + state: "complete", + }, + ], + }; } function createConversationBranchTitle( sourceTitle: string, - suffix: string + suffix: string, ): string { - const trailing = ` · ${suffix}` + const trailing = ` · ${suffix}`; if (trailing.length >= 200) { - return suffix.slice(0, 200) + return suffix.slice(0, 200); } - return `${sourceTitle - .slice(0, 200 - trailing.length) - .trimEnd()}${trailing}` + return `${sourceTitle.slice(0, 200 - trailing.length).trimEnd()}${trailing}`; } function ConversationBranchBadge({ - sourceTitle + sourceTitle, }: { - sourceTitle: string + sourceTitle: string; }): React.JSX.Element { - const { t } = useTranslation('app') - const label = t('conversation.branch.badge', { - title: sourceTitle - }) + const { t } = useTranslation("app"); + const label = t("conversation.branch.badge", { + title: sourceTitle, + }); return ( - ) + ); } function isUnusedConversation(conversation: Conversation): boolean { return ( - conversation.title === '新对话' && + conversation.title === "新对话" && conversation.messages.length === 1 && - conversation.messages[0]?.role === 'assistant' - ) + conversation.messages[0]?.role === "assistant" + ); } function getConversationDisplayTitle( conversation: Conversation, - defaultTitle: string + defaultTitle: string, ): string { - return isUnusedConversation(conversation) - ? defaultTitle - : conversation.title + return isUnusedConversation(conversation) ? defaultTitle : conversation.title; } type ChatQuickAction = { - title: string - description: string - prompt: string -} + title: string; + description: string; + prompt: string; +}; type ChatScrollSnapshot = { - pinnedToBottom: boolean - scrollTop: number -} + pinnedToBottom: boolean; + scrollTop: number; +}; function ChatHistoryPane({ active, @@ -864,281 +818,264 @@ function ChatHistoryPane({ quickActions, scrollSnapshot, taskStrip, - visibleMessageCount + visibleMessageCount, }: { - active: boolean - artifactById: ReadonlyMap - conversationHtmlRenderingEnabled: boolean - conversation: Conversation - locale: TimeFormatLocale - onCopyMessage: (content: string) => Promise - onDownloadImage: (item: ImageViewerItem) => void - onOpenCitationContext: ( - reference: KnowledgeSearchReference - ) => Promise - onOpenCitationSource: ( - reference: KnowledgeSearchReference - ) => Promise - onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void + active: boolean; + artifactById: ReadonlyMap; + conversationHtmlRenderingEnabled: boolean; + conversation: Conversation; + locale: TimeFormatLocale; + onCopyMessage: (content: string) => Promise; + onDownloadImage: (item: ImageViewerItem) => void; + onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise; + onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise; + onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void; onRespondApproval: ( conversationId: string, messageId: string, approvalId: string, - decision: ApprovalDecision - ) => Promise + decision: ApprovalDecision, + ) => Promise; onRespondQuestion: ( conversationId: string, messageId: string, questionId: string, - answers?: AgentQuestionAnswer[] - ) => Promise - onRetry: (content: string) => void + answers?: AgentQuestionAnswer[], + ) => Promise; + onRetry: (content: string) => void; onScrollSnapshotChange: ( conversationId: string, - snapshot: ChatScrollSnapshot - ) => void - onSetInput: (value: string) => void - onVisibleMessageCountChange: ( - conversationId: string, - count: number - ) => void - quickActions: ChatQuickAction[] - scrollSnapshot?: ChatScrollSnapshot - taskStrip?: ReactNode - visibleMessageCount: number + snapshot: ChatScrollSnapshot, + ) => void; + onSetInput: (value: string) => void; + onVisibleMessageCountChange: (conversationId: string, count: number) => void; + quickActions: ChatQuickAction[]; + scrollSnapshot?: ChatScrollSnapshot; + taskStrip?: ReactNode; + visibleMessageCount: number; }): React.JSX.Element { - const { t } = useTranslation('app') - const headingId = `chat-heading-${conversation.id}` - const scrollRef = useRef(null) - const pinnedToBottomRef = useRef( - scrollSnapshot?.pinnedToBottom ?? true - ) - const latestScrollSnapshotRef = useRef(scrollSnapshot) - const restorePendingRef = useRef(true) - const prependScrollPositionRef = useRef<{ - scrollHeight: number - scrollTop: number - } | undefined>(undefined) - const finalRevealedMessageIdRef = useRef( - undefined - ) - const messageArticleRefs = useRef(new Map()) - const previousMessageCountRef = useRef(conversation.messages.length) + const { t } = useTranslation("app"); + const headingId = `chat-heading-${conversation.id}`; + const scrollRef = useRef(null); + const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true); + const latestScrollSnapshotRef = useRef(scrollSnapshot); + const restorePendingRef = useRef(true); + const prependScrollPositionRef = useRef< + | { + scrollHeight: number; + scrollTop: number; + } + | undefined + >(undefined); + const finalRevealedMessageIdRef = useRef(undefined); + const messageArticleRefs = useRef(new Map()); + const previousMessageCountRef = useRef(conversation.messages.length); const [showScrollToBottom, setShowScrollToBottom] = useState( - scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false - ) + scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false, + ); const visibleMessageStartIndex = Math.max( 0, - conversation.messages.length - visibleMessageCount - ) + conversation.messages.length - visibleMessageCount, + ); const visibleMessages = useMemo( () => conversation.messages.slice(visibleMessageStartIndex), - [conversation.messages, visibleMessageStartIndex] - ) - const hiddenMessageCount = visibleMessageStartIndex + [conversation.messages, visibleMessageStartIndex], + ); + const hiddenMessageCount = visibleMessageStartIndex; const handleArticleRef = useCallback( (messageId: string, element: HTMLElement | null): void => { if (element) { - messageArticleRefs.current.set(messageId, element) + messageArticleRefs.current.set(messageId, element); } else { - messageArticleRefs.current.delete(messageId) + messageArticleRefs.current.delete(messageId); } }, - [] - ) + [], + ); const saveScrollPosition = useCallback( (scrollContainer: HTMLElement): boolean => { const distanceFromBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - - scrollContainer.clientHeight - const pinnedToBottom = distanceFromBottom <= chatBottomProximity + scrollContainer.clientHeight; + const pinnedToBottom = distanceFromBottom <= chatBottomProximity; latestScrollSnapshotRef.current = { pinnedToBottom, - scrollTop: scrollContainer.scrollTop - } - return pinnedToBottom + scrollTop: scrollContainer.scrollTop, + }; + return pinnedToBottom; }, - [] - ) + [], + ); const handleScrollRef = useCallback( (element: HTMLElement | null): void => { - const previous = scrollRef.current + const previous = scrollRef.current; if (previous && previous !== element) { - saveScrollPosition(previous) + saveScrollPosition(previous); if (!element && latestScrollSnapshotRef.current) { onScrollSnapshotChange( conversation.id, - latestScrollSnapshotRef.current - ) + latestScrollSnapshotRef.current, + ); } } - scrollRef.current = element + scrollRef.current = element; }, - [conversation.id, onScrollSnapshotChange, saveScrollPosition] - ) + [conversation.id, onScrollSnapshotChange, saveScrollPosition], + ); const updateScrollPosition = useCallback((): void => { - const scrollContainer = scrollRef.current + const scrollContainer = scrollRef.current; if (!scrollContainer) { - return + return; } - const atBottom = saveScrollPosition(scrollContainer) - pinnedToBottomRef.current = atBottom - setShowScrollToBottom(!atBottom) - }, [saveScrollPosition]) + const atBottom = saveScrollPosition(scrollContainer); + pinnedToBottomRef.current = atBottom; + setShowScrollToBottom(!atBottom); + }, [saveScrollPosition]); useLayoutEffect(() => { - const previousMessageCount = previousMessageCountRef.current + const previousMessageCount = previousMessageCountRef.current; if ( conversation.messages .slice(previousMessageCount) - .some((message) => message.role === 'user') + .some((message) => message.role === "user") ) { - pinnedToBottomRef.current = true + pinnedToBottomRef.current = true; } - previousMessageCountRef.current = conversation.messages.length - }, [conversation.messages]) + previousMessageCountRef.current = conversation.messages.length; + }, [conversation.messages]); useLayoutEffect(() => { if (!active) { - return + return; } - const scrollContainer = scrollRef.current + const scrollContainer = scrollRef.current; if (!scrollContainer) { - return + return; } if (restorePendingRef.current) { - restorePendingRef.current = false + restorePendingRef.current = false; if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) { - pinnedToBottomRef.current = false - scrollContainer.scrollTop = scrollSnapshot.scrollTop - return + pinnedToBottomRef.current = false; + scrollContainer.scrollTop = scrollSnapshot.scrollTop; + return; } } if (pinnedToBottomRef.current) { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, - behavior: 'auto' - }) + behavior: "auto", + }); } - }, [ - active, - conversation.messages, - scrollSnapshot, - visibleMessageCount - ]) + }, [active, conversation.messages, scrollSnapshot, visibleMessageCount]); useLayoutEffect(() => { - if (!active || typeof ResizeObserver !== 'function') { - return + if (!active || typeof ResizeObserver !== "function") { + return; } - const scrollContainer = scrollRef.current + const scrollContainer = scrollRef.current; const messageList = - scrollContainer?.querySelector('.message-list') + scrollContainer?.querySelector(".message-list"); if (!scrollContainer || !messageList) { - return + return; } const observer = new ResizeObserver(() => { - if ( - restorePendingRef.current || - !pinnedToBottomRef.current - ) { - return + if (restorePendingRef.current || !pinnedToBottomRef.current) { + return; } scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, - behavior: 'auto' - }) - }) - observer.observe(messageList) - return () => observer.disconnect() - }, [active]) + behavior: "auto", + }); + }); + observer.observe(messageList); + return () => observer.disconnect(); + }, [active]); useLayoutEffect(() => { - const previous = prependScrollPositionRef.current + const previous = prependScrollPositionRef.current; if (!previous) { - return + return; } - prependScrollPositionRef.current = undefined - const scrollContainer = scrollRef.current + prependScrollPositionRef.current = undefined; + const scrollContainer = scrollRef.current; if (!scrollContainer) { - return + return; } scrollContainer.scrollTop = previous.scrollTop + - (scrollContainer.scrollHeight - previous.scrollHeight) - const finalRevealedMessageId = finalRevealedMessageIdRef.current - finalRevealedMessageIdRef.current = undefined + (scrollContainer.scrollHeight - previous.scrollHeight); + const finalRevealedMessageId = finalRevealedMessageIdRef.current; + finalRevealedMessageIdRef.current = undefined; if (finalRevealedMessageId) { messageArticleRefs.current .get(finalRevealedMessageId) - ?.focus({ preventScroll: true }) + ?.focus({ preventScroll: true }); } - }, [visibleMessageCount]) + }, [visibleMessageCount]); const revealEarlierMessages = useCallback((): void => { - const scrollContainer = scrollRef.current + const scrollContainer = scrollRef.current; if (scrollContainer) { prependScrollPositionRef.current = { scrollHeight: scrollContainer.scrollHeight, - scrollTop: scrollContainer.scrollTop - } + scrollTop: scrollContainer.scrollTop, + }; } if ( visibleMessageCount + messageRenderBatchSize >= conversation.messages.length ) { - finalRevealedMessageIdRef.current = - conversation.messages[0]?.id + finalRevealedMessageIdRef.current = conversation.messages[0]?.id; } onVisibleMessageCountChange( conversation.id, - visibleMessageCount + messageRenderBatchSize - ) + visibleMessageCount + messageRenderBatchSize, + ); }, [ conversation.id, conversation.messages, onVisibleMessageCountChange, - visibleMessageCount - ]) + visibleMessageCount, + ]); const scrollToBottom = (): void => { - const scrollContainer = scrollRef.current + const scrollContainer = scrollRef.current; if (!scrollContainer) { - return + return; } - pinnedToBottomRef.current = true - setShowScrollToBottom(false) + pinnedToBottomRef.current = true; + setShowScrollToBottom(false); const reduceMotion = - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, - behavior: reduceMotion ? 'auto' : 'smooth' - }) - } + behavior: reduceMotion ? "auto" : "smooth", + }); + }; return ( - ) + ); } function App(): React.JSX.Element { - const { i18n, t } = useTranslation('app') - const { t: tWorkspace } = useTranslation('workspace') - const tRef = useRef(t) + const { i18n, t } = useTranslation("app"); + const { t: tWorkspace } = useTranslation("workspace"); + const tRef = useRef(t); useEffect(() => { - tRef.current = t - }, [t]) - const locale = i18n.resolvedLanguage === 'en-US' ? 'en-US' : 'zh-CN' + tRef.current = t; + }, [t]); + const locale = i18n.resolvedLanguage === "en-US" ? "en-US" : "zh-CN"; const conversationMigrationStoragePresent = useRef( - hasConversationMigrationStorage() - ) + hasConversationMigrationStorage(), + ); const [conversations, setConversations] = useState(() => loadConversations( - t('conversation.greeting'), - t('conversation.interrupted') - ) - ) + t("conversation.greeting"), + t("conversation.interrupted"), + ), + ); const [activeId, setActiveIdState] = useState( - () => conversations[0]?.id ?? '' - ) - const activeConversationIdRef = useRef(activeId) - const conversationsRef = useRef(conversations) + () => conversations[0]?.id ?? "", + ); + const activeConversationIdRef = useRef(activeId); + const conversationsRef = useRef(conversations); const persistedLocalConversationsRef = useRef( - new Map() - ) - const conversationPersistenceQueueRef = - useRef>(Promise.resolve()) - const conversationPersistencePausedRef = useRef(false) - const deletingLocalConversationIdsRef = useRef(new Set()) - const flushConversationPersistenceAfterRenderRef = useRef(false) + new Map(), + ); + const conversationPersistenceQueueRef = useRef>( + Promise.resolve(), + ); + const conversationPersistencePausedRef = useRef(false); + const deletingLocalConversationIdsRef = useRef(new Set()); + const flushConversationPersistenceAfterRenderRef = useRef(false); const [unreadConversationIds, setUnreadConversationIds] = useState< Set - >(() => new Set()) - const [conversationStoreReady, setConversationStoreReady] = - useState(false) - const migrationConversations = useRef(conversations) - const [projects, setProjects] = useState([]) - const projectsRef = useRef(projects) - const [projectRecoveryByProjectId, setProjectRecoveryByProjectId] = - useState>({}) + >(() => new Set()); + const [conversationStoreReady, setConversationStoreReady] = useState(false); + const migrationConversations = useRef(conversations); + const [projects, setProjects] = useState([]); + const projectsRef = useRef(projects); + const [projectRecoveryByProjectId, setProjectRecoveryByProjectId] = useState< + Record + >({}); const [projectRecoverySnapshotReady, setProjectRecoverySnapshotReady] = - useState(false) - const projectRecoverySnapshotReadyRef = useRef(false) + useState(false); + const projectRecoverySnapshotReadyRef = useRef(false); const projectRecoveryByProjectIdRef = useRef< Record - >({}) - const retryingRecoveryProjectIdsRef = useRef(new Set()) - const [assistantTasks, setAssistantTasks] = useState([]) - const assistantTasksRef = useRef(assistantTasks) + >({}); + const retryingRecoveryProjectIdsRef = useRef(new Set()); + const [assistantTasks, setAssistantTasks] = useState([]); + const assistantTasksRef = useRef(assistantTasks); const [tokenUsage, setTokenUsage] = - useState(emptyTokenUsage) - const [workspaceChanges, setWorkspaceChanges] = - useState() + useState(emptyTokenUsage); + const [workspaceChanges, setWorkspaceChanges] = useState(); const [assistantArtifacts, setAssistantArtifacts] = useState< AssistantArtifact[] - >([]) + >([]); const assistantArtifactById = useMemo( () => - new Map( - assistantArtifacts.map((artifact) => [artifact.id, artifact]) - ), - [assistantArtifacts] - ) - const [assistantMemories, setAssistantMemories] = useState< - AssistantMemory[] - >([]) + new Map(assistantArtifacts.map((artifact) => [artifact.id, artifact])), + [assistantArtifacts], + ); + const [assistantMemories, setAssistantMemories] = useState( + [], + ); const [assistantSchedules, setAssistantSchedules] = useState< AssistantSchedule[] - >([]) + >([]); const [conversationQueueItems, setConversationQueueItems] = useState< ConversationQueueItem[] - >([]) - const dispatchedConversationQueueItems = useRef(new Set()) + >([]); + const dispatchedConversationQueueItems = useRef(new Set()); const conversationQueueDispatchRef = useRef< (dispatch: ConversationQueueDispatch) => void - >(() => undefined) + >(() => undefined); const [selectedAssistantTaskId, setSelectedAssistantTaskId] = - useState() + useState(); const [expandedTaskConversationIds, setExpandedTaskConversationIds] = - useState>(() => new Set()) + useState>(() => new Set()); const [customTaskDialog, setCustomTaskDialog] = useState<{ - defaultDestination: CustomTaskDestination - }>() + defaultDestination: CustomTaskDestination; + }>(); const [assistantHeartbeats, setAssistantHeartbeats] = useState< AssistantHeartbeatConfig[] - >([]) + >([]); const [heartbeatEntries, setHeartbeatEntries] = useState< AssistantHeartbeatEntry[] - >([]) - const [heartbeatRuns, setHeartbeatRuns] = useState< - AssistantHeartbeatRun[] - >([]) - const [heartbeatMemories, setHeartbeatMemories] = useState< - AssistantMemory[] - >([]) - const [heartbeatLoading, setHeartbeatLoading] = useState(true) - const [heartbeatLoadError, setHeartbeatLoadError] = useState() - const [assistantExperts, setAssistantExperts] = useState< - AssistantExpert[] - >([]) - const [selectedExpertId, setSelectedExpertId] = useState('') - const [activeProjectId, setActiveProjectId] = useState('') - const activeProjectIdRef = useRef(activeProjectId) - const workspaceChangesRequestRef = useRef(0) - const runtimeStatusRequestRef = useRef(0) - const runtimeSetupPromptedRef = useRef(false) - const runtimeStatusCacheRef = useRef<{ - key: string - settings: RuntimeSettings - } | undefined>(undefined) - const viewRef = useRef('chat') - const heartbeatLoadRequestRef = useRef(0) - const [workMode, setWorkMode] = - useState('ask') + >([]); + const [heartbeatRuns, setHeartbeatRuns] = useState( + [], + ); + const [heartbeatMemories, setHeartbeatMemories] = useState( + [], + ); + const [heartbeatLoading, setHeartbeatLoading] = useState(true); + const [heartbeatLoadError, setHeartbeatLoadError] = useState(); + const [assistantExperts, setAssistantExperts] = useState( + [], + ); + const [selectedExpertId, setSelectedExpertId] = useState(""); + const [activeProjectId, setActiveProjectId] = useState(""); + const activeProjectIdRef = useRef(activeProjectId); + const workspaceChangesRequestRef = useRef(0); + const runtimeStatusRequestRef = useRef(0); + const runtimeSwitchGenerationRef = useRef(0); + const runtimeSetupPromptedRef = useRef(false); + const runtimeStatusCacheRef = useRef< + | { + key: string; + settings: RuntimeSettings; + } + | undefined + >(undefined); + const viewRef = useRef("chat"); + const heartbeatLoadRequestRef = useRef(0); + const [workMode, setWorkMode] = useState("ask"); const [conversationDrafts, setConversationDrafts] = useState< Record - >({}) - const input = conversationDrafts[activeId] ?? '' + >({}); + const input = conversationDrafts[activeId] ?? ""; const setInput = useCallback( (update: SetStateAction): void => { setConversationDrafts((current) => { - const currentValue = current[activeId] ?? '' + const currentValue = current[activeId] ?? ""; const nextValue = - typeof update === 'function' - ? update(currentValue) - : update + typeof update === "function" ? update(currentValue) : update; if (nextValue === currentValue) { - return current + return current; } if (!nextValue) { - const next = { ...current } - delete next[activeId] - return next + const next = { ...current }; + delete next[activeId]; + return next; } - return { ...current, [activeId]: nextValue } - }) + return { ...current, [activeId]: nextValue }; + }); }, - [activeId] - ) - const [voiceListening, setVoiceListening] = useState(false) - const [voiceRecording, setVoiceRecording] = useState(false) - const voiceRecordingRef = useRef(undefined) - const voiceRequestIdRef = useRef(undefined) - const voiceStartingRef = useRef(false) - const voiceDisposedRef = useRef(false) - const startupUpdateCheckStartedRef = useRef(false) - const startupReleaseNotesStartedRef = useRef(false) - const [releaseNotes, setReleaseNotes] = - useState() - const [runtime, setRuntime] = useState() - const [runtimeStatusKey, setRuntimeStatusKey] = useState('') - const [runtimeSettings, setRuntimeSettings] = useState() - const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false) + [activeId], + ); + const [voiceListening, setVoiceListening] = useState(false); + const [voiceRecording, setVoiceRecording] = useState(false); + const voiceRecordingRef = useRef(undefined); + const voiceRequestIdRef = useRef(undefined); + const voiceStartingRef = useRef(false); + const voiceDisposedRef = useRef(false); + const startupUpdateCheckStartedRef = useRef(false); + const startupReleaseNotesStartedRef = useRef(false); + const [releaseNotes, setReleaseNotes] = useState(); + const [runtime, setRuntime] = useState(); + const [runtimeStatusKey, setRuntimeStatusKey] = useState(""); + const [runtimeSettings, setRuntimeSettings] = useState(); + const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); const [composerMenuOpen, setComposerMenuOpen] = useState< - | 'expert' - | 'mode' - | 'runtime-agent' - | 'runtime-action' - | 'runtime-preset' + | "expert" + | "mode" + | "runtime-agent" + | "runtime-action" + | "runtime-preset" | undefined - >() + >(); const [runtimeCustomization, setRuntimeCustomization] = - useState() + useState(); const [runtimeNativeSnapshot, setRuntimeNativeSnapshot] = - useState() - const [selectedRuntimeAgent, setSelectedRuntimeAgent] = - useState('') - const [selectedRuntimeCommand, setSelectedRuntimeCommand] = - useState('') - const [selectedContinuePreset, setSelectedContinuePreset] = - useState('') + useState(); + const runtimeNativeCacheRef = useRef( + new Map< + string, + { + customization: RuntimeCustomizationSettings; + snapshot: RuntimeNativeSnapshot; + } + >(), + ); + const runtimeNativeRetryScopesRef = useRef(new Set()); + const [runtimeNativeRetry, setRuntimeNativeRetry] = useState(0); + const [selectedRuntimeAgent, setSelectedRuntimeAgent] = useState(""); + const [selectedRuntimeCommand, setSelectedRuntimeCommand] = useState(""); + const [selectedContinuePreset, setSelectedContinuePreset] = useState(""); const [runtimeContextCompacting, setRuntimeContextCompacting] = - useState(false) - const runtimeCustomizationRequestRef = useRef(0) - const runtimeMenuButtonRef = useRef(null) - const runtimeMenuRef = useRef(null) - const [runtimeSwitching, setRuntimeSwitching] = useState(false) + useState(false); + const runtimeCustomizationRequestRef = useRef(0); + const runtimeMenuButtonRef = useRef(null); + const runtimeMenuRef = useRef(null); + const [runtimeSwitching, setRuntimeSwitching] = useState(false); const [appearanceTheme, setAppearanceTheme] = - useState(loadAppearanceTheme) + useState(loadAppearanceTheme); const [brandingPreferences, setBrandingPreferences] = useState( - loadBrandingPreferences - ) + loadBrandingPreferences, + ); const [systemPrefersDark, setSystemPrefersDark] = useState( () => - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-color-scheme: dark)').matches - ) + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: dark)").matches, + ); const resolvedAppearanceTheme = resolveAppearanceTheme( appearanceTheme, - systemPrefersDark - ) + systemPrefersDark, + ); const brandingSubtitle = - locale === 'en-US' + locale === "en-US" ? brandingPreferences.subtitleEnUS - : brandingPreferences.subtitleZhCN + : brandingPreferences.subtitleZhCN; const toggleAppearanceTheme = useCallback((): void => { - setAppearanceTheme( - resolvedAppearanceTheme === 'dark' ? 'light' : 'dark' - ) - }, [resolvedAppearanceTheme]) + setAppearanceTheme(resolvedAppearanceTheme === "dark" ? "light" : "dark"); + }, [resolvedAppearanceTheme]); const effectiveWorkMode = - workMode === 'execute' && - runtime?.supportsToolExecution === false - ? 'ask' - : workMode + workMode === "execute" && runtime?.supportsToolExecution === false + ? "ask" + : workMode; const setExpertMenuOpen = useCallback((open: boolean): void => { - setComposerMenuOpen(open ? 'expert' : undefined) + setComposerMenuOpen(open ? "expert" : undefined); if (open) { - setRuntimeMenuOpen(false) + setRuntimeMenuOpen(false); } - }, []) + }, []); const setModeMenuOpen = useCallback((open: boolean): void => { - setComposerMenuOpen(open ? 'mode' : undefined) + setComposerMenuOpen(open ? "mode" : undefined); if (open) { - setRuntimeMenuOpen(false) + setRuntimeMenuOpen(false); } - }, []) - const setRuntimeAgentMenuOpen = useCallback( - (open: boolean): void => { - setComposerMenuOpen(open ? 'runtime-agent' : undefined) - if (open) { - setRuntimeMenuOpen(false) - } - }, - [] - ) - const setRuntimeActionMenuOpen = useCallback( - (open: boolean): void => { - setComposerMenuOpen(open ? 'runtime-action' : undefined) - if (open) { - setRuntimeMenuOpen(false) - } - }, - [] - ) - const setRuntimePresetMenuOpen = useCallback( - (open: boolean): void => { - setComposerMenuOpen(open ? 'runtime-preset' : undefined) - if (open) { - setRuntimeMenuOpen(false) - } - }, - [] - ) - const assistantExpertOptions = useMemo< - ComposerMenuOption[] - >( + }, []); + const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => { + setComposerMenuOpen(open ? "runtime-agent" : undefined); + if (open) { + setRuntimeMenuOpen(false); + } + }, []); + const setRuntimeActionMenuOpen = useCallback((open: boolean): void => { + setComposerMenuOpen(open ? "runtime-action" : undefined); + if (open) { + setRuntimeMenuOpen(false); + } + }, []); + const setRuntimePresetMenuOpen = useCallback((open: boolean): void => { + setComposerMenuOpen(open ? "runtime-preset" : undefined); + if (open) { + setRuntimeMenuOpen(false); + } + }, []); + const assistantExpertOptions = useMemo[]>( () => [ { - value: '', - label: t('composer.experts.general'), - description: t('composer.experts.generalDescription') + value: "", + label: t("composer.experts.general"), + description: t("composer.experts.generalDescription"), }, { - value: 'team', - label: t('composer.experts.team'), - description: t('composer.experts.teamDescription') + value: "team", + label: t("composer.experts.team"), + description: t("composer.experts.teamDescription"), }, ...assistantExperts.map((expert) => ({ value: expert.id, label: expert.name, description: - expert.description || t('composer.experts.customDescription') - })) + expert.description || t("composer.experts.customDescription"), + })), ], - [assistantExperts, t] - ) - const workModeOptions = useMemo< - ComposerMenuOption[] - >( + [assistantExperts, t], + ); + const workModeOptions = useMemo[]>( () => interactiveWorkModes.map((value) => ({ value, label: t(`composer.modes.${value}.label`), description: - value === 'execute' - ? t('composer.modes.execute.description') - : t('composer.modes.ask.description'), - disabled: - value === 'execute' && !runtime?.supportsToolExecution + value === "execute" + ? t("composer.modes.execute.description") + : t("composer.modes.ask.description"), + disabled: value === "execute" && !runtime?.supportsToolExecution, })), - [runtime?.supportsToolExecution, t] - ) + [runtime?.supportsToolExecution, t], + ); const quickActions = useMemo( () => [ { - title: t('chat.quickActions.summarize.title'), - description: t('chat.quickActions.summarize.description'), - prompt: t('chat.quickActions.summarize.prompt') + title: t("chat.quickActions.summarize.title"), + description: t("chat.quickActions.summarize.description"), + prompt: t("chat.quickActions.summarize.prompt"), }, { - title: t('chat.quickActions.analyzeError.title'), - description: t('chat.quickActions.analyzeError.description'), - prompt: t('chat.quickActions.analyzeError.prompt') + title: t("chat.quickActions.analyzeError.title"), + description: t("chat.quickActions.analyzeError.description"), + prompt: t("chat.quickActions.analyzeError.prompt"), }, { - title: t('chat.quickActions.write.title'), - description: t('chat.quickActions.write.description'), - prompt: t('chat.quickActions.write.prompt') - } + title: t("chat.quickActions.write.title"), + description: t("chat.quickActions.write.description"), + prompt: t("chat.quickActions.write.prompt"), + }, ], - [t] - ) - const [appInfo, setAppInfo] = useState() + [t], + ); + const [appInfo, setAppInfo] = useState(); const [narrowWindow, setNarrowWindow] = useState( - () => window.innerWidth < 900 - ) + () => window.innerWidth < 900, + ); const [sidebarOpen, setSidebarOpen] = useState( - () => window.innerWidth >= 900 - ) + () => window.innerWidth >= 900, + ); const [primarySidebarWidth, setPrimarySidebarWidth] = useState( - loadPrimarySidebarWidth - ) - const [primarySidebarResizing, setPrimarySidebarResizing] = - useState(false) + loadPrimarySidebarWidth, + ); + const [primarySidebarResizing, setPrimarySidebarResizing] = useState(false); const [assistantSidebarOpen, setAssistantSidebarOpen] = useState( - () => window.innerWidth >= 1280 - ) + () => window.innerWidth >= 1280, + ); const [assistantSidebarTab, setAssistantSidebarTab] = - useState('tasks') + useState("tasks"); const [browserStates, setBrowserStates] = useState< Record - >({}) - const [view, setViewState] = useState('chat') - const settingsEntryFocusRef = useRef( - undefined - ) - const settingsExitFocusRef = useRef( - undefined - ) - const settingsLeaveRequesterRef = useRef< - SettingsLeaveRequester | undefined - >(undefined) + >({}); + const [view, setViewState] = useState("chat"); + const settingsEntryFocusRef = useRef(undefined); + const settingsExitFocusRef = useRef(undefined); + const settingsLeaveRequesterRef = useRef( + undefined, + ); const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState< KeepAliveCacheEntry[] - >(() => [{ key: 'chat', lastVisitedAt: Date.now() }]) + >(() => [{ key: "chat", lastVisitedAt: Date.now() }]); const [cachedConversationViews, setCachedConversationViews] = useState< KeepAliveCacheEntry[] - >(() => - activeId - ? [{ key: activeId, lastVisitedAt: Date.now() }] - : [] - ) - const commitView = useCallback( - (next: WorkspaceView): void => { - const previous = viewRef.current - const now = Date.now() - const runningConversationIds = new Set( - [...activeRuns.current.values()].map((run) => run.conversationId) + >(() => (activeId ? [{ key: activeId, lastVisitedAt: Date.now() }] : [])); + const commitView = useCallback((next: WorkspaceView): void => { + const previous = viewRef.current; + const now = Date.now(); + const runningConversationIds = new Set( + [...activeRuns.current.values()].map((run) => run.conversationId), + ); + preparingConversations.current.forEach((conversationId) => + runningConversationIds.add(conversationId), + ); + const protectedWorkspaceViews = new Set(); + if (runningConversationIds.size > 0) { + protectedWorkspaceViews.add("chat"); + protectedWorkspaceViews.add("activity"); + } + if (knowledgeOperationCountRef.current > 0) { + protectedWorkspaceViews.add("knowledge"); + protectedWorkspaceViews.add("activity"); + } + if ( + assistantTasksRef.current.some( + (task) => + task.status === "queued" || + task.status === "running" || + task.status === "waiting_approval", ) - preparingConversations.current.forEach((conversationId) => - runningConversationIds.add(conversationId) - ) - const protectedWorkspaceViews = new Set() - if (runningConversationIds.size > 0) { - protectedWorkspaceViews.add('chat') - protectedWorkspaceViews.add('activity') - } - if (knowledgeOperationCountRef.current > 0) { - protectedWorkspaceViews.add('knowledge') - protectedWorkspaceViews.add('activity') - } - if ( - assistantTasksRef.current.some( - (task) => - task.status === 'queued' || - task.status === 'running' || - task.status === 'waiting_approval' - ) - ) { - protectedWorkspaceViews.add('activity') - } - viewRef.current = next - setCachedWorkspaceViews((current) => - touchAndPruneKeepAliveEntries(current, next, now, { - expiresAfterMs: keepAliveExpirationMs, - maximumEntries: maximumCachedWorkspaceViews, - protectedKeys: protectedWorkspaceViews, - recentEntries: recentCachedWorkspaceViews - }) - ) - setViewState(next) - if (previous === 'settings' && next !== 'settings') { - const preferred = settingsExitFocusRef.current - const returnTarget = settingsEntryFocusRef.current - settingsExitFocusRef.current = undefined - settingsEntryFocusRef.current = undefined - requestAnimationFrame(() => { - const target = - preferred?.isConnected - ? preferred - : returnTarget?.isConnected - ? returnTarget - : next === 'chat' - ? inputRef.current - : document.querySelector( - `.primary-nav [aria-current="page"]` - ) - target?.focus() - }) - } - }, - [] - ) + ) { + protectedWorkspaceViews.add("activity"); + } + viewRef.current = next; + setCachedWorkspaceViews((current) => + touchAndPruneKeepAliveEntries(current, next, now, { + expiresAfterMs: keepAliveExpirationMs, + maximumEntries: maximumCachedWorkspaceViews, + protectedKeys: protectedWorkspaceViews, + recentEntries: recentCachedWorkspaceViews, + }), + ); + setViewState(next); + if (previous === "settings" && next !== "settings") { + const preferred = settingsExitFocusRef.current; + const returnTarget = settingsEntryFocusRef.current; + settingsExitFocusRef.current = undefined; + settingsEntryFocusRef.current = undefined; + requestAnimationFrame(() => { + const target = preferred?.isConnected + ? preferred + : returnTarget?.isConnected + ? returnTarget + : next === "chat" + ? inputRef.current + : document.querySelector( + `.primary-nav [aria-current="page"]`, + ); + target?.focus(); + }); + } + }, []); const setView = useCallback( (update: SetStateAction): void => { const next = - typeof update === 'function' - ? update(viewRef.current) - : update + typeof update === "function" ? update(viewRef.current) : update; if ( - viewRef.current !== 'settings' && - next === 'settings' && + viewRef.current !== "settings" && + next === "settings" && !settingsEntryFocusRef.current?.isConnected ) { - const activeElement = document.activeElement + const activeElement = document.activeElement; settingsEntryFocusRef.current = activeElement instanceof HTMLElement && activeElement !== document.body && activeElement.isConnected ? activeElement - : undefined + : undefined; } - if (viewRef.current === 'settings' && next !== 'settings') { - const requestLeave = settingsLeaveRequesterRef.current + if (viewRef.current === "settings" && next !== "settings") { + const requestLeave = settingsLeaveRequesterRef.current; if (requestLeave) { - requestLeave(() => commitView(next)) - return + requestLeave(() => commitView(next)); + return; } } - commitView(next) + commitView(next); }, - [commitView] - ) + [commitView], + ); const registerSettingsLeaveRequester = useCallback( (requester: SettingsLeaveRequester | undefined): void => { - settingsLeaveRequesterRef.current = requester + settingsLeaveRequesterRef.current = requester; }, - [] - ) + [], + ); const handleShortcutSettingsChanged = useCallback( (snapshot: GlobalShortcutSettingsSnapshot): void => { setAppInfo((current) => current ? { ...current, - shortcut: snapshot.registered - ? snapshot.displayAccelerator - : '', - shortcutStatus: snapshot.status + shortcut: snapshot.registered ? snapshot.displayAccelerator : "", + shortcutStatus: snapshot.status, } - : current - ) + : current, + ); }, - [] - ) - const setActiveId = useCallback( - (update: SetStateAction): void => { - const next = - typeof update === 'function' - ? update(activeConversationIdRef.current) - : update - activeConversationIdRef.current = next - if (next) { - const now = Date.now() - const runningConversationIds = new Set( - [...activeRuns.current.values()].map((run) => run.conversationId) - ) - preparingConversations.current.forEach((conversationId) => - runningConversationIds.add(conversationId) - ) - setCachedConversationViews((current) => - touchAndPruneKeepAliveEntries(current, next, now, { - expiresAfterMs: keepAliveExpirationMs, - maximumEntries: maximumCachedConversations, - protectedKeys: runningConversationIds, - recentEntries: recentCachedConversations - }) - ) - } - setActiveIdState(next) - }, - [] - ) + [], + ); + const setActiveId = useCallback((update: SetStateAction): void => { + const next = + typeof update === "function" + ? update(activeConversationIdRef.current) + : update; + activeConversationIdRef.current = next; + if (next) { + const now = Date.now(); + const runningConversationIds = new Set( + [...activeRuns.current.values()].map((run) => run.conversationId), + ); + preparingConversations.current.forEach((conversationId) => + runningConversationIds.add(conversationId), + ); + setCachedConversationViews((current) => + touchAndPruneKeepAliveEntries(current, next, now, { + expiresAfterMs: keepAliveExpirationMs, + maximumEntries: maximumCachedConversations, + protectedKeys: runningConversationIds, + recentEntries: recentCachedConversations, + }), + ); + } + setActiveIdState(next); + }, []); const [settingsInitialCategory, setSettingsInitialCategory] = - useState() + useState(); const [settingsInitialChannel, setSettingsInitialChannel] = - useState() - const [remoteProjectsEnabled, setRemoteProjectsEnabled] = - useState(false) + useState(); + const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false); const [ conversationHtmlRenderingEnabled, - setConversationHtmlRenderingEnabled - ] = useState(true) - const [magicNotesEnabled, setMagicNotesEnabled] = useState(false) + setConversationHtmlRenderingEnabled, + ] = useState(true); + const [magicNotesEnabled, setMagicNotesEnabled] = useState(false); const [ magicNotesShowIncompleteTodoCount, - setMagicNotesShowIncompleteTodoCount - ] = useState(true) - const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = - useState(0) - const [searchQuery, setSearchQuery] = useState('') - const deferredSearchQuery = useDeferredValue(searchQuery) + setMagicNotesShowIncompleteTodoCount, + ] = useState(true); + const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const deferredSearchQuery = useDeferredValue(searchQuery); const [searchConversationSnapshot, setSearchConversationSnapshot] = - useState(conversations) - const [conversationLoadError, setConversationLoadError] = - useState() - const [conversationLoadRetry, setConversationLoadRetry] = useState(0) - const [conversationActionsId, setConversationActionsId] = useState('') - const [confirmingConversationId, setConfirmingConversationId] = - useState('') - const [deletingConversationId, setDeletingConversationId] = - useState('') - const [branchingConversationId, setBranchingConversationId] = - useState('') - const [renamingConversationId, setRenamingConversationId] = useState('') - const [notifications, notify] = useReducer( - appNotificationReducer, - [] - ) + useState(conversations); + const [conversationLoadError, setConversationLoadError] = useState(); + const [conversationLoadRetry, setConversationLoadRetry] = useState(0); + const [conversationActionsId, setConversationActionsId] = useState(""); + const [confirmingConversationId, setConfirmingConversationId] = useState(""); + const [deletingConversationId, setDeletingConversationId] = useState(""); + const [branchingConversationId, setBranchingConversationId] = useState(""); + const [renamingConversationId, setRenamingConversationId] = useState(""); + const [notifications, notify] = useReducer(appNotificationReducer, []); const handleWindowControlError = useCallback( (message: string): void => { - notify({ tone: 'error', message }) + notify({ tone: "error", message }); }, - [notify] - ) - const [attachmentsByConversation, setAttachmentsByConversation] = - useState>({}) - const attachments = - attachmentsByConversation[activeId] ?? [] - const attachmentsRef = useRef( - new Map() - ) + [notify], + ); + const [attachmentsByConversation, setAttachmentsByConversation] = useState< + Record + >({}); + const attachments = attachmentsByConversation[activeId] ?? []; + const attachmentsRef = useRef(new Map()); const updateAttachments = useCallback( ( update: | ContextAttachment[] - | ((current: ContextAttachment[]) => ContextAttachment[]) + | ((current: ContextAttachment[]) => ContextAttachment[]), ): void => { - const current = attachmentsRef.current.get(activeId) ?? [] - const next = - typeof update === 'function' - ? update(current) - : update + const current = attachmentsRef.current.get(activeId) ?? []; + const next = typeof update === "function" ? update(current) : update; if (next.length > 0) { - attachmentsRef.current.set(activeId, next) + attachmentsRef.current.set(activeId, next); } else { - attachmentsRef.current.delete(activeId) + attachmentsRef.current.delete(activeId); } setAttachmentsByConversation((values) => { if (next.length > 0) { - return { ...values, [activeId]: next } + return { ...values, [activeId]: next }; } - const remaining = { ...values } - delete remaining[activeId] - return remaining - }) + const remaining = { ...values }; + delete remaining[activeId]; + return remaining; + }); }, - [activeId] - ) - const [contextError, setContextError] = useState() + [activeId], + ); + const [contextError, setContextError] = useState(); const [fileSelectionProgress, setFileSelectionProgress] = - useState() - const [selectingContextFiles, setSelectingContextFiles] = - useState(false) - const selectingContextFilesRef = useRef(false) - const [imageViewerItem, setImageViewerItem] = - useState() + useState(); + const [selectingContextFiles, setSelectingContextFiles] = useState(false); + const selectingContextFilesRef = useRef(false); + const [imageViewerItem, setImageViewerItem] = useState(); const [citationDialog, setCitationDialog] = useState<{ - reference: KnowledgeSearchReference - context?: KnowledgeCitationContextView - loading: boolean - error?: string - }>() - const imageViewerTriggerRef = useRef( - undefined - ) - const imageViewerDialogRef = useRef(null) - const imageViewerCloseRef = useRef(null) + reference: KnowledgeSearchReference; + context?: KnowledgeCitationContextView; + loading: boolean; + error?: string; + }>(); + const imageViewerTriggerRef = useRef(undefined); + const imageViewerDialogRef = useRef(null); + const imageViewerCloseRef = useRef(null); useEffect(() => { if (!imageViewerItem) { - return + return; } - return activateModalFocus(() => imageViewerCloseRef.current) - }, [imageViewerItem]) + return activateModalFocus(() => imageViewerCloseRef.current); + }, [imageViewerItem]); useEffect( () => window.goodbuddy.context.onFileSelectionProgress((progress) => { if (selectingContextFilesRef.current) { - setFileSelectionProgress(progress) + setFileSelectionProgress(progress); } }), - [] - ) - const [knowledgeSnapshot, setKnowledgeSnapshot] = useState({ - libraries: [], - sources: [], - documents: [], - graphNodes: [], - graphRelations: [], - evidence: [], - tasks: [] - }) - const [knowledgeLoading, setKnowledgeLoading] = useState(true) - const [knowledgeLoadError, setKnowledgeLoadError] = useState() - const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0) - const knowledgeOperationCountRef = useRef(knowledgeOperationCount) - const knowledgeLoadRequestRef = useRef(0) - const failedKnowledgeLibraryIdRef = useRef( - undefined - ) - const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false) - const knowledgeScopeTriggerRef = useRef(null) - const knowledgeScopePopoverRef = useRef(null) - const [legacyActivityHistory] = useState(loadLegacyActivityHistory) + [], + ); + const [knowledgeSnapshot, setKnowledgeSnapshot] = useState( + { + libraries: [], + sources: [], + documents: [], + graphNodes: [], + graphRelations: [], + evidence: [], + tasks: [], + }, + ); + const [knowledgeLoading, setKnowledgeLoading] = useState(true); + const [knowledgeLoadError, setKnowledgeLoadError] = useState(); + const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0); + const knowledgeOperationCountRef = useRef(knowledgeOperationCount); + const knowledgeLoadRequestRef = useRef(0); + const failedKnowledgeLibraryIdRef = useRef(undefined); + const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false); + const knowledgeScopeTriggerRef = useRef(null); + const knowledgeScopePopoverRef = useRef(null); + const [legacyActivityHistory] = useState(loadLegacyActivityHistory); const [activityRecords, setActivityRecords] = useState( - legacyActivityHistory.records - ) + legacyActivityHistory.records, + ); const [ legacyActivityHistoryMayBeIncomplete, - setLegacyActivityHistoryMayBeIncomplete - ] = useState(legacyActivityHistory.historyMayBeIncomplete) - const [activityHistoryReady, setActivityHistoryReady] = useState(false) - const activityRecordsRef = useRef(activityRecords) + setLegacyActivityHistoryMayBeIncomplete, + ] = useState(legacyActivityHistory.historyMayBeIncomplete); + const [activityHistoryReady, setActivityHistoryReady] = useState(false); + const activityRecordsRef = useRef(activityRecords); const legacyActivityHistoryMayBeIncompleteRef = useRef( - legacyActivityHistoryMayBeIncomplete - ) - const activeRuns = useRef(new Map()) - const preparingConversations = useRef(new Set()) + legacyActivityHistoryMayBeIncomplete, + ); + const activeRuns = useRef(new Map()); + const preparingConversations = useRef(new Set()); const [activeConversationIds, setActiveConversationIds] = useState< ReadonlySet - >(() => new Set()) + >(() => new Set()); const setConversationActivity = useCallback( (conversationId: string, active: boolean): void => { setActiveConversationIds((current) => { if (current.has(conversationId) === active) { - return current + return current; } - const next = new Set(current) + const next = new Set(current); if (active) { - next.add(conversationId) + next.add(conversationId); } else { - next.delete(conversationId) + next.delete(conversationId); } - return next - }) + return next; + }); }, - [] - ) - const hydratingArtifactIds = useRef(new Set()) - const inputRef = useRef(null) + [], + ); + const hydratingArtifactIds = useRef(new Set()); + const inputRef = useRef(null); const [chatScrollSnapshots, setChatScrollSnapshots] = useState< Record - >({}) - const retryMessage = useCallback((content: string): void => { - setInput(content) - inputRef.current?.focus() - }, [setInput]) + >({}); + const retryMessage = useCallback( + (content: string): void => { + setInput(content); + inputRef.current?.focus(); + }, + [setInput], + ); useEffect( () => scheduleIdleRoutePreload( idleRouteModuleLoaders, () => activeRuns.current.size === 0 && - preparingConversations.current.size === 0 + preparingConversations.current.size === 0, ), - [] - ) + [], + ); const [visibleMessageCounts, setVisibleMessageCounts] = useState< Record - >({}) - const sidebarRef = useRef(null) - const sidebarToggleRef = useRef(null) - const livePrimarySidebarWidthRef = useRef(primarySidebarWidth) + >({}); + const sidebarRef = useRef(null); + const sidebarToggleRef = useRef(null); + const livePrimarySidebarWidthRef = useRef(primarySidebarWidth); const primarySidebarResizePointerIdRef = useRef( - undefined - ) - const assistantSidebarToggleRef = useRef(null) + undefined, + ); + const assistantSidebarToggleRef = useRef(null); const conversationActionTriggerRefs = useRef( - new Map() - ) + new Map(), + ); const handleChatScrollSnapshotChange = useCallback( (conversationId: string, snapshot: ChatScrollSnapshot): void => { setChatScrollSnapshots((current) => ({ ...current, - [conversationId]: snapshot - })) + [conversationId]: snapshot, + })); }, - [] - ) + [], + ); const handleVisibleMessageCountChange = useCallback( (conversationId: string, count: number): void => { setVisibleMessageCounts((current) => ({ ...current, - [conversationId]: count - })) + [conversationId]: count, + })); }, - [] - ) + [], + ); const closeNarrowSidebar = useCallback((): void => { - setSidebarOpen(false) - requestAnimationFrame(() => sidebarToggleRef.current?.focus()) - }, []) + setSidebarOpen(false); + requestAnimationFrame(() => sidebarToggleRef.current?.focus()); + }, []); const resizePrimarySidebarFromClientX = useCallback( (clientX: number, commit: boolean): void => { - const width = clampPrimarySidebarWidth( - clientX, - window.innerWidth - ) - livePrimarySidebarWidthRef.current = width + const width = clampPrimarySidebarWidth(clientX, window.innerWidth); + livePrimarySidebarWidthRef.current = width; if (commit) { - setPrimarySidebarWidth(width) - return + setPrimarySidebarWidth(width); + return; } sidebarRef.current?.style.setProperty( - '--primary-sidebar-width', - `${width}px` - ) + "--primary-sidebar-width", + `${width}px`, + ); }, - [] - ) + [], + ); const finishPrimarySidebarResize = useCallback( (event: React.PointerEvent): void => { - if ( - primarySidebarResizePointerIdRef.current !== event.pointerId - ) { - return + if (primarySidebarResizePointerIdRef.current !== event.pointerId) { + return; } - primarySidebarResizePointerIdRef.current = undefined + primarySidebarResizePointerIdRef.current = undefined; if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) + event.currentTarget.releasePointerCapture(event.pointerId); } - setPrimarySidebarWidth(livePrimarySidebarWidthRef.current) - setPrimarySidebarResizing(false) + setPrimarySidebarWidth(livePrimarySidebarWidthRef.current); + setPrimarySidebarResizing(false); }, - [] - ) + [], + ); const resizePrimarySidebarWithKeyboard = useCallback( (event: React.KeyboardEvent): void => { if (narrowWindow || !sidebarOpen) { - return + return; } - const limits = getPrimarySidebarWidthLimits(window.innerWidth) + const limits = getPrimarySidebarWidthLimits(window.innerWidth); const nextWidth = - event.key === 'Home' + event.key === "Home" ? limits.minimum - : event.key === 'End' + : event.key === "End" ? limits.maximum - : event.key === 'ArrowLeft' - ? primarySidebarWidth - - primarySidebarKeyboardResizeStep - : event.key === 'ArrowRight' - ? primarySidebarWidth + - primarySidebarKeyboardResizeStep - : undefined + : event.key === "ArrowLeft" + ? primarySidebarWidth - primarySidebarKeyboardResizeStep + : event.key === "ArrowRight" + ? primarySidebarWidth + primarySidebarKeyboardResizeStep + : undefined; if (nextWidth === undefined) { - return + return; } - event.preventDefault() - const width = clampPrimarySidebarWidth( - nextWidth, - window.innerWidth - ) - livePrimarySidebarWidthRef.current = width - setPrimarySidebarWidth(width) + event.preventDefault(); + const width = clampPrimarySidebarWidth(nextWidth, window.innerWidth); + livePrimarySidebarWidthRef.current = width; + setPrimarySidebarWidth(width); }, - [narrowWindow, primarySidebarWidth, sidebarOpen] - ) + [narrowWindow, primarySidebarWidth, sidebarOpen], + ); const navigateFromSidebar = useCallback( (nextView: WorkspaceView, trigger: HTMLElement): void => { - if (viewRef.current !== 'settings' && nextView === 'settings') { - settingsEntryFocusRef.current = trigger - } else if ( - viewRef.current === 'settings' && - nextView !== 'settings' - ) { - settingsExitFocusRef.current = trigger + if (viewRef.current !== "settings" && nextView === "settings") { + settingsEntryFocusRef.current = trigger; + } else if (viewRef.current === "settings" && nextView !== "settings") { + settingsExitFocusRef.current = trigger; } - setView(nextView) + setView(nextView); if (narrowWindow) { - closeNarrowSidebar() + closeNarrowSidebar(); } }, - [closeNarrowSidebar, narrowWindow, setView] - ) + [closeNarrowSidebar, narrowWindow, setView], + ); useEffect(() => { if (!conversationStoreReady || !projectRecoverySnapshotReady) { - return + return; } const conversationIds = [ ...new Set( @@ -2884,62 +2721,60 @@ function App(): React.JSX.Element { ? projectRecoveryByProjectIdRef.current[ conversation.projectId ] - : undefined - ) + : undefined, + ), ) - .map((conversation) => conversation.id) - ) - ] + .map((conversation) => conversation.id), + ), + ]; void Promise.all( conversationIds.map((conversationId) => - window.goodbuddy.conversationQueue.ready(conversationId) - ) + window.goodbuddy.conversationQueue.ready(conversationId), + ), ).catch(() => { notify({ - tone: 'error', - message: tRef.current( - 'notices.conversationQueueResumeFailed' - ), - dedupeKey: 'conversation-queue-resume' - }) - }) - }, [conversationStoreReady, projectRecoverySnapshotReady]) + tone: "error", + message: tRef.current("notices.conversationQueueResumeFailed"), + dedupeKey: "conversation-queue-resume", + }); + }); + }, [conversationStoreReady, projectRecoverySnapshotReady]); useEffect(() => { const sweep = (): void => { - const now = Date.now() + const now = Date.now(); const conversationIds = new Set( - conversationsRef.current.map((conversation) => conversation.id) - ) + conversationsRef.current.map((conversation) => conversation.id), + ); const runningConversationIds = new Set( - [...activeRuns.current.values()].map((run) => run.conversationId) - ) + [...activeRuns.current.values()].map((run) => run.conversationId), + ); preparingConversations.current.forEach((conversationId) => - runningConversationIds.add(conversationId) - ) - const protectedWorkspaceViews = new Set() + runningConversationIds.add(conversationId), + ); + const protectedWorkspaceViews = new Set(); if (runningConversationIds.size > 0) { - protectedWorkspaceViews.add('chat') - protectedWorkspaceViews.add('activity') + protectedWorkspaceViews.add("chat"); + protectedWorkspaceViews.add("activity"); } if (knowledgeOperationCount > 0) { - protectedWorkspaceViews.add('knowledge') - protectedWorkspaceViews.add('activity') + protectedWorkspaceViews.add("knowledge"); + protectedWorkspaceViews.add("activity"); } if ( assistantTasks.some( (task) => - task.status === 'queued' || - task.status === 'running' || - task.status === 'waiting_approval' + task.status === "queued" || + task.status === "running" || + task.status === "waiting_approval", ) ) { - protectedWorkspaceViews.add('activity') + protectedWorkspaceViews.add("activity"); } setCachedConversationViews((current) => pruneKeepAliveEntries( filterKeepAliveEntries(current, (entry) => - conversationIds.has(entry.key) + conversationIds.has(entry.key), ), { currentKey: activeId, @@ -2947,10 +2782,10 @@ function App(): React.JSX.Element { maximumEntries: maximumCachedConversations, now, protectedKeys: runningConversationIds, - recentEntries: recentCachedConversations - } - ) - ) + recentEntries: recentCachedConversations, + }, + ), + ); setCachedWorkspaceViews((current) => pruneKeepAliveEntries(current, { currentKey: view, @@ -2958,377 +2793,359 @@ function App(): React.JSX.Element { maximumEntries: maximumCachedWorkspaceViews, now, protectedKeys: protectedWorkspaceViews, - recentEntries: recentCachedWorkspaceViews - }) - ) - } - const interval = window.setInterval(sweep, keepAliveSweepIntervalMs) - return () => window.clearInterval(interval) - }, [activeId, assistantTasks, knowledgeOperationCount, view]) + recentEntries: recentCachedWorkspaceViews, + }), + ); + }; + const interval = window.setInterval(sweep, keepAliveSweepIntervalMs); + return () => window.clearInterval(interval); + }, [activeId, assistantTasks, knowledgeOperationCount, view]); useEffect(() => { - livePrimarySidebarWidthRef.current = primarySidebarWidth + livePrimarySidebarWidthRef.current = primarySidebarWidth; try { localStorage.setItem( primarySidebarWidthStorageKey, - String(primarySidebarWidth) - ) + String(primarySidebarWidth), + ); } catch { // The current width remains usable when browser storage is unavailable. } - }, [primarySidebarWidth]) + }, [primarySidebarWidth]); useEffect(() => { const collapseSidebarAtNarrowWidth = (): void => { - const narrow = window.innerWidth < 900 - setNarrowWindow(narrow) + const narrow = window.innerWidth < 900; + setNarrowWindow(narrow); if (narrow) { - setSidebarOpen(false) - setPrimarySidebarResizing(false) - primarySidebarResizePointerIdRef.current = undefined + setSidebarOpen(false); + setPrimarySidebarResizing(false); + primarySidebarResizePointerIdRef.current = undefined; } else { setPrimarySidebarWidth((current) => - clampPrimarySidebarWidth(current, window.innerWidth) - ) + clampPrimarySidebarWidth(current, window.innerWidth), + ); } - } - window.addEventListener('resize', collapseSidebarAtNarrowWidth) + }; + window.addEventListener("resize", collapseSidebarAtNarrowWidth); return () => - window.removeEventListener('resize', collapseSidebarAtNarrowWidth) - }, []) + window.removeEventListener("resize", collapseSidebarAtNarrowWidth); + }, []); useEffect(() => { if (!narrowWindow || !sidebarOpen) { - return + return; } const focusFrame = requestAnimationFrame(() => { sidebarRef.current ?.querySelector( - '.primary-nav button[aria-current="page"], .primary-nav button' + '.primary-nav button[aria-current="page"], .primary-nav button', ) - ?.focus() - }) + ?.focus(); + }); const closeOnEscape = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - event.preventDefault() - closeNarrowSidebar() + if (event.key === "Escape") { + event.preventDefault(); + closeNarrowSidebar(); } - } - document.addEventListener('keydown', closeOnEscape) + }; + document.addEventListener("keydown", closeOnEscape); return () => { - cancelAnimationFrame(focusFrame) - document.removeEventListener('keydown', closeOnEscape) - } - }, [closeNarrowSidebar, narrowWindow, sidebarOpen]) + cancelAnimationFrame(focusFrame); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [closeNarrowSidebar, narrowWindow, sidebarOpen]); useEffect(() => { if (!knowledgeScopeOpen) { - return + return; } const focusFrame = requestAnimationFrame(() => { knowledgeScopePopoverRef.current - ?.querySelector('input') - ?.focus() - }) + ?.querySelector("input") + ?.focus(); + }); const isScopeTarget = (target: EventTarget | null): boolean => target instanceof Node && (knowledgeScopePopoverRef.current?.contains(target) === true || - knowledgeScopeTriggerRef.current?.contains(target) === true) + knowledgeScopeTriggerRef.current?.contains(target) === true); const closeOnOutsidePointer = (event: PointerEvent): void => { if (!isScopeTarget(event.target)) { - setKnowledgeScopeOpen(false) + setKnowledgeScopeOpen(false); } - } + }; const closeOnEscape = (event: KeyboardEvent): void => { - if (event.key !== 'Escape') { - return + if (event.key !== "Escape") { + return; } - event.preventDefault() - setKnowledgeScopeOpen(false) - knowledgeScopeTriggerRef.current?.focus() - } - document.addEventListener('pointerdown', closeOnOutsidePointer) - document.addEventListener('keydown', closeOnEscape) + event.preventDefault(); + setKnowledgeScopeOpen(false); + knowledgeScopeTriggerRef.current?.focus(); + }; + document.addEventListener("pointerdown", closeOnOutsidePointer); + document.addEventListener("keydown", closeOnEscape); return () => { - cancelAnimationFrame(focusFrame) - document.removeEventListener('pointerdown', closeOnOutsidePointer) - document.removeEventListener('keydown', closeOnEscape) - } - }, [knowledgeScopeOpen]) + cancelAnimationFrame(focusFrame); + document.removeEventListener("pointerdown", closeOnOutsidePointer); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [knowledgeScopeOpen]); useLayoutEffect(() => { - conversationsRef.current = conversations - }, [conversations]) + conversationsRef.current = conversations; + }, [conversations]); useEffect(() => { if (!searchQuery.trim()) { - return + return; } const timeout = window.setTimeout( () => setSearchConversationSnapshot(conversations), - conversationSearchSnapshotDelayMs - ) - return () => window.clearTimeout(timeout) - }, [conversations, searchQuery]) + conversationSearchSnapshotDelayMs, + ); + return () => window.clearTimeout(timeout); + }, [conversations, searchQuery]); useEffect(() => { - projectsRef.current = projects - }, [projects]) + projectsRef.current = projects; + }, [projects]); useLayoutEffect(() => { - projectRecoveryByProjectIdRef.current = - projectRecoveryByProjectId - }, [projectRecoveryByProjectId]) + projectRecoveryByProjectIdRef.current = projectRecoveryByProjectId; + }, [projectRecoveryByProjectId]); useEffect(() => { - assistantTasksRef.current = assistantTasks - }, [assistantTasks]) + assistantTasksRef.current = assistantTasks; + }, [assistantTasks]); useLayoutEffect(() => { - resizeComposerTextarea(inputRef.current) - }, [input]) + resizeComposerTextarea(inputRef.current); + }, [input]); useEffect(() => { - saveAppearanceTheme(appearanceTheme) - }, [appearanceTheme]) + saveAppearanceTheme(appearanceTheme); + }, [appearanceTheme]); useEffect(() => { - const updates = window.goodbuddy.updates + const updates = window.goodbuddy.updates; if (!updates || startupUpdateCheckStartedRef.current) { - return + return; } - startupUpdateCheckStartedRef.current = true - let updateCheckSource: 'github' | 'mirror' | undefined + startupUpdateCheckStartedRef.current = true; + let updateCheckSource: "github" | "mirror" | undefined; void updates .getSettings() .then(async (settings) => { setConversationHtmlRenderingEnabled( - settings.conversationHtmlRenderingEnabled !== false - ) - setRemoteProjectsEnabled(settings.remoteProjectsEnabled) - setMagicNotesEnabled(settings.magicNotesEnabled) + settings.conversationHtmlRenderingEnabled !== false, + ); + setRemoteProjectsEnabled(settings.remoteProjectsEnabled); + setMagicNotesEnabled(settings.magicNotesEnabled); setMagicNotesShowIncompleteTodoCount( - settings.magicNotesShowIncompleteTodoCount - ) + settings.magicNotesShowIncompleteTodoCount, + ); if (!settings.magicNotesEnabled) { - setView((current) => - current === 'magic-notes' ? 'chat' : current - ) + setView((current) => (current === "magic-notes" ? "chat" : current)); } if (!settings.checkUpdatesOnStartup) { - return + return; } - updateCheckSource = settings.updateSource - const result = await updates.check() + updateCheckSource = settings.updateSource; + const result = await updates.check(); if (result.updateAvailable) { notify({ - tone: 'info', - message: i18n.t('notices.updateAvailable', { - ns: 'app', - version: result.latestVersion + tone: "info", + message: i18n.t("notices.updateAvailable", { + ns: "app", + version: result.latestVersion, }), - dedupeKey: 'update-available' - }) + dedupeKey: "update-available", + }); } }) .catch((reason: unknown) => { if (!updateCheckSource) { - return + return; } notify({ - tone: 'error', - message: i18n.t('notices.startupUpdateCheckFailed', { - ns: 'app', - source: i18n.t( - `notices.updateSources.${updateCheckSource}`, - { ns: 'app' } - ), + tone: "error", + message: i18n.t("notices.startupUpdateCheckFailed", { + ns: "app", + source: i18n.t(`notices.updateSources.${updateCheckSource}`, { + ns: "app", + }), error: displayNetworkAwareErrorMessage( reason, - i18n.t('notices.updateCheckFailed', { ns: 'app' }), - i18n.t('notices.updateCheckNetwork', { ns: 'app' }) - ) + i18n.t("notices.updateCheckFailed", { ns: "app" }), + i18n.t("notices.updateCheckNetwork", { ns: "app" }), + ), }), - dedupeKey: 'startup-update-check' - }) - }) - }, [i18n, setView]) + dedupeKey: "startup-update-check", + }); + }); + }, [i18n, setView]); useEffect(() => { - const magicNotes = window.goodbuddy.magicNotes - if ( - !magicNotesEnabled || - !magicNotesShowIncompleteTodoCount - ) { - return + const magicNotes = window.goodbuddy.magicNotes; + if (!magicNotesEnabled || !magicNotesShowIncompleteTodoCount) { + return; } - let active = true - let requestId = 0 + let active = true; + let requestId = 0; const refresh = async (): Promise => { - const currentRequestId = ++requestId + const currentRequestId = ++requestId; try { - const status = await magicNotes.getTodoStatus() + const status = await magicNotes.getTodoStatus(); if (active && requestId === currentRequestId) { - setIncompleteMagicTodoCount(status.incompleteCount) + setIncompleteMagicTodoCount(status.incompleteCount); } } catch { if (active && requestId === currentRequestId) { - setIncompleteMagicTodoCount(0) + setIncompleteMagicTodoCount(0); } } - } + }; const removeListener = magicNotes.onTodoStatusChanged(() => { - void refresh() - }) - void refresh() + void refresh(); + }); + void refresh(); return () => { - active = false - removeListener() - } - }, [ - magicNotesEnabled, - magicNotesShowIncompleteTodoCount - ]) + active = false; + removeListener(); + }; + }, [magicNotesEnabled, magicNotesShowIncompleteTodoCount]); useEffect(() => { - const releaseNotesApi = window.goodbuddy.releaseNotes + const releaseNotesApi = window.goodbuddy.releaseNotes; if (!releaseNotesApi || startupReleaseNotesStartedRef.current) { - return + return; } - startupReleaseNotesStartedRef.current = true + startupReleaseNotesStartedRef.current = true; void releaseNotesApi .getPending() .then((snapshot) => { if (snapshot.releases.length > 0) { - setReleaseNotes(snapshot) + setReleaseNotes(snapshot); } }) - .catch(() => undefined) - }, []) + .catch(() => undefined); + }, []); useEffect(() => { - applyAppearanceTheme(resolvedAppearanceTheme) - }, [resolvedAppearanceTheme]) + applyAppearanceTheme(resolvedAppearanceTheme); + }, [resolvedAppearanceTheme]); useEffect(() => { - voiceDisposedRef.current = false + voiceDisposedRef.current = false; return () => { - voiceDisposedRef.current = true - voiceRecordingRef.current?.cancel() - const requestId = voiceRequestIdRef.current + voiceDisposedRef.current = true; + voiceRecordingRef.current?.cancel(); + const requestId = voiceRequestIdRef.current; if (requestId) { - void window.goodbuddy.speech?.cancel(requestId) + void window.goodbuddy.speech?.cancel(requestId); } - } - }, []) + }; + }, []); useEffect(() => { - if (appearanceTheme !== 'system') { - return + if (appearanceTheme !== "system") { + return; } - if (typeof window.matchMedia !== 'function') { - return + if (typeof window.matchMedia !== "function") { + return; } - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); const updateSystemTheme = (): void => { - setSystemPrefersDark(systemTheme.matches) - } - updateSystemTheme() - systemTheme.addEventListener('change', updateSystemTheme) + setSystemPrefersDark(systemTheme.matches); + }; + updateSystemTheme(); + systemTheme.addEventListener("change", updateSystemTheme); return () => { - systemTheme.removeEventListener('change', updateSystemTheme) - } - }, [appearanceTheme]) + systemTheme.removeEventListener("change", updateSystemTheme); + }; + }, [appearanceTheme]); useEffect(() => { - if (typeof window.matchMedia !== 'function') { - return + if (typeof window.matchMedia !== "function") { + return; } - const compactLayout = window.matchMedia('(max-width: 1279px)') + const compactLayout = window.matchMedia("(max-width: 1279px)"); const closeCompactAssistantSidebar = (): void => { if (compactLayout.matches) { - setAssistantSidebarOpen(false) + setAssistantSidebarOpen(false); } - } - closeCompactAssistantSidebar() - compactLayout.addEventListener('change', closeCompactAssistantSidebar) + }; + closeCompactAssistantSidebar(); + compactLayout.addEventListener("change", closeCompactAssistantSidebar); return () => { - compactLayout.removeEventListener( - 'change', - closeCompactAssistantSidebar - ) - } - }, []) + compactLayout.removeEventListener("change", closeCompactAssistantSidebar); + }; + }, []); const activeConversation = useMemo( () => conversations.find((conversation) => conversation.id === activeId), - [activeId, conversations] - ) + [activeId, conversations], + ); const enabledKnowledgeLibraryIds = - activeConversation?.knowledgeLibraryIds ?? [] + activeConversation?.knowledgeLibraryIds ?? []; const setEnabledKnowledgeLibraryIds = useCallback( (action: SetStateAction): void => { setConversations((current) => current.map((conversation) => { if (conversation.id !== activeId) { - return conversation + return conversation; } - const previous = conversation.knowledgeLibraryIds ?? [] - const next = - typeof action === 'function' ? action(previous) : action + const previous = conversation.knowledgeLibraryIds ?? []; + const next = typeof action === "function" ? action(previous) : action; if ( previous.length === next.length && previous.every((id, index) => id === next[index]) ) { - return conversation + return conversation; } return { ...conversation, knowledgeLibraryIds: next, - updatedAt: Date.now() - } - }) - ) + updatedAt: Date.now(), + }; + }), + ); }, - [activeId] - ) + [activeId], + ); const activeProject = useMemo( () => projects.find((project) => project.id === activeProjectId), - [activeProjectId, projects] - ) - const activeProjectUsesManagedSsh = - isManagedSshProject(activeProject) + [activeProjectId, projects], + ); + const activeProjectUsesManagedSsh = isManagedSshProject(activeProject); const activeProjectRecovery = activeProjectUsesManagedSsh && activeProject ? projectRecoveryByProjectId[activeProject.id] - : undefined + : undefined; const activeProjectRecoveryBlocked = activeProjectUsesManagedSsh && (!projectRecoverySnapshotReady || - isProjectRecoveryUnsettled(activeProjectRecovery)) + isProjectRecoveryUnsettled(activeProjectRecovery)); const cachedWorkspaceViewKeys = useMemo( () => new Set(cachedWorkspaceViews.map((entry) => entry.key)), - [cachedWorkspaceViews] - ) + [cachedWorkspaceViews], + ); const cachedConversations = useMemo(() => { const conversationById = new Map( - conversations.map((conversation) => [ - conversation.id, - conversation - ]) - ) + conversations.map((conversation) => [conversation.id, conversation]), + ); const cachedIds = [ activeId, - ...cachedConversationViews.map((entry) => entry.key) + ...cachedConversationViews.map((entry) => entry.key), ].filter( (conversationId, index, values) => - conversationId && values.indexOf(conversationId) === index - ) + conversationId && values.indexOf(conversationId) === index, + ); return cachedIds.flatMap((conversationId) => { - const conversation = conversationById.get(conversationId) - return conversation ? [conversation] : [] - }) - }, [activeId, cachedConversationViews, conversations]) + const conversation = conversationById.get(conversationId); + return conversation ? [conversation] : []; + }); + }, [activeId, cachedConversationViews, conversations]); const activeRuntimeSelection = useMemo( () => @@ -3336,541 +3153,544 @@ function App(): React.JSX.Element { (runtimeSettings ? getDefaultRuntimeSelection(runtimeSettings) : undefined), - [activeConversation?.runtimeSelection, runtimeSettings] - ) + [activeConversation?.runtimeSelection, runtimeSettings], + ); const activeRuntimeSelectionKey = activeRuntimeSelection ? agentRuntimeSelectionKey(activeRuntimeSelection) - : '' - const activeRuntimeSelectionRef = useRef(activeRuntimeSelection) + : ""; + const activeRuntimeSelectionRef = useRef(activeRuntimeSelection); useEffect(() => { - activeRuntimeSelectionRef.current = activeRuntimeSelection - }, [activeRuntimeSelection]) + activeRuntimeSelectionRef.current = activeRuntimeSelection; + }, [activeRuntimeSelection]); const runtimeLabels = useMemo( () => ({ - directModel: t('runtime.directModel'), - automatic: t('runtime.automatic'), - automaticSelection: t('runtime.automaticSelection'), - modelUnavailable: t('runtime.modelUnavailable') + directModel: t("runtime.directModel"), + automatic: t("runtime.automatic"), + automaticSelection: t("runtime.automaticSelection"), + modelUnavailable: t("runtime.modelUnavailable"), }), - [t] - ) + [t], + ); const configuredRuntimeLabels = useMemo( () => ({ - modelUnavailable: t('runtime.modelUnavailable'), - selectModel: t('runtime.selectModel'), - ownConfiguration: t('runtime.ownConfiguration'), + modelUnavailable: t("runtime.modelUnavailable"), + selectModel: t("runtime.selectModel"), + ownConfiguration: t("runtime.ownConfiguration"), useOwnConfiguration: (runtimeLabel: string) => - t('runtime.useOwnConfiguration', { runtime: runtimeLabel }) + t("runtime.useOwnConfiguration", { runtime: runtimeLabel }), }), - [t] - ) + [t], + ); const activeRuntimeLabel = getRuntimeSelectionLabel( activeRuntimeSelection, runtimeSettings, runtime, - runtimeLabels - ) + runtimeLabels, + ); const openCodeMenuSelection = runtimeSettings - ? getRuntimeSelectionForProvider('opencode', runtimeSettings) - : undefined + ? getRuntimeSelectionForProvider("opencode", runtimeSettings) + : undefined; const continueMenuSelection = runtimeSettings - ? getRuntimeSelectionForProvider('continue', runtimeSettings) - : undefined + ? getRuntimeSelectionForProvider("continue", runtimeSettings) + : undefined; const deepseekHarnessMenuSelection = runtimeSettings - ? getRuntimeSelectionForProvider( - 'deepseek-harness', - runtimeSettings - ) - : undefined + ? getRuntimeSelectionForProvider("deepseek-harness", runtimeSettings) + : undefined; const openCodeMenuSource = runtimeSettings ? getConfiguredAgentRuntimeSource( runtimeSettings, - 'opencode', - configuredRuntimeLabels + "opencode", + configuredRuntimeLabels, ) - : undefined + : undefined; const continueMenuSource = runtimeSettings ? getConfiguredAgentRuntimeSource( runtimeSettings, - 'continue', - configuredRuntimeLabels + "continue", + configuredRuntimeLabels, ) - : undefined + : undefined; const deepseekHarnessMenuSource = runtimeSettings ? getConfiguredAgentRuntimeSource( runtimeSettings, - 'deepseek-harness', - configuredRuntimeLabels + "deepseek-harness", + configuredRuntimeLabels, ) - : undefined + : undefined; // Conversation refreshes replace the conversation object, so the runtime // selection identity changes without the selection itself changing. Keying // this effect on the resolved scope keeps the runtime controls mounted // instead of clearing and refetching the snapshot on every refresh. const runtimeNativeScopeKey = [ - activeConversation?.remote ? 'remote' : 'local', - activeProjectUsesManagedSsh ? 'managed-ssh' : 'direct', - activeProjectId ?? '', - activeRuntimeSelectionKey - ].join('\u0000') - const runtimeNativeScopeKeyRef = useRef(undefined) + activeConversation?.remote ? "remote" : "local", + activeProjectUsesManagedSsh ? "managed-ssh" : "direct", + activeProjectId ?? "", + activeRuntimeSelectionKey, + ].join("\u0000"); + const runtimeNativeScopeKeyRef = useRef(undefined); + + useEffect(() => { + runtimeSwitchGenerationRef.current += 1; + const generation = runtimeSwitchGenerationRef.current; + queueMicrotask(() => { + if (runtimeSwitchGenerationRef.current === generation) { + setRuntimeSwitching(false); + } + }); + }, [activeId]); + + useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) { + setSelectedRuntimeAgent(""); + setSelectedRuntimeCommand(""); + setSelectedContinuePreset(""); + } + }); + return () => { cancelled = true; }; + }, [activeId, runtimeNativeScopeKey]); useEffect(() => { if (runtimeNativeScopeKeyRef.current === runtimeNativeScopeKey) { - return + return; } - runtimeNativeScopeKeyRef.current = runtimeNativeScopeKey - const selection = activeRuntimeSelectionRef.current - const requestId = runtimeCustomizationRequestRef.current + 1 - runtimeCustomizationRequestRef.current = requestId + if ( + runtimeNativeScopeKeyRef.current !== undefined && + runtimeNativeScopeKeyRef.current !== runtimeNativeScopeKey + ) { + runtimeNativeRetryScopesRef.current.delete(runtimeNativeScopeKey); + } + runtimeNativeScopeKeyRef.current = runtimeNativeScopeKey; + const selection = activeRuntimeSelectionRef.current; + const cached = runtimeNativeCacheRef.current.get(runtimeNativeScopeKey); + const requestId = runtimeCustomizationRequestRef.current + 1; + runtimeCustomizationRequestRef.current = requestId; queueMicrotask(() => { if (runtimeCustomizationRequestRef.current !== requestId) { - return + return; } - setRuntimeNativeSnapshot(undefined) - setRuntimeCustomization(undefined) - setSelectedRuntimeAgent('') - setSelectedRuntimeCommand('') - setSelectedContinuePreset('') - }) + setRuntimeNativeSnapshot(cached?.snapshot); + setRuntimeCustomization(cached?.customization); + }); if ( !selection || activeConversation?.remote || activeProjectUsesManagedSsh || - (selection.provider !== 'opencode' && - selection.provider !== 'continue') + (selection.provider !== "opencode" && selection.provider !== "continue") ) { - return + return; } - const provider = selection.provider + const provider = selection.provider; void Promise.all([ window.goodbuddy.runtimeCustomization.getSettings(), window.goodbuddy.runtimeCustomization.getNativeSnapshot({ provider, - ...('profileId' in selection && selection.profileId + ...("profileId" in selection && selection.profileId ? { profileId: selection.profileId } : {}), - ...(activeProjectId ? { projectId: activeProjectId } : {}) - }) + ...(activeProjectId ? { projectId: activeProjectId } : {}), + }), ]) .then(([customization, snapshot]) => { if (runtimeCustomizationRequestRef.current !== requestId) { - return + return; } - setRuntimeCustomization(customization) - setRuntimeNativeSnapshot(snapshot) - setSelectedContinuePreset('') + if ( + snapshot.inventoryStatus === "unavailable" || + snapshot.inventoryStatus === "partial" + ) { + // Keep a healthy cached inventory; otherwise retain usable partial data. + runtimeNativeCacheRef.current.set( + runtimeNativeScopeKey, + cached?.snapshot.available ? cached : { customization, snapshot }, + ); + throw new Error(snapshot.detail); + } + setRuntimeCustomization(customization); + setRuntimeNativeSnapshot(snapshot); + runtimeNativeCacheRef.current.set(runtimeNativeScopeKey, { + customization, + snapshot, + }); + runtimeNativeRetryScopesRef.current.delete(runtimeNativeScopeKey); }) .catch(() => { if (runtimeCustomizationRequestRef.current === requestId) { - setRuntimeCustomization(undefined) - setRuntimeNativeSnapshot(undefined) + const retained = runtimeNativeCacheRef.current.get(runtimeNativeScopeKey); + setRuntimeCustomization(retained?.customization); + setRuntimeNativeSnapshot(retained?.snapshot); + runtimeNativeScopeKeyRef.current = undefined; + if (!runtimeNativeRetryScopesRef.current.has(runtimeNativeScopeKey)) { + runtimeNativeRetryScopesRef.current.add(runtimeNativeScopeKey); + setRuntimeNativeRetry((value) => value + 1); + } } - }) + }); }, [ activeConversation?.remote, activeProjectUsesManagedSsh, activeProjectId, - runtimeNativeScopeKey - ]) + runtimeNativeRetry, + runtimeNativeScopeKey, + ]); - const runtimeAgentOptions = useMemo< - ComposerMenuOption[] - >(() => { + const runtimeAgentOptions = useMemo[]>(() => { if ( - activeRuntimeSelection?.provider !== 'opencode' || + activeRuntimeSelection?.provider !== "opencode" || !runtimeNativeSnapshot ) { - return [] + return []; } - const configuredDefault = - runtimeCustomization?.opencode.defaultAgent + const configuredDefault = runtimeCustomization?.opencode.defaultAgent; return [ { - value: '', + value: "", label: configuredDefault - ? t('composer.runtimeControls.configuredAgent', { - name: configuredDefault + ? t("composer.runtimeControls.configuredAgent", { + name: configuredDefault, }) - : t('composer.runtimeControls.runtimeDefaultAgent'), + : t("composer.runtimeControls.runtimeDefaultAgent"), description: t( - 'composer.runtimeControls.runtimeDefaultAgentDescription' - ) + "composer.runtimeControls.runtimeDefaultAgentDescription", + ), }, ...runtimeNativeSnapshot.agents .filter( (agent) => - !agent.hidden && - (agent.mode === 'primary' || agent.mode === 'all') + !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), ) .map((agent) => ({ value: agent.id, label: agent.name, description: - agent.description ?? - t('composer.runtimeControls.agentDescription') - })) - ] + agent.description ?? t("composer.runtimeControls.agentDescription"), + })), + ]; }, [ activeRuntimeSelection?.provider, runtimeCustomization?.opencode.defaultAgent, runtimeNativeSnapshot, - t - ]) + t, + ]); - const runtimePresetOptions = useMemo< - ComposerMenuOption[] - >(() => { + const runtimePresetOptions = useMemo[]>(() => { if ( - activeRuntimeSelection?.provider !== 'continue' || + activeRuntimeSelection?.provider !== "continue" || !runtimeCustomization ) { - return [] + return []; } return [ { - value: '', - label: t('composer.runtimeControls.noPreset'), - description: t( - 'composer.runtimeControls.noPresetDescription' - ) + value: "", + label: t("composer.runtimeControls.noPreset"), + description: t("composer.runtimeControls.noPresetDescription"), }, ...runtimeCustomization.continue.presets.map((preset) => ({ value: preset.id, label: preset.name, description: preset.description ?? - t('composer.runtimeControls.presetDescription', { + t("composer.runtimeControls.presetDescription", { rules: preset.rules.filter((rule) => rule.enabled).length, - prompts: preset.prompts.length - }) - })) - ] - }, [ - activeRuntimeSelection?.provider, - runtimeCustomization, - t - ]) + prompts: preset.prompts.length, + }), + })), + ]; + }, [activeRuntimeSelection?.provider, runtimeCustomization, t]); const runtimeActionOptions = useMemo(() => { if (!runtimeNativeSnapshot) { - return [] + return []; } - const nativePrompts = runtimeNativeSnapshot.prompts + const nativePrompts = runtimeNativeSnapshot.prompts; const selectedPreset = - activeRuntimeSelection?.provider === 'continue' + activeRuntimeSelection?.provider === "continue" ? runtimeCustomization?.continue.presets.find( (preset) => preset.id === (selectedContinuePreset || - runtimeCustomization.continue.defaultPresetId) + runtimeCustomization.continue.defaultPresetId), ) - : undefined + : undefined; return [ { - value: '', - label: t('composer.runtimeControls.noAction'), - description: t( - 'composer.runtimeControls.noActionDescription' - ) + value: "", + label: t("composer.runtimeControls.noAction"), + description: t("composer.runtimeControls.noActionDescription"), }, - ...(activeRuntimeSelection?.provider === 'opencode' + ...(activeRuntimeSelection?.provider === "opencode" ? runtimeNativeSnapshot.commands.map((command) => ({ - value: JSON.stringify(['command', command.id]), + value: JSON.stringify(["command", command.id]), label: `/${command.name}`, description: command.description ?? - t('composer.runtimeControls.commandDescription'), + t("composer.runtimeControls.commandDescription"), action: { - type: 'command' as const, - id: command.id - } + type: "command" as const, + id: command.id, + }, })) : []), ...nativePrompts.map((prompt) => ({ - value: JSON.stringify(['native-prompt', prompt.id]), + value: JSON.stringify(["native-prompt", prompt.id]), label: prompt.name, description: - prompt.description ?? - t('composer.runtimeControls.promptDescription'), + prompt.description ?? t("composer.runtimeControls.promptDescription"), action: { - type: 'prompt' as const, - prompt: prompt.prompt - } + type: "prompt" as const, + prompt: prompt.prompt, + }, })), ...(selectedPreset?.prompts.map((prompt) => ({ - value: JSON.stringify([ - 'preset-prompt', - selectedPreset.id, - prompt.id - ]), + value: JSON.stringify(["preset-prompt", selectedPreset.id, prompt.id]), label: prompt.name, description: - prompt.description ?? - t('composer.runtimeControls.promptDescription'), + prompt.description ?? t("composer.runtimeControls.promptDescription"), action: { - type: 'prompt' as const, - prompt: prompt.prompt - } - })) ?? []) - ] + type: "prompt" as const, + prompt: prompt.prompt, + }, + })) ?? []), + ]; }, [ activeRuntimeSelection?.provider, runtimeCustomization, runtimeNativeSnapshot, selectedContinuePreset, - t - ]) + t, + ]); const selectRuntimeAction = useCallback( (value: string): void => { if (!value) { - setSelectedRuntimeCommand('') - return + setSelectedRuntimeCommand(""); + return; } const choice = runtimeActionOptions.find( - (candidate) => candidate.value === value - ) - if (choice?.action?.type === 'command') { - setSelectedRuntimeCommand(choice.action.id) - return + (candidate) => candidate.value === value, + ); + if (choice?.action?.type === "command") { + setSelectedRuntimeCommand(choice.action.id); + return; } - if (choice?.action?.type === 'prompt') { - setInput(choice.action.prompt) - setSelectedRuntimeCommand('') + if (choice?.action?.type === "prompt") { + setInput(choice.action.prompt); + setSelectedRuntimeCommand(""); requestAnimationFrame(() => { - resizeComposerTextarea(inputRef.current) - inputRef.current?.focus() - }) + resizeComposerTextarea(inputRef.current); + inputRef.current?.focus(); + }); } }, - [runtimeActionOptions, setInput] - ) + [runtimeActionOptions, setInput], + ); useEffect(() => { if (!runtimeMenuOpen) { - return + return; } - const menu = runtimeMenuRef.current + const menu = runtimeMenuRef.current; if (!menu) { - return + return; } const menuItems = Array.from( menu.querySelectorAll( - '[role="menuitemradio"], [role="menuitem"]' - ) - ).filter((item) => !item.disabled) + '[role="menuitemradio"], [role="menuitem"]', + ), + ).filter((item) => !item.disabled); const initialItem = - menuItems.find( - (item) => item.getAttribute('aria-checked') === 'true' - ) ?? menuItems[0] + menuItems.find((item) => item.getAttribute("aria-checked") === "true") ?? + menuItems[0]; menuItems.forEach((item) => { - item.tabIndex = item === initialItem ? 0 : -1 - }) + item.tabIndex = item === initialItem ? 0 : -1; + }); const focusFrame = requestAnimationFrame(() => { - initialItem?.focus() - }) + initialItem?.focus(); + }); const isRuntimeMenuTarget = (target: EventTarget | null): boolean => target instanceof Node && (menu.contains(target) || - runtimeMenuButtonRef.current?.contains(target) === true) + runtimeMenuButtonRef.current?.contains(target) === true); const dismissOnOutsidePointer = (event: PointerEvent): void => { if (!isRuntimeMenuTarget(event.target)) { - setRuntimeMenuOpen(false) + setRuntimeMenuOpen(false); } - } + }; const dismissOnOutsideFocus = (event: FocusEvent): void => { if (!isRuntimeMenuTarget(event.target)) { - setRuntimeMenuOpen(false) + setRuntimeMenuOpen(false); } - } - document.addEventListener('pointerdown', dismissOnOutsidePointer) - document.addEventListener('focusin', dismissOnOutsideFocus) + }; + document.addEventListener("pointerdown", dismissOnOutsidePointer); + document.addEventListener("focusin", dismissOnOutsideFocus); return () => { - cancelAnimationFrame(focusFrame) - document.removeEventListener( - 'pointerdown', - dismissOnOutsidePointer - ) - document.removeEventListener('focusin', dismissOnOutsideFocus) - } - }, [activeRuntimeSelectionKey, runtimeMenuOpen]) + cancelAnimationFrame(focusFrame); + document.removeEventListener("pointerdown", dismissOnOutsidePointer); + document.removeEventListener("focusin", dismissOnOutsideFocus); + }; + }, [activeRuntimeSelectionKey, runtimeMenuOpen]); const conversationNavigationRef = useRef({ activeId, - conversations - }) + conversations, + }); useEffect(() => { conversationNavigationRef.current = { activeId, - conversations - } - }, [activeId, conversations]) + conversations, + }; + }, [activeId, conversations]); useEffect(() => { - const selection = activeRuntimeSelectionRef.current + const selection = activeRuntimeSelectionRef.current; if (!selection || !runtimeSettings) { - return + return; } if ( - runtimeStatusCacheRef.current?.key === - activeRuntimeSelectionKey && + runtimeStatusCacheRef.current?.key === activeRuntimeSelectionKey && runtimeStatusCacheRef.current.settings === runtimeSettings ) { - return + return; } runtimeStatusCacheRef.current = { key: activeRuntimeSelectionKey, - settings: runtimeSettings - } - const requestId = runtimeStatusRequestRef.current + 1 - runtimeStatusRequestRef.current = requestId - setRuntimeSwitching(false) - setRuntimeStatusKey('') + settings: runtimeSettings, + }; + const requestId = runtimeStatusRequestRef.current + 1; + runtimeStatusRequestRef.current = requestId; + setRuntimeSwitching(false); + setRuntimeStatusKey(""); void window.goodbuddy.agent .getStatus(selection) .then((status) => { if (runtimeStatusRequestRef.current !== requestId) { - return + return; } - setRuntime(status) - setRuntimeStatusKey(activeRuntimeSelectionKey) + setRuntime(status); + setRuntimeStatusKey(activeRuntimeSelectionKey); if (!status.available && !runtimeSetupPromptedRef.current) { - runtimeSetupPromptedRef.current = true - setView('settings') + runtimeSetupPromptedRef.current = true; + setView("settings"); } }) .catch((reason: unknown) => { if (runtimeStatusRequestRef.current !== requestId) { - return + return; } setRuntime({ - id: 'setup', - label: tRef.current('runtime.unavailable'), + id: "setup", + label: tRef.current("runtime.unavailable"), available: false, supportsToolExecution: false, detail: reason instanceof Error ? reason.message - : tRef.current('runtime.errors.readStatus') - }) - setRuntimeStatusKey(activeRuntimeSelectionKey) - }) - }, [ - activeRuntimeSelectionKey, - runtimeSettings, - setView - ]) + : tRef.current("runtime.errors.readStatus"), + }); + setRuntimeStatusKey(activeRuntimeSelectionKey); + }); + }, [activeRuntimeSelectionKey, runtimeSettings, setView]); const startNewConversation = useCallback( (projectId?: string): boolean => { - const project = projects.find( - (candidate) => candidate.id === projectId - ) - if (project?.kind === 'channel') { - setView('chat') + const project = projects.find((candidate) => candidate.id === projectId); + if (project?.kind === "channel") { + setView("chat"); notify({ - tone: 'info', - message: tRef.current('notices.channelConversationAutomatic'), - dedupeKey: 'channel-project-new-conversation' - }) - return false + tone: "info", + message: tRef.current("notices.channelConversationAutomatic"), + dedupeKey: "channel-project-new-conversation", + }); + return false; } - const navigation = conversationNavigationRef.current + const navigation = conversationNavigationRef.current; const currentConversation = navigation.conversations.find( - (conversation) => conversation.id === navigation.activeId - ) + (conversation) => conversation.id === navigation.activeId, + ); if ( currentConversation && currentConversation.projectId === projectId && isUnusedConversation(currentConversation) ) { - setView('chat') - requestAnimationFrame(() => inputRef.current?.focus()) - return true + setView("chat"); + requestAnimationFrame(() => inputRef.current?.focus()); + return true; } const conversation = createConversation( projectId, runtimeSettings ? getProjectDefaultRuntimeSelection(project, runtimeSettings) : undefined, - tRef.current('conversation.greeting') - ) - const nextConversations = [ - conversation, - ...navigation.conversations - ] + tRef.current("conversation.greeting"), + ); + const nextConversations = [conversation, ...navigation.conversations]; conversationNavigationRef.current = { activeId: conversation.id, - conversations: nextConversations - } - setConversations(nextConversations) - setActiveId(conversation.id) - setView('chat') - requestAnimationFrame(() => inputRef.current?.focus()) - return true + conversations: nextConversations, + }; + setConversations(nextConversations); + setActiveId(conversation.id); + setView("chat"); + requestAnimationFrame(() => inputRef.current?.focus()); + return true; }, - [notify, projects, runtimeSettings, setActiveId, setView] - ) + [notify, projects, runtimeSettings, setActiveId, setView], + ); const activeProjectDisplayName = activeProject ? getProjectDisplayText(activeProject, tWorkspace).name - : undefined + : undefined; const queuedConversationIds = useMemo( - () => - new Set( - conversationQueueItems.map((item) => item.conversationId) - ), - [conversationQueueItems] - ) + () => new Set(conversationQueueItems.map((item) => item.conversationId)), + [conversationQueueItems], + ); const filteredConversations = useMemo(() => { - const query = deferredSearchQuery.trim().toLocaleLowerCase() - const candidates = query - ? searchConversationSnapshot - : conversations + const query = deferredSearchQuery.trim().toLocaleLowerCase(); + const candidates = query ? searchConversationSnapshot : conversations; return candidates.filter( (conversation) => - (!activeProjectId || - conversation.projectId === activeProjectId) && - (activeProject?.kind !== 'channel' || + (!activeProjectId || conversation.projectId === activeProjectId) && + (activeProject?.kind !== "channel" || conversation.remote !== undefined) && (!query || - conversation.title.toLocaleLowerCase().includes(query) || - conversation.messages.some((message) => - message.content.toLocaleLowerCase().includes(query) - )) - ) + conversation.title.toLocaleLowerCase().includes(query) || + conversation.messages.some((message) => + message.content.toLocaleLowerCase().includes(query), + )), + ); }, [ activeProject, activeProjectId, conversations, deferredSearchQuery, - searchConversationSnapshot - ]) + searchConversationSnapshot, + ]); const productAssistantTasks = useMemo( () => assistantTasks.filter( - (task) => !task.parentTaskId && task.origin === 'schedule' + (task) => !task.parentTaskId && task.origin === "schedule", ), - [assistantTasks] - ) + [assistantTasks], + ); const tasksByConversation = useMemo(() => { - const grouped = new Map() + const grouped = new Map(); for (const task of productAssistantTasks) { if (!task.conversationId) { - continue + continue; } - const existing = grouped.get(task.conversationId) ?? [] - existing.push(task) - grouped.set(task.conversationId, existing) + const existing = grouped.get(task.conversationId) ?? []; + existing.push(task); + grouped.set(task.conversationId, existing); } for (const tasks of grouped.values()) { tasks.sort((left, right) => - right.createdAt.localeCompare(left.createdAt) - ) + right.createdAt.localeCompare(left.createdAt), + ); } - return grouped - }, [productAssistantTasks]) + return grouped; + }, [productAssistantTasks]); const conversationTitles = useMemo( () => new Map( @@ -3878,22 +3698,22 @@ function App(): React.JSX.Element { conversation.id, getConversationDisplayTitle( conversation, - t('conversation.defaultTitle') - ) - ]) + t("conversation.defaultTitle"), + ), + ]), ), - [conversations, t] - ) + [conversations, t], + ); const projectNames = useMemo( () => new Map( projects.map((project) => [ project.id, - getProjectDisplayText(project, tWorkspace).name - ]) + getProjectDisplayText(project, tWorkspace).name, + ]), ), - [projects, tWorkspace] - ) + [projects, tWorkspace], + ); const pendingSidebarApprovals = useMemo( () => conversations.flatMap((conversation) => @@ -3906,56 +3726,55 @@ function App(): React.JSX.Element { approvalId: message.approval.id, title: message.approval.title, description: message.approval.description, - toolName: message.approval.toolName - } + toolName: message.approval.toolName, + }, ] - : [] - ) + : [], + ), ), - [conversations] - ) + [conversations], + ); const sidebarArtifacts = useMemo( () => assistantArtifacts .filter( (artifact) => - !activeProjectId || artifact.projectId === activeProjectId + !activeProjectId || artifact.projectId === activeProjectId, ) .map((artifact) => ({ id: artifact.id, title: artifact.title, - content: artifact.content ?? '', + content: artifact.content ?? "", createdAt: new Date(artifact.createdAt).getTime(), - mimeType: artifact.mimeType + mimeType: artifact.mimeType, })), - [activeProjectId, assistantArtifacts] - ) + [activeProjectId, assistantArtifacts], + ); const pendingHeartbeatSuggestionCount = useMemo(() => { const memoryIds = new Set( - heartbeatEntries.flatMap((entry) => entry.proposedMemoryIds) - ) + heartbeatEntries.flatMap((entry) => entry.proposedMemoryIds), + ); const taskIds = new Set( - heartbeatEntries.flatMap((entry) => entry.followUpTaskIds) - ) + heartbeatEntries.flatMap((entry) => entry.followUpTaskIds), + ); return ( heartbeatMemories.filter( - (memory) => - memoryIds.has(memory.id) && memory.status === 'proposed' + (memory) => memoryIds.has(memory.id) && memory.status === "proposed", ).length + assistantTasks.filter( (task) => taskIds.has(task.id) && - task.status !== 'completed' && - task.status !== 'cancelled' + task.status !== "completed" && + task.status !== "cancelled", ).length - ) - }, [assistantTasks, heartbeatEntries, heartbeatMemories]) + ); + }, [assistantTasks, heartbeatEntries, heartbeatMemories]); const updateMessage = useCallback( ( conversationId: string, messageId: string, - update: (message: Message) => Message + update: (message: Message) => Message, ): void => { setConversations((current) => current.map((conversation) => @@ -3964,88 +3783,88 @@ function App(): React.JSX.Element { ...conversation, updatedAt: Date.now(), messages: conversation.messages.map((message) => - message.id === messageId ? update(message) : message - ) + message.id === messageId ? update(message) : message, + ), } - : conversation - ) - ) + : conversation, + ), + ); }, - [] - ) + [], + ); const recordActivity = useCallback( ( - record: Omit, - scopeOverride?: ActivityRecord['scope'] + record: Omit, + scopeOverride?: ActivityRecord["scope"], ): void => { const conversation = conversationsRef.current.find( - (candidate) => candidate.id === record.conversationId - ) + (candidate) => candidate.id === record.conversationId, + ); const project = conversation?.projectId ? projectsRef.current.find( - (candidate) => candidate.id === conversation.projectId + (candidate) => candidate.id === conversation.projectId, ) - : undefined - const scope: ActivityRecord['scope'] = + : undefined; + const scope: ActivityRecord["scope"] = scopeOverride ?? (!conversation - ? { kind: 'unavailable' } + ? { kind: "unavailable" } : !conversation.projectId - ? { kind: 'global' } + ? { kind: "global" } : project?.id && project.name ? { - kind: 'project', + kind: "project", projectId: project.id.slice(0, 256), - projectName: project.name.slice(0, 120) + projectName: project.name.slice(0, 120), } - : { kind: 'unavailable' }) + : { kind: "unavailable" }); setActivityRecords((current) => upsertActivityRecord(current, { ...record, title: record.title.slice(0, 240), scope, id: crypto.randomUUID(), - createdAt: Date.now() - }) - ) + createdAt: Date.now(), + }), + ); }, - [] - ) + [], + ); const updateRequestActivity = useCallback( ( requestId: string, - status: ActivityRecord['status'], - detail?: string + status: ActivityRecord["status"], + detail?: string, ): void => { setActivityRecords((current) => current.map((record) => - record.requestId === requestId && record.kind === 'request' + record.requestId === requestId && record.kind === "request" ? { ...record, status, - detail: detail ?? record.detail + detail: detail ?? record.detail, } - : record - ) - ) + : record, + ), + ); }, - [] - ) + [], + ); useEffect(() => { - const api = window.goodbuddy.channels + const api = window.goodbuddy.channels; if (!api) { - return + return; } return api.onRemoteActivity((activity) => { - if (activity.kind === 'result') { + if (activity.kind === "result") { updateRequestActivity( activity.requestId, activity.status, - activity.detail - ) + activity.detail, + ); } recordActivity( { @@ -4055,74 +3874,74 @@ function App(): React.JSX.Element { kind: activity.kind, title: activity.title, detail: activity.detail, - status: activity.status + status: activity.status, }, { - kind: 'project', + kind: "project", projectId: activity.projectId.slice(0, 256), - projectName: activity.projectName.slice(0, 120) - } - ) - }) - }, [recordActivity, updateRequestActivity]) + projectName: activity.projectName.slice(0, 120), + }, + ); + }); + }, [recordActivity, updateRequestActivity]); const refreshKnowledge = useCallback( async (libraryId?: string): Promise => { - const requestId = ++knowledgeLoadRequestRef.current + const requestId = ++knowledgeLoadRequestRef.current; try { const snapshot = - await window.goodbuddy.knowledge.getSnapshot(libraryId) + await window.goodbuddy.knowledge.getSnapshot(libraryId); if (requestId !== knowledgeLoadRequestRef.current) { - return snapshot + return snapshot; } - failedKnowledgeLibraryIdRef.current = undefined - setKnowledgeSnapshot(snapshot) - setKnowledgeLoadError(undefined) + failedKnowledgeLibraryIdRef.current = undefined; + setKnowledgeSnapshot(snapshot); + setKnowledgeLoadError(undefined); const availableIds = new Set( - snapshot.libraries.map((library) => library.id) - ) + snapshot.libraries.map((library) => library.id), + ); setConversations((current) => current.map((conversation) => { - const previous = conversation.knowledgeLibraryIds ?? [] - const next = previous.filter((id) => availableIds.has(id)) + const previous = conversation.knowledgeLibraryIds ?? []; + const next = previous.filter((id) => availableIds.has(id)); return previous.length === next.length ? conversation : { ...conversation, knowledgeLibraryIds: next, - updatedAt: Date.now() - } - }) - ) - return snapshot + updatedAt: Date.now(), + }; + }), + ); + return snapshot; } catch (reason) { if (requestId !== knowledgeLoadRequestRef.current) { - throw reason + throw reason; } - failedKnowledgeLibraryIdRef.current = libraryId + failedKnowledgeLibraryIdRef.current = libraryId; setKnowledgeLoadError( displayErrorMessage( reason, - tRef.current('notices.knowledgeReadFailed') - ) - ) - throw reason + tRef.current("notices.knowledgeReadFailed"), + ), + ); + throw reason; } }, - [] - ) + [], + ); const retryKnowledgeLoad = useCallback(async (): Promise => { - setKnowledgeLoading(true) - setKnowledgeLoadError(undefined) + setKnowledgeLoading(true); + setKnowledgeLoadError(undefined); try { - await refreshKnowledge(failedKnowledgeLibraryIdRef.current) + await refreshKnowledge(failedKnowledgeLibraryIdRef.current); } catch { // The recoverable page state is set by refreshKnowledge. } finally { - setKnowledgeLoading(false) + setKnowledgeLoading(false); } - }, [refreshKnowledge]) + }, [refreshKnowledge]); const switchRuntime = useCallback( async (selection: AgentRuntimeSelection): Promise => { @@ -4130,73 +3949,82 @@ function App(): React.JSX.Element { !runtimeSettings || !activeConversation || runtimeSwitching || - (activeProjectUsesManagedSsh && - selection.provider !== 'opencode') + (activeProjectUsesManagedSsh && selection.provider !== "opencode") ) { - return + return; } - runtimeMenuButtonRef.current?.focus() - setRuntimeSwitching(true) - setRuntimeMenuOpen(false) - const requestId = runtimeStatusRequestRef.current + 1 - runtimeStatusRequestRef.current = requestId + runtimeMenuButtonRef.current?.focus(); + setRuntimeSwitching(true); + setRuntimeMenuOpen(false); + const requestId = runtimeStatusRequestRef.current + 1; + runtimeStatusRequestRef.current = requestId; + const generation = runtimeSwitchGenerationRef.current; try { - const status = await window.goodbuddy.agent.getStatus(selection) - if (runtimeStatusRequestRef.current !== requestId) { - return + const status = await window.goodbuddy.agent.getStatus(selection); + if ( + runtimeStatusRequestRef.current !== requestId || + runtimeSwitchGenerationRef.current !== generation + ) { + return; } - const selectionKey = agentRuntimeSelectionKey(selection) + const selectionKey = agentRuntimeSelectionKey(selection); runtimeStatusCacheRef.current = { key: selectionKey, - settings: runtimeSettings - } + settings: runtimeSettings, + }; const label = getRuntimeSelectionLabel( selection, runtimeSettings, status, - runtimeLabels - ) + runtimeLabels, + ); setConversations((current) => current.map((conversation) => conversation.id === activeConversation.id ? { ...conversation, runtimeSelection: selection, - updatedAt: Date.now() + updatedAt: Date.now(), } - : conversation - ) - ) - setRuntime(status) - setRuntimeStatusKey(selectionKey) + : conversation, + ), + ); + setRuntime(status); + setRuntimeStatusKey(selectionKey); notify({ - tone: status.available ? 'success' : 'error', + tone: status.available ? "success" : "error", message: status.available - ? tRef.current('runtime.switched', { label }) - : tRef.current('runtime.selectionUnavailable', { + ? tRef.current("runtime.switched", { label }) + : tRef.current("runtime.selectionUnavailable", { label, - detail: status.detail + detail: status.detail, }), - dedupeKey: 'runtime-switch' - }) + dedupeKey: "runtime-switch", + }); } catch (reason) { - if (runtimeStatusRequestRef.current !== requestId) { - return + if ( + runtimeStatusRequestRef.current !== requestId || + runtimeSwitchGenerationRef.current !== generation + ) { + return; } notify({ - tone: 'error', + tone: "error", message: reason instanceof Error ? reason.message - : tRef.current('runtime.errors.switch'), - dedupeKey: 'runtime-switch' - }) + : tRef.current("runtime.errors.switch"), + dedupeKey: "runtime-switch", + }); } finally { - if (runtimeStatusRequestRef.current === requestId) { - setRuntimeSwitching(false) + if ( + runtimeStatusRequestRef.current === requestId && + runtimeSwitchGenerationRef.current === generation + ) { + setRuntimeSwitching(false); requestAnimationFrame(() => { - runtimeMenuButtonRef.current?.focus() - }) + runtimeMenuButtonRef.current?.focus(); + }); } } }, @@ -4205,105 +4033,97 @@ function App(): React.JSX.Element { activeProjectUsesManagedSsh, runtimeLabels, runtimeSettings, - runtimeSwitching - ] - ) + runtimeSwitching, + ], + ); const refreshTokenUsage = useCallback(async (): Promise => { - setTokenUsage(await window.goodbuddy.usage.getTokenSummary()) - }, []) + setTokenUsage(await window.goodbuddy.usage.getTokenSummary()); + }, []); const loadWorkspaceChanges = useCallback( async (projectId: string): Promise => { - const requestId = workspaceChangesRequestRef.current + 1 - workspaceChangesRequestRef.current = requestId - const changes = await window.goodbuddy.workspace.getChanges(projectId) + const requestId = workspaceChangesRequestRef.current + 1; + workspaceChangesRequestRef.current = requestId; + const changes = await window.goodbuddy.workspace.getChanges(projectId); if ( workspaceChangesRequestRef.current === requestId && activeProjectIdRef.current === projectId ) { - setWorkspaceChanges(changes) + setWorkspaceChanges(changes); } }, - [] - ) + [], + ); const releaseConversationQueueAfterRun = useCallback( - (run: Pick): void => { + (run: Pick): void => { requestAnimationFrame(() => { if ( run.projectId && ((!projectRecoverySnapshotReadyRef.current && isManagedSshProject( projectsRef.current.find( - (project) => project.id === run.projectId - ) + (project) => project.id === run.projectId, + ), )) || isProjectRecoveryUnsettled( - projectRecoveryByProjectIdRef.current[run.projectId] + projectRecoveryByProjectIdRef.current[run.projectId], )) ) { - return + return; } void window.goodbuddy.conversationQueue .ready(run.conversationId) .catch(() => { notify({ - tone: 'error', - message: tRef.current( - 'notices.conversationQueueResumeFailed' - ), - dedupeKey: 'conversation-queue-resume' - }) - }) - }) + tone: "error", + message: tRef.current("notices.conversationQueueResumeFailed"), + dedupeKey: "conversation-queue-resume", + }); + }); + }); }, - [notify] - ) + [notify], + ); const handleAgentEvent = useCallback( (event: AgentEvent): void => { - const run = activeRuns.current.get(event.requestId) + const run = activeRuns.current.get(event.requestId); if (!run) { - if (event.type !== 'approval') { - return + if (event.type !== "approval") { + return; } const attachScheduledApproval = ( - task: AssistantTask | undefined + task: AssistantTask | undefined, ): void => { - if ( - !task?.conversationId || - task.origin !== 'schedule' - ) { - return + if (!task?.conversationId || task.origin !== "schedule") { + return; } setAssistantTasks((current) => current.map((candidate) => candidate.id === task.id - ? { ...candidate, status: 'waiting_approval' } - : candidate - ) - ) - if ( - activeConversationIdRef.current !== task.conversationId - ) { + ? { ...candidate, status: "waiting_approval" } + : candidate, + ), + ); + if (activeConversationIdRef.current !== task.conversationId) { setUnreadConversationIds((current) => { - const next = new Set(current) - next.add(task.conversationId!) - return next - }) + const next = new Set(current); + next.add(task.conversationId!); + return next; + }); } setConversations((current) => current.map((conversation) => { if (conversation.id !== task.conversationId) { - return conversation + return conversation; } const existing = conversation.messages.find( - (message) => - message.approval?.id === event.approvalId - ) + (message) => message.approval?.id === event.approvalId, + ); if (existing) { - return conversation + return conversation; } return { ...conversation, @@ -4312,13 +4132,13 @@ function App(): React.JSX.Element { ...conversation.messages, { id: crypto.randomUUID(), - role: 'assistant', + role: "assistant", content: event.title, createdAt: Date.now(), - state: 'complete', + state: "complete", task: { id: task.id, - title: task.title + title: task.title, }, approval: { id: event.approvalId, @@ -4326,175 +4146,162 @@ function App(): React.JSX.Element { description: event.description, toolName: event.toolName, argumentSummary: event.argumentSummary, - allowPermanent: event.allowPermanent - } - } - ] - } - }) - ) - } + allowPermanent: event.allowPermanent, + }, + }, + ], + }; + }), + ); + }; const task = assistantTasksRef.current.find( - (candidate) => candidate.id === event.requestId - ) + (candidate) => candidate.id === event.requestId, + ); if (task) { - attachScheduledApproval(task) + attachScheduledApproval(task); } else { void window.goodbuddy.tasks .list() .then((tasks) => { - setAssistantTasks(tasks) - assistantTasksRef.current = tasks + setAssistantTasks(tasks); + assistantTasksRef.current = tasks; attachScheduledApproval( - tasks.find( - (candidate) => candidate.id === event.requestId - ) - ) + tasks.find((candidate) => candidate.id === event.requestId), + ); }) - .catch(() => undefined) + .catch(() => undefined); } - return + return; } setAssistantTasks((current) => { - let changed = false + let changed = false; const updated = current.map((task) => { if (task.id !== event.requestId) { - return task + return task; } - const status: AssistantTask['status'] = - event.type === 'approval' || event.type === 'question' - ? 'waiting_approval' - : event.type === 'done' - ? 'completed' - : event.type === 'error' + const status: AssistantTask["status"] = + event.type === "approval" || event.type === "question" + ? "waiting_approval" + : event.type === "done" + ? "completed" + : event.type === "error" ? event.status - : 'running' + : "running"; const completedAt = - event.type === 'done' || event.type === 'error' + event.type === "done" || event.type === "error" ? new Date().toISOString() - : task.completedAt - const error = - event.type === 'error' ? event.message : task.error + : task.completedAt; + const error = event.type === "error" ? event.message : task.error; if ( task.status === status && task.completedAt === completedAt && task.error === error ) { - return task + return task; } - changed = true + changed = true; return { ...task, status, completedAt, - error - } - }) - return changed ? updated : current - }) - if (event.type === 'done') { - if ( - run.projectId && - activeProjectIdRef.current === run.projectId - ) { + error, + }; + }); + return changed ? updated : current; + }); + if (event.type === "done") { + if (run.projectId && activeProjectIdRef.current === run.projectId) { void loadWorkspaceChanges(run.projectId).catch(() => notify({ - tone: 'error', - message: tRef.current( - 'notices.workspaceChangesReadFailed' - ) - }) - ) + tone: "error", + message: tRef.current("notices.workspaceChangesReadFailed"), + }), + ); } - if (viewRef.current === 'activity') { + if (viewRef.current === "activity") { void refreshTokenUsage().catch(() => notify({ - tone: 'error', - message: tRef.current('notices.tokenUsageReadFailed') - }) - ) + tone: "error", + message: tRef.current("notices.tokenUsageReadFailed"), + }), + ); } void window.goodbuddy.artifacts .list() .then((artifacts) => setAssistantArtifacts((current) => - mergeArtifacts(current, artifacts) - ) + mergeArtifacts(current, artifacts), + ), ) .catch(() => notify({ - tone: 'error', - message: tRef.current('notices.resultsRefreshFailed') - }) - ) - } else if (event.type === 'artifact') { - hydratingArtifactIds.current.add(event.artifactId) + tone: "error", + message: tRef.current("notices.resultsRefreshFailed"), + }), + ); + } else if (event.type === "artifact") { + hydratingArtifactIds.current.add(event.artifactId); void window.goodbuddy.artifacts .get(event.artifactId) .then((artifact) => setAssistantArtifacts((current) => - mergeArtifacts(current, [artifact]) - ) + mergeArtifacts(current, [artifact]), + ), ) .catch(() => notify({ - tone: 'error', - message: tRef.current( - 'notices.generatedImageReadFailed' - ) - }) + tone: "error", + message: tRef.current("notices.generatedImageReadFailed"), + }), ) .finally(() => { - hydratingArtifactIds.current.delete(event.artifactId) - }) + hydratingArtifactIds.current.delete(event.artifactId); + }); } - if (event.type === 'text') { + if (event.type === "text") { updateMessage(run.conversationId, run.messageId, (message) => { const remaining = Math.max( 0, - maxMessageContentLength - message.content.length - ) - const acceptedDelta = event.delta.slice(0, remaining) + maxMessageContentLength - message.content.length, + ); + const acceptedDelta = event.delta.slice(0, remaining); const blocks = appendMessageContentBlock( message.blocks, - 'text', - acceptedDelta - ) + "text", + acceptedDelta, + ); return { ...message, content: `${message.content}${acceptedDelta}`, blocks, status: event.delta.length > remaining - ? tRef.current('chat.status.responseTruncated') - : undefined - } - }) - } else if (event.type === 'reasoning') { + ? tRef.current("chat.status.responseTruncated") + : undefined, + }; + }); + } else if (event.type === "reasoning") { updateMessage(run.conversationId, run.messageId, (message) => { - const currentReasoning = message.reasoning ?? '' + const currentReasoning = message.reasoning ?? ""; const acceptedDelta = event.delta.slice( 0, - Math.max( - 0, - maxMessageContentLength - currentReasoning.length - ) - ) + Math.max(0, maxMessageContentLength - currentReasoning.length), + ); const blocks = appendMessageContentBlock( message.blocks, - 'reasoning', - acceptedDelta - ) + "reasoning", + acceptedDelta, + ); return { ...message, reasoning: `${currentReasoning}${acceptedDelta}`, status: undefined, - blocks - } - }) - } else if (event.type === 'context-metrics') { + blocks, + }; + }); + } else if (event.type === "context-metrics") { setConversations((current) => current.map((conversation) => conversation.id === run.conversationId @@ -4503,56 +4310,49 @@ function App(): React.JSX.Element { contextMetrics: { contextTokens: event.contextTokens, source: event.source, - basis: 'model-call', - runtimeSelectionKey: run.runtimeSelectionKey - } + basis: "model-call", + runtimeSelectionKey: run.runtimeSelectionKey, + }, } - : conversation - ) - ) - } else if (event.type === 'context-compression') { - const estimatedAfterTokens = event.estimatedAfterTokens - const conversationScoped = event.scope !== 'agent-run' - const scope = event.scope ?? 'conversation' + : conversation, + ), + ); + } else if (event.type === "context-compression") { + const estimatedAfterTokens = event.estimatedAfterTokens; + const conversationScoped = event.scope !== "agent-run"; + const scope = event.scope ?? "conversation"; const marker: ConversationContextCompressionMarker = { - state: - event.state === 'started' - ? 'compressing' - : event.state, - scope, - estimatedBeforeTokens: event.estimatedBeforeTokens, - estimatedAfterTokens: event.estimatedAfterTokens, - compressionCount: event.compressionCount - } + state: event.state === "started" ? "compressing" : event.state, + scope, + estimatedBeforeTokens: event.estimatedBeforeTokens, + estimatedAfterTokens: event.estimatedAfterTokens, + compressionCount: event.compressionCount, + }; updateMessage(run.conversationId, run.messageId, (message) => { const current = message.contextCompressions ?? - (message.contextCompression - ? [message.contextCompression] - : []) + (message.contextCompression ? [message.contextCompression] : []); const existingIndex = current.findIndex( - (compression) => - (compression.scope ?? 'conversation') === scope - ) + (compression) => (compression.scope ?? "conversation") === scope, + ); const contextCompressions = existingIndex >= 0 ? [ ...current.filter( - (_compression, index) => - index !== existingIndex + (_compression, index) => index !== existingIndex, ), - marker + marker, ] - : [...current, marker] + : [...current, marker]; return { ...message, contextCompression: undefined, - contextCompressions - } - }) + contextCompressions, + }; + }); if ( conversationScoped && - event.state === 'completed' && + event.state === "completed" && estimatedAfterTokens !== undefined ) { setConversations((current) => @@ -4563,60 +4363,55 @@ function App(): React.JSX.Element { contextMetrics: { runtimeSelectionKey: run.runtimeSelectionKey, contextTokens: estimatedAfterTokens, - source: 'estimated', - basis: 'conversation' + source: "estimated", + basis: "conversation", }, contextCompressionState: event.conversationState ?? - conversation.contextCompressionState + conversation.contextCompressionState, } - : conversation - ) - ) - } else if ( - conversationScoped && - event.conversationState - ) { + : conversation, + ), + ); + } else if (conversationScoped && event.conversationState) { setConversations((current) => current.map((conversation) => conversation.id === run.conversationId ? { ...conversation, - contextCompressionState: event.conversationState + contextCompressionState: event.conversationState, } - : conversation - ) - ) + : conversation, + ), + ); } - } else if (event.type === 'status') { + } else if (event.type === "status") { updateMessage(run.conversationId, run.messageId, (message) => ({ ...message, - status: event.message - })) - } else if (event.type === 'tool') { + status: event.message, + })); + } else if (event.type === "tool") { recordActivity({ conversationId: run.conversationId, requestId: event.requestId, callId: event.callId.slice(0, 256), - kind: 'tool', + kind: "tool", title: event.name, - detail: [event.summary, event.error] - .filter(Boolean) - .join('\n'), + detail: [event.summary, event.error].filter(Boolean).join("\n"), status: - event.state === 'pending' - ? 'pending' - : event.state === 'running' - ? 'running' - : event.state === 'failed' - ? 'failed' - : 'completed' - }) + event.state === "pending" + ? "pending" + : event.state === "running" + ? "running" + : event.state === "failed" + ? "failed" + : "completed", + }); updateMessage(run.conversationId, run.messageId, (message) => { - const tools = [...(message.tools ?? [])] + const tools = [...(message.tools ?? [])]; const index = tools.findIndex( - (tool) => tool.callId === event.callId.slice(0, 256) - ) + (tool) => tool.callId === event.callId.slice(0, 256), + ); const tool = { callId: event.callId.slice(0, 256), name: event.name, @@ -4624,45 +4419,45 @@ function App(): React.JSX.Element { summary: event.summary, input: event.input, output: event.output, - error: event.error - } + error: event.error, + }; if (index >= 0) { - tools[index] = tool + tools[index] = tool; } else { - tools.push(tool) + tools.push(tool); } - const blocks = upsertMessageToolBlock(message.blocks, tool) + const blocks = upsertMessageToolBlock(message.blocks, tool); return { ...message, tools, - blocks - } - }) - } else if (event.type === 'subagent') { + blocks, + }; + }); + } else if (event.type === "subagent") { const actor = - 'actor' in event + "actor" in event ? event.actor : { - kind: 'expert' as const, + kind: "expert" as const, expertId: event.expertId, - expertName: event.expertName - } + expertName: event.expertName, + }; const actorLabel = - actor.kind === 'direct-model' - ? tRef.current('chat.subagents.directModelLabel') - : actor.expertName - const childStatus = event.state + actor.kind === "direct-model" + ? tRef.current("chat.subagents.directModelLabel") + : actor.expertName; + const childStatus = event.state; const completedAt = - event.state === 'completed' || - event.state === 'failed' || - event.state === 'cancelled' + event.state === "completed" || + event.state === "failed" || + event.state === "cancelled" ? new Date().toISOString() - : undefined - if (actor.kind === 'expert') { + : undefined; + if (actor.kind === "expert") { setAssistantTasks((current) => { const existing = current.find( - (task) => task.id === event.childTaskId - ) + (task) => task.id === event.childTaskId, + ); const childTask: AssistantTask = { id: event.childTaskId, projectId: run.projectId, @@ -4670,32 +4465,29 @@ function App(): React.JSX.Element { parentTaskId: event.requestId, expertId: actor.expertId, routingMode: - event.routingMode === 'native' - ? undefined - : event.routingMode, + event.routingMode === "native" ? undefined : event.routingMode, title: actor.expertName, instructions: event.reason ?? - tRef.current('chat.subagents.fallbackTask', { - name: actor.expertName + tRef.current("chat.subagents.fallbackTask", { + name: actor.expertName, }), - origin: 'subagent', + origin: "subagent", status: childStatus, - createdAt: - existing?.createdAt ?? new Date().toISOString(), + createdAt: existing?.createdAt ?? new Date().toISOString(), startedAt: - event.state === 'running' - ? existing?.startedAt ?? new Date().toISOString() + event.state === "running" + ? (existing?.startedAt ?? new Date().toISOString()) : existing?.startedAt, completedAt: completedAt ?? existing?.completedAt, - error: event.error - } + error: event.error, + }; return existing ? current.map((task) => - task.id === event.childTaskId ? childTask : task + task.id === event.childTaskId ? childTask : task, ) - : [...current, childTask] - }) + : [...current, childTask]; + }); } if (event.runtimeCallId) { setActivityRecords((current) => @@ -4703,47 +4495,43 @@ function App(): React.JSX.Element { (record) => !( record.requestId === event.requestId && - record.kind === 'tool' && + record.kind === "tool" && record.callId === event.runtimeCallId - ) - ) - ) + ), + ), + ); } recordActivity({ conversationId: run.conversationId, requestId: event.requestId, callId: event.childTaskId, - kind: 'subagent', + kind: "subagent", title: actorLabel, detail: [ - actor.kind === 'direct-model' - ? tRef.current('chat.subagents.directModel') - : event.routingMode === 'smart' - ? tRef.current('chat.subagents.smart') - : event.routingMode === 'native' - ? tRef.current('chat.subagents.native') - : tRef.current('chat.subagents.manual'), - actor.kind === 'direct-model' && event.workMode - ? event.workMode === 'execute' - ? 'Execute' - : 'Ask' + actor.kind === "direct-model" + ? tRef.current("chat.subagents.directModel") + : event.routingMode === "smart" + ? tRef.current("chat.subagents.smart") + : event.routingMode === "native" + ? tRef.current("chat.subagents.native") + : tRef.current("chat.subagents.manual"), + actor.kind === "direct-model" && event.workMode + ? event.workMode === "execute" + ? "Execute" + : "Ask" : undefined, event.reason, - event.error + event.error, ] .filter(Boolean) - .join(' · '), - status: - event.state === 'queued' - ? 'pending' - : event.state - }) + .join(" · "), + status: event.state === "queued" ? "pending" : event.state, + }); updateMessage(run.conversationId, run.messageId, (message) => { - const subagents = [...(message.subagents ?? [])] + const subagents = [...(message.subagents ?? [])]; const index = subagents.findIndex( - (subagent) => - subagent.childTaskId === event.childTaskId - ) + (subagent) => subagent.childTaskId === event.childTaskId, + ); const commonSubagent = { childTaskId: event.childTaskId, routingMode: event.routingMode, @@ -4753,47 +4541,45 @@ function App(): React.JSX.Element { reason: event.reason, progress: event.progress, output: event.output, - error: event.error - } + error: event.error, + }; const subagent: SubagentActivity = - 'actor' in event + "actor" in event ? { ...commonSubagent, actor: event.actor } : { ...commonSubagent, expertId: event.expertId, - expertName: event.expertName - } + expertName: event.expertName, + }; if (index >= 0) { - subagents[index] = subagent + subagents[index] = subagent; } else { - subagents.push(subagent) + subagents.push(subagent); } - const runtimeCallId = event.runtimeCallId + const runtimeCallId = event.runtimeCallId; const blocks = upsertMessageSubagentBlock( message.blocks, event.childTaskId, - runtimeCallId - ) + runtimeCallId, + ); return { ...message, subagents, tools: runtimeCallId - ? message.tools?.filter( - (tool) => tool.callId !== runtimeCallId - ) + ? message.tools?.filter((tool) => tool.callId !== runtimeCallId) : message.tools, - blocks - } - }) - } else if (event.type === 'approval') { + blocks, + }; + }); + } else if (event.type === "approval") { recordActivity({ conversationId: run.conversationId, requestId: event.requestId, - kind: 'approval', + kind: "approval", title: event.title, detail: event.description, - status: 'pending' - }) + status: "pending", + }); updateMessage(run.conversationId, run.messageId, (message) => ({ ...message, status: undefined, @@ -4803,24 +4589,24 @@ function App(): React.JSX.Element { description: event.description, toolName: event.toolName, argumentSummary: event.argumentSummary, - allowPermanent: event.allowPermanent - } - })) - } else if (event.type === 'question') { + allowPermanent: event.allowPermanent, + }, + })); + } else if (event.type === "question") { updateMessage(run.conversationId, run.messageId, (message) => ({ ...message, status: undefined, - question: event - })) - } else if (event.type === 'artifact') { + question: event, + })); + } else if (event.type === "artifact") { updateMessage(run.conversationId, run.messageId, (message) => ({ ...message, artifactIds: [ - ...new Set([...(message.artifactIds ?? []), event.artifactId]) + ...new Set([...(message.artifactIds ?? []), event.artifactId]), ].slice(-8), - status: tRef.current('chat.status.savingImage') - })) - } else if (event.type === 'knowledge-retrieval') { + status: tRef.current("chat.status.savingImage"), + })); + } else if (event.type === "knowledge-retrieval") { updateMessage(run.conversationId, run.messageId, (message) => ({ ...message, knowledgeRetrieval: { @@ -4830,216 +4616,201 @@ function App(): React.JSX.Element { resultCount: event.resultCount, durationMs: event.durationMs, usedChannels: event.usedChannels, - warnings: event.warnings + warnings: event.warnings, }, status: - event.state === 'searching' - ? tRef.current('chat.knowledgeRetrieval.searching') - : undefined - })) - } else if (event.type === 'source-references') { + event.state === "searching" + ? tRef.current("chat.knowledgeRetrieval.searching") + : undefined, + })); + } else if (event.type === "source-references") { updateMessage(run.conversationId, run.messageId, (message) => { - const referenceKey = ( - reference: KnowledgeSearchReference - ): string => + const referenceKey = (reference: KnowledgeSearchReference): string => [ reference.libraryId, reference.documentId, - reference.chunkId ?? '', - reference.locator ?? '', - reference.snippet - ].join('\0') + reference.chunkId ?? "", + reference.locator ?? "", + reference.snippet, + ].join("\0"); const incoming = [ ...new Map( event.references.map((reference) => [ referenceKey(reference), - reference - ]) - ).values() - ] - const incomingKeys = new Set(incoming.map(referenceKey)) + reference, + ]), + ).values(), + ]; + const incomingKeys = new Set(incoming.map(referenceKey)); const references = [ ...incoming, ...(message.sourceReferences ?? []).filter( - (reference) => !incomingKeys.has(referenceKey(reference)) - ) - ].slice(0, 20) + (reference) => !incomingKeys.has(referenceKey(reference)), + ), + ].slice(0, 20); return { ...message, - sourceReferences: references - } - }) + sourceReferences: references, + }; + }); } else { const terminalStatus = - event.type === 'error' - ? event.status === 'cancelled' - ? 'cancelled' - : 'failed' - : 'completed' + event.type === "error" + ? event.status === "cancelled" + ? "cancelled" + : "failed" + : "completed"; const incompleteSubagentError = tRef.current( - 'chat.subagents.incomplete' - ) + "chat.subagents.incomplete", + ); const incompleteActivityDetail = tRef.current( - 'chat.status.activityIncomplete' - ) + "chat.status.activityIncomplete", + ); updateRequestActivity( event.requestId, terminalStatus, - event.type === 'error' + event.type === "error" ? event.message - : tRef.current('chat.status.taskCompleted') - ) + : tRef.current("chat.status.taskCompleted"), + ); setActivityRecords((current) => current.map((record) => record.requestId === event.requestId && - record.kind !== 'request' && - (record.status === 'pending' || - record.status === 'running') + record.kind !== "request" && + (record.status === "pending" || record.status === "running") ? { ...record, status: - event.type === 'done' - ? 'interrupted' - : terminalStatus, + event.type === "done" ? "interrupted" : terminalStatus, detail: `${record.detail}\n${ - event.type === 'done' + event.type === "done" ? incompleteActivityDetail : event.message - }` + }`, } - : record - ) - ) + : record, + ), + ); recordActivity({ conversationId: run.conversationId, requestId: event.requestId, - kind: 'result', + kind: "result", title: - event.type === 'error' - ? tRef.current('chat.status.taskFailed') - : tRef.current('chat.status.taskCompleted'), + event.type === "error" + ? tRef.current("chat.status.taskFailed") + : tRef.current("chat.status.taskCompleted"), detail: - event.type === 'error' + event.type === "error" ? event.message - : tRef.current('chat.status.runtimeCompleted'), - status: terminalStatus - }) + : tRef.current("chat.status.runtimeCompleted"), + status: terminalStatus, + }); setAssistantTasks((current) => current.map((task) => task.parentTaskId === event.requestId && - (task.status === 'queued' || task.status === 'running') + (task.status === "queued" || task.status === "running") ? { ...task, - status: - event.type === 'done' - ? 'failed' - : terminalStatus, + status: event.type === "done" ? "failed" : terminalStatus, completedAt: new Date().toISOString(), error: - event.type === 'error' + event.type === "error" ? event.message - : incompleteSubagentError + : incompleteSubagentError, } - : task - ) - ) + : task, + ), + ); updateMessage(run.conversationId, run.messageId, (message) => { const representedToolError = - event.type === 'error' && - isErrorRepresentedByFailedTool( - message.tools, - event.message - ) + event.type === "error" && + isErrorRepresentedByFailedTool(message.tools, event.message); const toolTerminalState = - event.type === 'error' - ? event.status === 'cancelled' - ? ('cancelled' as const) - : ('failed' as const) - : ('interrupted' as const) + event.type === "error" + ? event.status === "cancelled" + ? ("cancelled" as const) + : ("failed" as const) + : ("interrupted" as const); const fallbackError = - event.type === 'error' && - !representedToolError && - !message.content + event.type === "error" && !representedToolError && !message.content ? event.message.slice(0, maxMessageContentLength) - : '' + : ""; return { ...message, - state: event.type === 'error' ? 'error' : 'complete', + state: event.type === "error" ? "error" : "complete", status: - event.type === 'error' && !representedToolError + event.type === "error" && !representedToolError ? event.message - : event.type === 'done' - ? tRef.current('chat.status.taskCompleted') + : event.type === "done" + ? tRef.current("chat.status.taskCompleted") : undefined, contextCompression: - event.type === 'error' && - message.contextCompression?.state === 'compressing' + event.type === "error" && + message.contextCompression?.state === "compressing" ? { ...message.contextCompression, - state: 'failed' as const + state: "failed" as const, } : message.contextCompression, contextCompressions: - event.type === 'error' + event.type === "error" ? message.contextCompressions?.map((compression) => - compression.state === 'compressing' + compression.state === "compressing" ? { ...compression, - state: 'failed' as const + state: "failed" as const, } - : compression + : compression, ) : message.contextCompressions, approval: undefined, question: undefined, tools: toolTerminalState ? message.tools?.map((tool) => - tool.state === 'pending' || tool.state === 'running' + tool.state === "pending" || tool.state === "running" ? { ...tool, state: toolTerminalState } - : tool + : tool, ) : message.tools, subagents: message.subagents?.map((subagent) => - subagent.state === 'queued' || - subagent.state === 'running' + subagent.state === "queued" || subagent.state === "running" ? { ...subagent, state: - event.type === 'error' - ? event.status === 'cancelled' - ? ('cancelled' as const) - : ('failed' as const) - : ('failed' as const), - ...(event.type === 'error' && - event.status !== 'cancelled' + event.type === "error" + ? event.status === "cancelled" + ? ("cancelled" as const) + : ("failed" as const) + : ("failed" as const), + ...(event.type === "error" && event.status !== "cancelled" ? { error: event.message.slice(0, 1_000) } - : event.type === 'done' + : event.type === "done" ? { error: incompleteSubagentError } - : {}) + : {}), } - : subagent + : subagent, ), blocks: toolTerminalState ? terminalizeMessageToolBlocks( appendMessageContentBlock( message.blocks, - 'text', - fallbackError + "text", + fallbackError, ), - toolTerminalState + toolTerminalState, ) : appendMessageContentBlock( message.blocks, - 'text', - fallbackError + "text", + fallbackError, ), - content: fallbackError || message.content - } - }) - activeRuns.current.delete(event.requestId) - setConversationActivity(run.conversationId, false) - releaseConversationQueueAfterRun(run) - flushConversationPersistenceAfterRenderRef.current = true + content: fallbackError || message.content, + }; + }); + activeRuns.current.delete(event.requestId); + setConversationActivity(run.conversationId, false); + releaseConversationQueueAfterRun(run); + flushConversationPersistenceAfterRenderRef.current = true; } }, [ @@ -5049,464 +4820,432 @@ function App(): React.JSX.Element { releaseConversationQueueAfterRun, setConversationActivity, updateMessage, - updateRequestActivity - ] - ) + updateRequestActivity, + ], + ); useEffect(() => { - activeProjectIdRef.current = activeProjectId + activeProjectIdRef.current = activeProjectId; if (activeProjectId) { try { - localStorage.setItem(activeProjectStorageKey, activeProjectId) + localStorage.setItem(activeProjectStorageKey, activeProjectId); } catch { // The project selection still works when persistence is unavailable. } } - }, [activeProjectId]) + }, [activeProjectId]); useEffect(() => { - viewRef.current = view - }, [view]) + viewRef.current = view; + }, [view]); const persistLocalConversationChanges = useCallback((): void => { - const operation = conversationPersistenceQueueRef.current.then( - async () => { - if (conversationPersistencePausedRef.current) { - return - } - const { batch, acknowledgements } = - createLocalConversationSaveBatch( - conversationsRef.current, - persistedLocalConversationsRef.current, - deletingLocalConversationIdsRef.current - ) - if (batch.length === 0) { - return - } - await window.goodbuddy.conversations.saveLocal(batch) - for (const conversation of acknowledgements) { - persistedLocalConversationsRef.current.set( - conversation.id, - conversation - ) - } + const operation = conversationPersistenceQueueRef.current.then(async () => { + if (conversationPersistencePausedRef.current) { + return; } - ) - conversationPersistenceQueueRef.current = - operation.catch(() => undefined) + const { batch, acknowledgements } = createLocalConversationSaveBatch( + conversationsRef.current, + persistedLocalConversationsRef.current, + deletingLocalConversationIdsRef.current, + ); + if (batch.length === 0) { + return; + } + await window.goodbuddy.conversations.saveLocal(batch); + for (const conversation of acknowledgements) { + persistedLocalConversationsRef.current.set( + conversation.id, + conversation, + ); + } + }); + conversationPersistenceQueueRef.current = operation.catch(() => undefined); void operation.catch(() => { notify({ - tone: 'error', - message: tRef.current( - 'notices.conversationPersistenceFailed' - ), - dedupeKey: 'conversation-persistence' - }) - }) - }, []) + tone: "error", + message: tRef.current("notices.conversationPersistenceFailed"), + dedupeKey: "conversation-persistence", + }); + }); + }, []); useEffect(() => { if (!conversationStoreReady) { - return + return; } - persistLocalConversationChanges() + persistLocalConversationChanges(); const interval = window.setInterval( persistLocalConversationChanges, - conversationPersistenceIntervalMs - ) + conversationPersistenceIntervalMs, + ); return () => { - window.clearInterval(interval) - persistLocalConversationChanges() - } - }, [conversationStoreReady, persistLocalConversationChanges]) + window.clearInterval(interval); + persistLocalConversationChanges(); + }; + }, [conversationStoreReady, persistLocalConversationChanges]); useEffect(() => { if ( !conversationStoreReady || !flushConversationPersistenceAfterRenderRef.current ) { - return + return; } - flushConversationPersistenceAfterRenderRef.current = false - persistLocalConversationChanges() - }, [ - conversationStoreReady, - conversations, - persistLocalConversationChanges - ]) + flushConversationPersistenceAfterRenderRef.current = false; + persistLocalConversationChanges(); + }, [conversationStoreReady, conversations, persistLocalConversationChanges]); const persistActivityHistory = useCallback(async (): Promise => { if (!activityHistoryReady) { - return + return; } try { await window.goodbuddy.activityHistory.replace( activityRecordsRef.current, - legacyActivityHistoryMayBeIncompleteRef.current - ) + legacyActivityHistoryMayBeIncompleteRef.current, + ); } catch { notify({ - tone: 'error', - message: tRef.current('notices.activityHistoryPersistenceFailed'), - dedupeKey: 'activity-history-persistence' - }) + tone: "error", + message: tRef.current("notices.activityHistoryPersistenceFailed"), + dedupeKey: "activity-history-persistence", + }); } - }, [activityHistoryReady]) + }, [activityHistoryReady]); useEffect( () => window.goodbuddy.app.onBeforeQuit(async () => { - await persistActivityHistory() + await persistActivityHistory(); if (!conversationStoreReady) { - return + return; } - flushConversationPersistenceAfterRenderRef.current = false - persistLocalConversationChanges() - await conversationPersistenceQueueRef.current + flushConversationPersistenceAfterRenderRef.current = false; + persistLocalConversationChanges(); + await conversationPersistenceQueueRef.current; }), [ conversationStoreReady, persistActivityHistory, - persistLocalConversationChanges - ] - ) + persistLocalConversationChanges, + ], + ); useEffect(() => { if (!conversationStoreReady) { - return + return; } - let active = true - let refreshSequence = 0 - let refreshTimer: number | undefined - let refreshInFlight = false - let refreshQueued = false + let active = true; + let refreshSequence = 0; + let refreshTimer: number | undefined; + let refreshInFlight = false; + let refreshQueued = false; const queueRefresh = (): void => { - refreshSequence += 1 - refreshQueued = true + refreshSequence += 1; + refreshQueued = true; if (refreshInFlight || refreshTimer !== undefined) { - return + return; } refreshTimer = window.setTimeout(() => { - refreshTimer = undefined - refresh() - }, 50) - } + refreshTimer = undefined; + refresh(); + }, 50); + }; // A run whose assistant message has already reached a persisted // terminal state (for example a remote task that finished while the // desktop was disconnected) must stop counting as in-flight, otherwise // the renderer keeps showing "processing" forever. const settleActiveRunsFromPersistedMessages = ( - persisted: readonly ConversationSnapshot[] + persisted: readonly ConversationSnapshot[], ): void => { if (activeRuns.current.size === 0) { - return + return; } - const persistedMessageStates = new Map() + const persistedMessageStates = new Map(); for (const conversation of persisted) { for (const message of conversation.messages) { persistedMessageStates.set( `${conversation.id}\0${message.id}`, - message.state - ) + message.state, + ); } } for (const [requestId, run] of activeRuns.current) { const state = persistedMessageStates.get( - `${run.conversationId}\0${run.messageId}` - ) - if (state !== undefined && state !== 'streaming') { - activeRuns.current.delete(requestId) - setConversationActivity(run.conversationId, false) - releaseConversationQueueAfterRun(run) + `${run.conversationId}\0${run.messageId}`, + ); + if (state !== undefined && state !== "streaming") { + activeRuns.current.delete(requestId); + setConversationActivity(run.conversationId, false); + releaseConversationQueueAfterRun(run); } } - } + }; const refresh = (): void => { if (refreshInFlight) { - refreshQueued = true - return + refreshQueued = true; + return; } - refreshInFlight = true - refreshQueued = false - const sequence = refreshSequence + refreshInFlight = true; + refreshQueued = false; + const sequence = refreshSequence; const tasksRefresh = window.goodbuddy.tasks .list() .then((tasks) => { if (!active || sequence !== refreshSequence) { - return + return; } - setAssistantTasks(tasks) + setAssistantTasks(tasks); setActivityRecords((current) => reconcileActivityRecords( current, tasks, - new Set(activeRuns.current.keys()) - ) - ) + new Set(activeRuns.current.keys()), + ), + ); }) .catch(() => { if (active && sequence === refreshSequence) { notify({ - tone: 'error', - message: tRef.current('notices.taskHistoryReadFailed'), - dedupeKey: 'task-lifecycle-refresh' - }) + tone: "error", + message: tRef.current("notices.taskHistoryReadFailed"), + dedupeKey: "task-lifecycle-refresh", + }); } - }) + }); const schedulesRefresh = window.goodbuddy.schedules .list() .then((schedules) => { if (active && sequence === refreshSequence) { - setAssistantSchedules(schedules) + setAssistantSchedules(schedules); } }) .catch(() => { if (active && sequence === refreshSequence) { notify({ - tone: 'error', - message: tRef.current('notices.schedulesReadFailed'), - dedupeKey: 'schedule-lifecycle-refresh' - }) + tone: "error", + message: tRef.current("notices.schedulesReadFailed"), + dedupeKey: "schedule-lifecycle-refresh", + }); } - }) + }); const conversationsRefresh = window.goodbuddy.conversations .list() .then((persisted) => { if (!active || sequence !== refreshSequence) { - return + return; } const remote = persisted.filter( - (conversation) => conversation.remote - ) + (conversation) => conversation.remote, + ); const previousById = new Map( conversationsRef.current.map((conversation) => [ conversation.id, - conversation - ]) - ) + conversation, + ]), + ); const updated = remote.filter((conversation) => { - const previous = previousById.get(conversation.id) + const previous = previousById.get(conversation.id); return ( previous === undefined || conversation.updatedAt > previous.updatedAt - ) - }) + ); + }); const unread = updated.filter( (conversation) => - conversation.id !== activeConversationIdRef.current - ) + conversation.id !== activeConversationIdRef.current, + ); if (unread.length > 0) { setUnreadConversationIds((current) => { - const next = new Set(current) - unread.forEach((conversation) => - next.add(conversation.id) - ) - return next - }) + const next = new Set(current); + unread.forEach((conversation) => next.add(conversation.id)); + return next; + }); notify({ - tone: 'info', - message: tRef.current('notices.remoteMessage', { - channel: - projectChannelLabels[ - unread[0]!.remote!.channel - ] + tone: "info", + message: tRef.current("notices.remoteMessage", { + channel: projectChannelLabels[unread[0]!.remote!.channel], }), - dedupeKey: 'remote-channel-message' - }) + dedupeKey: "remote-channel-message", + }); } setConversations((current) => mergePersistedConversations( current, persisted, - persistedLocalConversationsRef.current - ) - ) - settleActiveRunsFromPersistedMessages(persisted) + persistedLocalConversationsRef.current, + ), + ); + settleActiveRunsFromPersistedMessages(persisted); }) .catch(() => { if (active && sequence === refreshSequence) { notify({ - tone: 'error', - message: tRef.current( - 'notices.remoteConversationRefreshFailed' - ), - dedupeKey: 'remote-conversation-refresh' - }) + tone: "error", + message: tRef.current("notices.remoteConversationRefreshFailed"), + dedupeKey: "remote-conversation-refresh", + }); } - }) + }); void Promise.allSettled([ tasksRefresh, schedulesRefresh, - conversationsRefresh + conversationsRefresh, ]).finally(() => { - refreshInFlight = false + refreshInFlight = false; if (active && refreshQueued) { - queueRefresh() + queueRefresh(); } - }) - } + }); + }; const refreshWhenVisible = (): void => { - if (document.visibilityState !== 'hidden') { - queueRefresh() + if (document.visibilityState !== "hidden") { + queueRefresh(); } - } - const remove = window.goodbuddy.conversations.onChanged(queueRefresh) - window.addEventListener('focus', refreshWhenVisible) - document.addEventListener('visibilitychange', refreshWhenVisible) + }; + const remove = window.goodbuddy.conversations.onChanged(queueRefresh); + window.addEventListener("focus", refreshWhenVisible); + document.addEventListener("visibilitychange", refreshWhenVisible); return () => { - active = false + active = false; if (refreshTimer !== undefined) { - window.clearTimeout(refreshTimer) + window.clearTimeout(refreshTimer); } - remove() - window.removeEventListener('focus', refreshWhenVisible) - document.removeEventListener('visibilitychange', refreshWhenVisible) - } + remove(); + window.removeEventListener("focus", refreshWhenVisible); + document.removeEventListener("visibilitychange", refreshWhenVisible); + }; }, [ conversationStoreReady, releaseConversationQueueAfterRun, - setConversationActivity - ]) + setConversationActivity, + ]); useEffect(() => { - let active = true - let refreshInFlight = false - let refreshQueued = false - let refreshTimer: number | undefined - let refreshAll = false - const pendingConversationIds = new Set() + let active = true; + let refreshInFlight = false; + let refreshQueued = false; + let refreshTimer: number | undefined; + let refreshAll = false; + const pendingConversationIds = new Set(); const scheduleRefresh = (): void => { if (refreshTimer !== undefined || refreshInFlight) { - refreshQueued = true - return + refreshQueued = true; + return; } refreshTimer = window.setTimeout(() => { - refreshTimer = undefined - refreshQueued = false + refreshTimer = undefined; + refreshQueued = false; const conversationIds = refreshAll ? undefined - : [...pendingConversationIds] - refreshAll = false - pendingConversationIds.clear() - refresh(conversationIds) - }, 0) - } + : [...pendingConversationIds]; + refreshAll = false; + pendingConversationIds.clear(); + refresh(conversationIds); + }, 0); + }; const refresh = (conversationIds?: string[]): void => { - refreshInFlight = true - const affectedConversationIds = new Set( - conversationIds ?? [] - ) + refreshInFlight = true; + const affectedConversationIds = new Set(conversationIds ?? []); const reads = conversationIds ? conversationIds.map(async (conversationId) => ({ conversationId, items: - await window.goodbuddy.conversationQueue.list( - conversationId - ) + await window.goodbuddy.conversationQueue.list(conversationId), })) : [ - window.goodbuddy.conversationQueue - .list() - .then((items) => ({ - conversationId: undefined, - items - })) - ] + window.goodbuddy.conversationQueue.list().then((items) => ({ + conversationId: undefined, + items, + })), + ]; void Promise.all(reads) .then((results) => { if (!active) { - return + return; } setConversationQueueItems((current) => { const allItems = results.find( - (result) => result.conversationId === undefined - )?.items + (result) => result.conversationId === undefined, + )?.items; const next = allItems ? allItems : [ ...current.filter( - (item) => - !affectedConversationIds.has( - item.conversationId - ) + (item) => !affectedConversationIds.has(item.conversationId), ), - ...results.flatMap((result) => result.items) + ...results.flatMap((result) => result.items), ].sort( (left, right) => left.createdAt.localeCompare(right.createdAt) || - left.id.localeCompare(right.id) - ) - return sameConversationQueueItems(current, next) - ? current - : next - }) + left.id.localeCompare(right.id), + ); + return sameConversationQueueItems(current, next) ? current : next; + }); }) .catch(() => { if (active) { notify({ - tone: 'error', - message: tRef.current( - 'notices.conversationQueueReadFailed' - ), - dedupeKey: 'conversation-queue-read' - }) + tone: "error", + message: tRef.current("notices.conversationQueueReadFailed"), + dedupeKey: "conversation-queue-read", + }); } }) .finally(() => { - refreshInFlight = false + refreshInFlight = false; if (active && refreshQueued) { - refreshQueued = false - scheduleRefresh() + refreshQueued = false; + scheduleRefresh(); } - }) - } + }); + }; const queueRefresh = (conversationId?: string): void => { if (conversationId) { - pendingConversationIds.add(conversationId) + pendingConversationIds.add(conversationId); } else { - refreshAll = true + refreshAll = true; } - scheduleRefresh() - } - refresh() - const remove = - window.goodbuddy.conversationQueue.onChanged(queueRefresh) - const removeDispatch = - window.goodbuddy.conversationQueue.onDispatch((dispatch) => - conversationQueueDispatchRef.current(dispatch) - ) + scheduleRefresh(); + }; + refresh(); + const remove = window.goodbuddy.conversationQueue.onChanged(queueRefresh); + const removeDispatch = window.goodbuddy.conversationQueue.onDispatch( + (dispatch) => conversationQueueDispatchRef.current(dispatch), + ); return () => { - active = false + active = false; if (refreshTimer !== undefined) { - window.clearTimeout(refreshTimer) + window.clearTimeout(refreshTimer); } - remove() - removeDispatch() - } - }, []) + remove(); + removeDispatch(); + }; + }, []); useEffect(() => { - let active = true + let active = true; void (async () => { - let snapshot: ActivityHistorySnapshot + let snapshot: ActivityHistorySnapshot; try { - snapshot = await window.goodbuddy.activityHistory.get() + snapshot = await window.goodbuddy.activityHistory.get(); } catch { if (active) { notify({ - tone: 'error', - message: tRef.current('notices.activityHistoryReadFailed'), - dedupeKey: 'activity-history-read' - }) + tone: "error", + message: tRef.current("notices.activityHistoryReadFailed"), + dedupeKey: "activity-history-read", + }); } - return + return; } if (!active) { - return + return; } const legacyHistoryMayBeIncomplete = legacyActivityHistory.historyMayBeIncomplete || - snapshot.legacyHistoryMayBeIncomplete + snapshot.legacyHistoryMayBeIncomplete; if ( legacyActivityHistory.records.length > 0 || legacyActivityHistory.historyMayBeIncomplete @@ -5515,424 +5254,387 @@ function App(): React.JSX.Element { await window.goodbuddy.activityHistory.replace( mergeActivityRecords( legacyActivityHistory.records, - snapshot.records + snapshot.records, ), - legacyHistoryMayBeIncomplete - ) - clearLegacyActivityHistory() + legacyHistoryMayBeIncomplete, + ); + clearLegacyActivityHistory(); } catch { notify({ - tone: 'error', - message: tRef.current( - 'notices.activityHistoryPersistenceFailed' - ), - dedupeKey: 'activity-history-persistence' - }) + tone: "error", + message: tRef.current("notices.activityHistoryPersistenceFailed"), + dedupeKey: "activity-history-persistence", + }); } if (!active) { - return + return; } } setActivityRecords((current) => - mergeActivityRecords(current, snapshot.records) - ) - setLegacyActivityHistoryMayBeIncomplete(legacyHistoryMayBeIncomplete) - setActivityHistoryReady(true) - })() + mergeActivityRecords(current, snapshot.records), + ); + setLegacyActivityHistoryMayBeIncomplete(legacyHistoryMayBeIncomplete); + setActivityHistoryReady(true); + })(); return () => { - active = false - } - }, [legacyActivityHistory]) + active = false; + }; + }, [legacyActivityHistory]); useEffect(() => { - activityRecordsRef.current = activityRecords + activityRecordsRef.current = activityRecords; legacyActivityHistoryMayBeIncompleteRef.current = - legacyActivityHistoryMayBeIncomplete + legacyActivityHistoryMayBeIncomplete; if (!activityHistoryReady) { - return + return; } const timeout = window.setTimeout(() => { - void persistActivityHistory() - }, 250) - return () => window.clearTimeout(timeout) + void persistActivityHistory(); + }, 250); + return () => window.clearTimeout(timeout); }, [ activityHistoryReady, activityRecords, legacyActivityHistoryMayBeIncomplete, - persistActivityHistory - ]) + persistActivityHistory, + ]); useEffect( () => () => { - void persistActivityHistory() + void persistActivityHistory(); }, - [persistActivityHistory] - ) + [persistActivityHistory], + ); const resumeProjectConversationQueues = useCallback( (projectId: string): void => { if ( (!projectRecoverySnapshotReadyRef.current && isManagedSshProject( - projectsRef.current.find( - (project) => project.id === projectId - ) + projectsRef.current.find((project) => project.id === projectId), )) || isProjectRecoveryUnsettled( - projectRecoveryByProjectIdRef.current[projectId] + projectRecoveryByProjectIdRef.current[projectId], ) ) { - return + return; } const conversationIds = conversationsRef.current .filter( (conversation) => - conversation.projectId === projectId && - !conversation.remote + conversation.projectId === projectId && !conversation.remote, ) - .map((conversation) => conversation.id) + .map((conversation) => conversation.id); if (conversationIds.length === 0) { - return + return; } void Promise.all( conversationIds.map((conversationId) => - window.goodbuddy.conversationQueue.ready(conversationId) - ) + window.goodbuddy.conversationQueue.ready(conversationId), + ), ).catch(() => { notify({ - tone: 'error', - message: tRef.current( - 'notices.conversationQueueResumeFailed' - ), - dedupeKey: `conversation-queue-resume:${projectId}` - }) - }) + tone: "error", + message: tRef.current("notices.conversationQueueResumeFailed"), + dedupeKey: `conversation-queue-resume:${projectId}`, + }); + }); }, - [notify] - ) + [notify], + ); const applyProjectRecoveryState = useCallback( (state: RemoteProjectRecoveryState): void => { - const previous = - projectRecoveryByProjectIdRef.current[state.projectId] + const previous = projectRecoveryByProjectIdRef.current[state.projectId]; if (previous) { if (previous.requestId !== state.requestId) { if ( - !['completed', 'failed'].includes(previous.stage) || - state.stage !== 'network' + !["completed", "failed"].includes(previous.stage) || + state.stage !== "network" ) { - return + return; } } else if ( remoteRecoveryStageOrder(state.stage) < remoteRecoveryStageOrder(previous.stage) || - (previous.stage === 'cursor' && - state.stage === 'cursor' && + (previous.stage === "cursor" && + state.stage === "cursor" && BigInt(state.current) < BigInt(previous.current)) || - ['completed', 'failed'].includes(previous.stage) + ["completed", "failed"].includes(previous.stage) ) { - return + return; } } const next = { ...projectRecoveryByProjectIdRef.current, - [state.projectId]: state - } - projectRecoveryByProjectIdRef.current = next - setProjectRecoveryByProjectId(next) - if ( - state.stage === 'completed' && - previous?.stage !== 'completed' - ) { - resumeProjectConversationQueues(state.projectId) + [state.projectId]: state, + }; + projectRecoveryByProjectIdRef.current = next; + setProjectRecoveryByProjectId(next); + if (state.stage === "completed" && previous?.stage !== "completed") { + resumeProjectConversationQueues(state.projectId); } }, - [resumeProjectConversationQueues] - ) + [resumeProjectConversationQueues], + ); useEffect(() => { - const recoveryApi = window.goodbuddy.projects.remote - let active = true + const recoveryApi = window.goodbuddy.projects.remote; + let active = true; const removeListener = recoveryApi.onRecoveryProgress((state) => { if (active) { - applyProjectRecoveryState(state) + applyProjectRecoveryState(state); } - }) + }); void recoveryApi.getRecoverySnapshot().then( (snapshot) => { if (active) { - snapshot.recoveries.forEach(applyProjectRecoveryState) - projectRecoverySnapshotReadyRef.current = true - setProjectRecoverySnapshotReady(true) + snapshot.recoveries.forEach(applyProjectRecoveryState); + projectRecoverySnapshotReadyRef.current = true; + setProjectRecoverySnapshotReady(true); } }, () => { // Main reports actionable recovery failures as project states. if (active) { - projectRecoverySnapshotReadyRef.current = true - setProjectRecoverySnapshotReady(true) + projectRecoverySnapshotReadyRef.current = true; + setProjectRecoverySnapshotReady(true); } - } - ) + }, + ); return () => { - active = false - removeListener() - } - }, [applyProjectRecoveryState]) + active = false; + removeListener(); + }; + }, [applyProjectRecoveryState]); const retryProjectRecovery = useCallback( async (projectId: string): Promise => { if (retryingRecoveryProjectIdsRef.current.has(projectId)) { - return + return; } - retryingRecoveryProjectIdsRef.current.add(projectId) + retryingRecoveryProjectIdsRef.current.add(projectId); try { const state = - await window.goodbuddy.projects.remote.retryRecovery( - projectId - ) - applyProjectRecoveryState(state) + await window.goodbuddy.projects.remote.retryRecovery(projectId); + applyProjectRecoveryState(state); } catch { // Preserve the existing local failure without duplicating a toast. } finally { - retryingRecoveryProjectIdsRef.current.delete(projectId) + retryingRecoveryProjectIdsRef.current.delete(projectId); } }, - [applyProjectRecoveryState] - ) + [applyProjectRecoveryState], + ); useEffect(() => { - let active = true + let active = true; const initialization = Promise.all([ window.goodbuddy.projects.list(false), - window.goodbuddy.conversations.list() - ]) - .then(async ([value, persistedConversations]) => { - if (!active || value.length === 0) { - return - } - setProjects(value) - const project = value.find(isOrdinaryLocalProject) - if (!project) { - throw new Error('没有可用的本地项目') - } - setActiveProjectId(project.id) - setWorkMode( - normalizeInteractiveWorkMode(project.defaultWorkMode) - ) - const persistedLocalConversations = - persistedConversations.filter( - (conversation) => !conversation.remote - ) - const persistedConversationIds = new Set( - persistedConversations.map((conversation) => conversation.id) - ) - const shouldMigrateLocalStorage = - conversationMigrationStoragePresent.current || - persistedConversations.length === 0 - const migratedLocalConversations = - shouldMigrateLocalStorage - ? migrationConversations.current - .filter( - (conversation) => - !conversation.remote && - !persistedConversationIds.has(conversation.id) - ) - .map((conversation) => - conversation.projectId || project.kind === 'channel' - ? conversation - : { ...conversation, projectId: project.id } - ) - : [] - let nextConversations: Conversation[] = [ - ...persistedConversations, - ...migratedLocalConversations - ] - let projectConversation = nextConversations.find( + window.goodbuddy.conversations.list(), + ]).then(async ([value, persistedConversations]) => { + if (!active || value.length === 0) { + return; + } + setProjects(value); + const project = value.find(isOrdinaryLocalProject); + if (!project) { + throw new Error("没有可用的本地项目"); + } + setActiveProjectId(project.id); + setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); + const persistedLocalConversations = persistedConversations.filter( + (conversation) => !conversation.remote, + ); + const persistedConversationIds = new Set( + persistedConversations.map((conversation) => conversation.id), + ); + const shouldMigrateLocalStorage = + conversationMigrationStoragePresent.current || + persistedConversations.length === 0; + const migratedLocalConversations = shouldMigrateLocalStorage + ? migrationConversations.current + .filter( + (conversation) => + !conversation.remote && + !persistedConversationIds.has(conversation.id), + ) + .map((conversation) => + conversation.projectId || project.kind === "channel" + ? conversation + : { ...conversation, projectId: project.id }, + ) + : []; + let nextConversations: Conversation[] = [ + ...persistedConversations, + ...migratedLocalConversations, + ]; + let projectConversation = nextConversations.find( + (conversation) => + conversation.projectId === project.id && + (project.kind !== "channel" || conversation.remote !== undefined), + ); + if (!projectConversation && project.kind !== "channel") { + projectConversation = createConversation( + project.id, + undefined, + tRef.current("conversation.greeting"), + ); + nextConversations = [projectConversation, ...nextConversations]; + } + const acknowledgedLocalConversations = new Map( + persistedLocalConversations.map((conversation) => [ + conversation.id, + conversation, + ]), + ); + if ( + nextConversations.some( (conversation) => - conversation.projectId === project.id && - (project.kind !== 'channel' || - conversation.remote !== undefined) + !conversation.remote && + acknowledgedLocalConversations.get(conversation.id) !== + conversation, ) - if (!projectConversation && project.kind !== 'channel') { - projectConversation = createConversation( - project.id, - undefined, - tRef.current('conversation.greeting') - ) - nextConversations = [ - projectConversation, - ...nextConversations - ] - } - const acknowledgedLocalConversations = new Map( - persistedLocalConversations.map((conversation) => [ - conversation.id, - conversation - ]) - ) - if ( - nextConversations.some( - (conversation) => - !conversation.remote && - acknowledgedLocalConversations.get(conversation.id) !== - conversation - ) - ) { - if (persistedConversations.length === 0) { - const migratedSnapshots = - toConversationSnapshots(nextConversations) - await window.goodbuddy.conversations.replace( - migratedSnapshots - ) - const migratedSnapshotIds = new Set( - migratedSnapshots.map((conversation) => conversation.id) - ) - for (const conversation of nextConversations) { - if (migratedSnapshotIds.has(conversation.id)) { - acknowledgedLocalConversations.set( - conversation.id, - conversation - ) - } - } - } - while (true) { - const migration = createLocalConversationSaveBatch( - nextConversations, - acknowledgedLocalConversations, - new Set() - ) - if (migration.batch.length === 0) { - break - } - await window.goodbuddy.conversations.saveLocal( - migration.batch - ) - for (const conversation of migration.acknowledgements) { - acknowledgedLocalConversations.set( - conversation.id, - conversation - ) + ) { + if (persistedConversations.length === 0) { + const migratedSnapshots = toConversationSnapshots(nextConversations); + await window.goodbuddy.conversations.replace(migratedSnapshots); + const migratedSnapshotIds = new Set( + migratedSnapshots.map((conversation) => conversation.id), + ); + for (const conversation of nextConversations) { + if (migratedSnapshotIds.has(conversation.id)) { + acknowledgedLocalConversations.set(conversation.id, conversation); } } } - if (!active) { - return + while (true) { + const migration = createLocalConversationSaveBatch( + nextConversations, + acknowledgedLocalConversations, + new Set(), + ); + if (migration.batch.length === 0) { + break; + } + await window.goodbuddy.conversations.saveLocal(migration.batch); + for (const conversation of migration.acknowledgements) { + acknowledgedLocalConversations.set(conversation.id, conversation); + } } - persistedLocalConversationsRef.current = - acknowledgedLocalConversations - setConversations(nextConversations) - setActiveId(projectConversation?.id ?? '') - try { - localStorage.removeItem(storageKey) - conversationMigrationStoragePresent.current = false - } catch { - // The SQLite migration has already completed successfully. - } - setConversationStoreReady(true) - }) + } + if (!active) { + return; + } + persistedLocalConversationsRef.current = acknowledgedLocalConversations; + setConversations(nextConversations); + setActiveId(projectConversation?.id ?? ""); + try { + localStorage.removeItem(storageKey); + conversationMigrationStoragePresent.current = false; + } catch { + // The SQLite migration has already completed successfully. + } + setConversationStoreReady(true); + }); conversationPersistenceQueueRef.current = initialization.then( () => undefined, - () => undefined - ) + () => undefined, + ); void initialization.catch((reason: unknown) => { - if (active) { - setConversationLoadError( - displayErrorMessage( - reason, - tRef.current('notices.projectReadFailed') - ) - ) - } - }) + if (active) { + setConversationLoadError( + displayErrorMessage( + reason, + tRef.current("notices.projectReadFailed"), + ), + ); + } + }); return () => { - active = false - } - }, [conversationLoadRetry, setActiveId]) + active = false; + }; + }, [conversationLoadRetry, setActiveId]); useEffect(() => { if (!activeProjectId) { - return + return; } void window.goodbuddy.memory .list(activeProjectId) .then(setAssistantMemories) .catch(() => notify({ - tone: 'error', - message: tRef.current('notices.memoryReadFailed') - }) - ) - }, [activeProjectId]) + tone: "error", + message: tRef.current("notices.memoryReadFailed"), + }), + ); + }, [activeProjectId]); const refreshWorkspaceChanges = useCallback(async (): Promise => { if (!activeProjectId) { - workspaceChangesRequestRef.current += 1 - setWorkspaceChanges(undefined) - return + workspaceChangesRequestRef.current += 1; + setWorkspaceChanges(undefined); + return; } - await loadWorkspaceChanges(activeProjectId) - }, [activeProjectId, loadWorkspaceChanges]) + await loadWorkspaceChanges(activeProjectId); + }, [activeProjectId, loadWorkspaceChanges]); const listWorkspaceDirectory = useCallback( async (path: string) => { if (!activeProjectId) { - throw new Error(tRef.current('notices.selectProject')) + throw new Error(tRef.current("notices.selectProject")); } - return window.goodbuddy.workspace.listDirectory(activeProjectId, path) + return window.goodbuddy.workspace.listDirectory(activeProjectId, path); }, - [activeProjectId] - ) + [activeProjectId], + ); const loadWorkspaceFile = useCallback( async (path: string, offsetBytes = 0) => { if (!activeProjectId) { - throw new Error(tRef.current('notices.selectProject')) + throw new Error(tRef.current("notices.selectProject")); } return window.goodbuddy.workspace.readFile( activeProjectId, path, - offsetBytes - ) + offsetBytes, + ); }, - [activeProjectId] - ) - const loadWorkspaceDiff = useCallback(async (path: string) => { - if (!activeProjectId) throw new Error(tRef.current('notices.selectProject')) - return window.goodbuddy.workspace.getFileDiff(activeProjectId, path) - }, [activeProjectId]) + [activeProjectId], + ); + const loadWorkspaceDiff = useCallback( + async (path: string) => { + if (!activeProjectId) + throw new Error(tRef.current("notices.selectProject")); + return window.goodbuddy.workspace.getFileDiff(activeProjectId, path); + }, + [activeProjectId], + ); const openWorkspaceEntry = useCallback( - async ( - path: string, - type: 'file' | 'directory' - ): Promise => { + async (path: string, type: "file" | "directory"): Promise => { if (!activeProjectId) { - throw new Error(tRef.current('notices.selectProject')) + throw new Error(tRef.current("notices.selectProject")); } - await window.goodbuddy.workspace.openPath( - activeProjectId, - path, - type - ) + await window.goodbuddy.workspace.openPath(activeProjectId, path, type); }, - [activeProjectId] - ) + [activeProjectId], + ); useEffect(() => { - if (assistantSidebarTab !== 'workspace') { - return + if (assistantSidebarTab !== "workspace") { + return; } const timeout = setTimeout(() => { void refreshWorkspaceChanges().catch(() => { notify({ - tone: 'error', - message: tRef.current('notices.workspaceChangesReadFailed') - }) - }) - }, 0) - return () => clearTimeout(timeout) - }, [assistantSidebarTab, refreshWorkspaceChanges]) + tone: "error", + message: tRef.current("notices.workspaceChangesReadFailed"), + }); + }); + }, 0); + return () => clearTimeout(timeout); + }, [assistantSidebarTab, refreshWorkspaceChanges]); useEffect(() => { void window.goodbuddy.experts @@ -5940,296 +5642,287 @@ function App(): React.JSX.Element { .then(setAssistantExperts) .catch(() => notify({ - tone: 'error', - message: tRef.current('notices.expertsReadFailed') - }) - ) - }, []) + tone: "error", + message: tRef.current("notices.expertsReadFailed"), + }), + ); + }, []); useEffect(() => { if (!activeProjectId) { - return + return; } void window.goodbuddy.schedules .list() .then(setAssistantSchedules) .catch(() => notify({ - tone: 'error', - message: tRef.current('notices.schedulesReadFailed') - }) - ) - }, [activeProjectId]) + tone: "error", + message: tRef.current("notices.schedulesReadFailed"), + }), + ); + }, [activeProjectId]); const loadHeartbeats = useCallback(async () => { const [configs, memories] = await Promise.all([ window.goodbuddy.heartbeats.list(), - window.goodbuddy.memory.list() - ]) - const history = await window.goodbuddy.heartbeats.history() + window.goodbuddy.memory.list(), + ]); + const history = await window.goodbuddy.heartbeats.history(); return { configs, memories, runs: history.runs, - entries: history.entries - } - }, []) + entries: history.entries, + }; + }, []); const refreshHeartbeats = useCallback(async (): Promise => { - const requestId = ++heartbeatLoadRequestRef.current - const result = await loadHeartbeats() + const requestId = ++heartbeatLoadRequestRef.current; + const result = await loadHeartbeats(); if (requestId !== heartbeatLoadRequestRef.current) { - return + return; } - setAssistantHeartbeats(result.configs) - setHeartbeatMemories(result.memories) - setHeartbeatRuns(result.runs) - setHeartbeatEntries(result.entries) - }, [loadHeartbeats]) + setAssistantHeartbeats(result.configs); + setHeartbeatMemories(result.memories); + setHeartbeatRuns(result.runs); + setHeartbeatEntries(result.entries); + }, [loadHeartbeats]); useEffect(() => { if (projects.length === 0) { - return + return; } - const requestId = ++heartbeatLoadRequestRef.current + const requestId = ++heartbeatLoadRequestRef.current; const timeout = setTimeout(() => { if (requestId !== heartbeatLoadRequestRef.current) { - return + return; } - setHeartbeatLoading(true) - setHeartbeatLoadError(undefined) - setAssistantHeartbeats([]) - setHeartbeatRuns([]) - setHeartbeatEntries([]) + setHeartbeatLoading(true); + setHeartbeatLoadError(undefined); + setAssistantHeartbeats([]); + setHeartbeatRuns([]); + setHeartbeatEntries([]); void loadHeartbeats() .then((result) => { if (requestId !== heartbeatLoadRequestRef.current) { - return + return; } - setAssistantHeartbeats(result.configs) - setHeartbeatMemories(result.memories) - setHeartbeatRuns(result.runs) - setHeartbeatEntries(result.entries) - setHeartbeatLoadError(undefined) + setAssistantHeartbeats(result.configs); + setHeartbeatMemories(result.memories); + setHeartbeatRuns(result.runs); + setHeartbeatEntries(result.entries); + setHeartbeatLoadError(undefined); }) .catch((reason: unknown) => { if (requestId !== heartbeatLoadRequestRef.current) { - return + return; } setHeartbeatLoadError( displayErrorMessage( reason, - tRef.current('notices.heartbeatReadFailed') - ) - ) + tRef.current("notices.heartbeatReadFailed"), + ), + ); }) .finally(() => { if (requestId === heartbeatLoadRequestRef.current) { - setHeartbeatLoading(false) + setHeartbeatLoading(false); } - }) - }, 0) + }); + }, 0); return () => { - clearTimeout(timeout) + clearTimeout(timeout); if (requestId === heartbeatLoadRequestRef.current) { - heartbeatLoadRequestRef.current += 1 + heartbeatLoadRequestRef.current += 1; } - } - }, [loadHeartbeats, projects.length]) + }; + }, [loadHeartbeats, projects.length]); const refreshHeartbeatCenter = useCallback(async (): Promise => { const [artifacts] = await Promise.all([ window.goodbuddy.artifacts.list(), - refreshHeartbeats() - ]) - setAssistantArtifacts((current) => - mergeArtifacts(current, artifacts) - ) - }, [refreshHeartbeats]) + refreshHeartbeats(), + ]); + setAssistantArtifacts((current) => mergeArtifacts(current, artifacts)); + }, [refreshHeartbeats]); const retryHeartbeatLoad = useCallback(async (): Promise => { - setHeartbeatLoading(true) - setHeartbeatLoadError(undefined) + setHeartbeatLoading(true); + setHeartbeatLoadError(undefined); try { - await refreshHeartbeatCenter() - setHeartbeatLoadError(undefined) + await refreshHeartbeatCenter(); + setHeartbeatLoadError(undefined); } catch (reason) { setHeartbeatLoadError( displayErrorMessage( reason, - tRef.current('notices.heartbeatReadFailed') - ) - ) + tRef.current("notices.heartbeatReadFailed"), + ), + ); } finally { - setHeartbeatLoading(false) + setHeartbeatLoading(false); } - }, [refreshHeartbeatCenter]) + }, [refreshHeartbeatCenter]); const createHeartbeat = useCallback( async (input: HeartbeatCreateInput): Promise => { - await window.goodbuddy.heartbeats.create(input) - await refreshHeartbeats() + await window.goodbuddy.heartbeats.create(input); + await refreshHeartbeats(); }, - [refreshHeartbeats] - ) + [refreshHeartbeats], + ); const updateHeartbeat = useCallback( - async ( - heartbeatId: string, - input: HeartbeatUpdateInput - ): Promise => { - await window.goodbuddy.heartbeats.update(heartbeatId, input) - await refreshHeartbeats() + async (heartbeatId: string, input: HeartbeatUpdateInput): Promise => { + await window.goodbuddy.heartbeats.update(heartbeatId, input); + await refreshHeartbeats(); }, - [refreshHeartbeats] - ) + [refreshHeartbeats], + ); const removeHeartbeat = useCallback( async (heartbeatId: string): Promise => { - await window.goodbuddy.heartbeats.remove(heartbeatId) - await refreshHeartbeats() + await window.goodbuddy.heartbeats.remove(heartbeatId); + await refreshHeartbeats(); }, - [refreshHeartbeats] - ) + [refreshHeartbeats], + ); const runHeartbeat = useCallback( async (heartbeatId: string): Promise => { - await window.goodbuddy.heartbeats.runNow(heartbeatId) - await refreshHeartbeatCenter() + await window.goodbuddy.heartbeats.runNow(heartbeatId); + await refreshHeartbeatCenter(); }, - [refreshHeartbeatCenter] - ) + [refreshHeartbeatCenter], + ); const setHeartbeatPaused = useCallback( async (heartbeatId: string, paused: boolean): Promise => { - await window.goodbuddy.heartbeats.setPaused(heartbeatId, paused) - await refreshHeartbeats() + await window.goodbuddy.heartbeats.setPaused(heartbeatId, paused); + await refreshHeartbeats(); }, - [refreshHeartbeats] - ) + [refreshHeartbeats], + ); useEffect(() => { - if (view !== 'heartbeat') { - return + if (view !== "heartbeat") { + return; } - let refreshing = false + let refreshing = false; const refresh = (): void => { if (refreshing) { - return + return; } - refreshing = true + refreshing = true; void refreshHeartbeatCenter() .then(() => setHeartbeatLoadError(undefined)) .catch((reason: unknown) => setHeartbeatLoadError( displayErrorMessage( reason, - tRef.current('notices.heartbeatRefreshFailed') - ) - ) + tRef.current("notices.heartbeatRefreshFailed"), + ), + ), ) .finally(() => { - refreshing = false - }) - } - const timeout = setTimeout(refresh, 0) - const interval = setInterval(refresh, 30_000) + refreshing = false; + }); + }; + const timeout = setTimeout(refresh, 0); + const interval = setInterval(refresh, 30_000); return () => { - clearTimeout(timeout) - clearInterval(interval) - } - }, [refreshHeartbeatCenter, view]) + clearTimeout(timeout); + clearInterval(interval); + }; + }, [refreshHeartbeatCenter, view]); useEffect(() => { void window.goodbuddy.tasks .list() .then((tasks) => { - setAssistantTasks(tasks) + setAssistantTasks(tasks); setActivityRecords((current) => reconcileActivityRecords( current, tasks, - new Set(activeRuns.current.keys()) - ) - ) + new Set(activeRuns.current.keys()), + ), + ); }) .catch(() => notify({ - tone: 'error', - message: tRef.current('notices.taskHistoryReadFailed') - }) - ) - }, []) + tone: "error", + message: tRef.current("notices.taskHistoryReadFailed"), + }), + ); + }, []); useEffect(() => { - if (view !== 'activity') { - return + if (view !== "activity") { + return; } const timeout = setTimeout(() => { void refreshTokenUsage().catch(() => notify({ - tone: 'error', - message: tRef.current('notices.tokenUsageReadFailed') - }) - ) - }, 0) - return () => clearTimeout(timeout) - }, [refreshTokenUsage, view]) + tone: "error", + message: tRef.current("notices.tokenUsageReadFailed"), + }), + ); + }, 0); + return () => clearTimeout(timeout); + }, [refreshTokenUsage, view]); useEffect(() => { void window.goodbuddy.artifacts .list() .then((artifacts) => - setAssistantArtifacts((current) => - mergeArtifacts(current, artifacts) - ) + setAssistantArtifacts((current) => mergeArtifacts(current, artifacts)), ) .catch(() => notify({ - tone: 'error', - message: tRef.current('notices.resultHistoryReadFailed') - }) - ) - }, []) + tone: "error", + message: tRef.current("notices.resultHistoryReadFailed"), + }), + ); + }, []); useEffect(() => { const missingIds = [ ...new Set( (activeConversation?.messages ?? []).flatMap( - (message) => message.artifactIds ?? [] - ) - ) + (message) => message.artifactIds ?? [], + ), + ), ] .filter( (artifactId) => !assistantArtifactById.get(artifactId)?.content && - !hydratingArtifactIds.current.has(artifactId) + !hydratingArtifactIds.current.has(artifactId), ) - .slice(-32) + .slice(-32); if (missingIds.length === 0) { - return + return; } for (const artifactId of missingIds) { - hydratingArtifactIds.current.add(artifactId) + hydratingArtifactIds.current.add(artifactId); } void Promise.allSettled( missingIds.map((artifactId) => - window.goodbuddy.artifacts.get(artifactId) - ) + window.goodbuddy.artifacts.get(artifactId), + ), ).then((results) => { const artifacts = results.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [] - ) + result.status === "fulfilled" ? [result.value] : [], + ); if (artifacts.length > 0) { - setAssistantArtifacts((current) => - mergeArtifacts(current, artifacts) - ) + setAssistantArtifacts((current) => mergeArtifacts(current, artifacts)); } for (const artifactId of missingIds) { - hydratingArtifactIds.current.delete(artifactId) + hydratingArtifactIds.current.delete(artifactId); } - }) - }, [activeConversation, assistantArtifactById]) + }); + }, [activeConversation, assistantArtifactById]); useEffect(() => { const timeout = setTimeout(() => { @@ -6237,624 +5930,566 @@ function App(): React.JSX.Element { .catch(() => { // refreshKnowledge exposes a recoverable page-local error. }) - .finally(() => setKnowledgeLoading(false)) - }, 0) - return () => clearTimeout(timeout) - }, [refreshKnowledge]) + .finally(() => setKnowledgeLoading(false)); + }, 0); + return () => clearTimeout(timeout); + }, [refreshKnowledge]); useEffect(() => { if ( knowledgeLoadError || - (view !== 'knowledge' && knowledgeOperationCount === 0) + (view !== "knowledge" && knowledgeOperationCount === 0) ) { - return + return; } - const interval = setInterval(() => { - void refreshKnowledge( - knowledgeSnapshot.selectedLibraryId - ).catch(() => { - // The task center keeps the last successful snapshot while polling. - }) - }, knowledgeOperationCount > 0 ? 350 : 1_000) - return () => clearInterval(interval) + const interval = setInterval( + () => { + void refreshKnowledge(knowledgeSnapshot.selectedLibraryId).catch(() => { + // The task center keeps the last successful snapshot while polling. + }); + }, + knowledgeOperationCount > 0 ? 350 : 1_000, + ); + return () => clearInterval(interval); }, [ knowledgeLoadError, knowledgeOperationCount, knowledgeSnapshot.selectedLibraryId, refreshKnowledge, - view - ]) + view, + ]); useEffect(() => { void Promise.all([ window.goodbuddy.settings.getRuntime(), - window.goodbuddy.agent.getStatus() + window.goodbuddy.agent.getStatus(), ]) .then(([settings, status]) => { const selectionKey = agentRuntimeSelectionKey( - getDefaultRuntimeSelection(settings) - ) + getDefaultRuntimeSelection(settings), + ); runtimeStatusCacheRef.current = { key: selectionKey, - settings - } - setRuntimeSettings(settings) - setRuntime(status) - setRuntimeStatusKey(selectionKey) + settings, + }; + setRuntimeSettings(settings); + setRuntime(status); + setRuntimeStatusKey(selectionKey); if (!status.available && !runtimeSetupPromptedRef.current) { - runtimeSetupPromptedRef.current = true - setView('settings') + runtimeSetupPromptedRef.current = true; + setView("settings"); } }) .catch(() => notify({ - tone: 'error', - message: tRef.current('runtime.errors.readSettings') - }) - ) + tone: "error", + message: tRef.current("runtime.errors.readSettings"), + }), + ); void window.goodbuddy.app .getInfo() .then(setAppInfo) .catch(() => notify({ - tone: 'error', - message: tRef.current('notices.appInfoReadFailed') - }) - ) + tone: "error", + message: tRef.current("notices.appInfoReadFailed"), + }), + ); const removeAgentListener = - window.goodbuddy.agent.onEvent(handleAgentEvent) - const removeOpenSettingsListener = - window.goodbuddy.app.onOpenSettings(() => setView('settings')) + window.goodbuddy.agent.onEvent(handleAgentEvent); + const removeOpenSettingsListener = window.goodbuddy.app.onOpenSettings(() => + setView("settings"), + ); return () => { - removeAgentListener() - removeOpenSettingsListener() - } - }, [handleAgentEvent, setView]) + removeAgentListener(); + removeOpenSettingsListener(); + }; + }, [handleAgentEvent, setView]); - useEffect( - () => { - const browserApi = window.goodbuddy.browser - if (!browserApi) { - return + useEffect(() => { + const browserApi = window.goodbuddy.browser; + if (!browserApi) { + return; + } + return browserApi.onState((state) => { + setBrowserStates((current) => { + const previous = current[state.conversationId]; + return { + ...current, + [state.conversationId]: + state.status === "stopped" || + state.frameDataUrl || + !previous?.frameDataUrl + ? state + : { + ...state, + frameDataUrl: previous.frameDataUrl, + }, + }; + }); + if ( + state.status !== "stopped" && + state.conversationId === conversationNavigationRef.current.activeId + ) { + setAssistantSidebarOpen(true); + setAssistantSidebarTab("browser"); } - return browserApi.onState((state) => { - setBrowserStates((current) => { - const previous = current[state.conversationId] - return { - ...current, - [state.conversationId]: - state.status === 'stopped' || - state.frameDataUrl || - !previous?.frameDataUrl - ? state - : { - ...state, - frameDataUrl: previous.frameDataUrl - } - } - }) - if ( - state.status !== 'stopped' && - state.conversationId === - conversationNavigationRef.current.activeId - ) { - setAssistantSidebarOpen(true) - setAssistantSidebarTab('browser') - } - }) - }, - [] - ) + }); + }, []); useEffect( () => window.goodbuddy.app.onNewConversation(() => { - startNewConversation(activeProjectIdRef.current || undefined) + startNewConversation(activeProjectIdRef.current || undefined); }), - [startNewConversation] - ) + [startNewConversation], + ); useEffect(() => { - const handleNewConversationShortcut = ( - event: KeyboardEvent - ): void => { + const handleNewConversationShortcut = (event: KeyboardEvent): void => { if ( - event.key.toLocaleLowerCase() !== 'n' || + event.key.toLocaleLowerCase() !== "n" || (!event.ctrlKey && !event.metaKey) || event.altKey || event.shiftKey ) { - return + return; } - event.preventDefault() - startNewConversation(activeProjectIdRef.current || undefined) - } - document.addEventListener( - 'keydown', - handleNewConversationShortcut - ) + event.preventDefault(); + startNewConversation(activeProjectIdRef.current || undefined); + }; + document.addEventListener("keydown", handleNewConversationShortcut); return () => - document.removeEventListener( - 'keydown', - handleNewConversationShortcut - ) - }, [startNewConversation]) + document.removeEventListener("keydown", handleNewConversationShortcut); + }, [startNewConversation]); const commitProjectSelection = ( selected: AssistantProject, - candidateConversations = conversationsRef.current + candidateConversations = conversationsRef.current, ): void => { - setActiveProjectId(selected.id) - setWorkMode( - normalizeInteractiveWorkMode(selected.defaultWorkMode) - ) + setActiveProjectId(selected.id); + setWorkMode(normalizeInteractiveWorkMode(selected.defaultWorkMode)); const conversation = candidateConversations.find( (candidate) => candidate.projectId === selected.id && - ( - selected.kind !== 'channel' || - candidate.remote !== undefined - ) - ) + (selected.kind !== "channel" || candidate.remote !== undefined), + ); if (conversation) { if (isManagedSshProject(selected)) { const runtimeSelection = runtimeSettings - ? getRuntimeSelectionForProvider( - 'opencode', - runtimeSettings - ) - : ({ provider: 'opencode' } as const) + ? getRuntimeSelectionForProvider("opencode", runtimeSettings) + : ({ provider: "opencode" } as const); setConversations((current) => current.map((candidate) => candidate.projectId === selected.id && - candidate.runtimeSelection?.provider !== 'opencode' + candidate.runtimeSelection?.provider !== "opencode" ? { ...candidate, runtimeSelection, - updatedAt: Date.now() + updatedAt: Date.now(), } - : candidate - ) - ) + : candidate, + ), + ); } - setActiveId(conversation.id) - } else if (selected.kind === 'channel') { - setActiveId('') + setActiveId(conversation.id); + } else if (selected.kind === "channel") { + setActiveId(""); } else { const created = createConversation( selected.id, runtimeSettings - ? getProjectDefaultRuntimeSelection( - selected, - runtimeSettings - ) + ? getProjectDefaultRuntimeSelection(selected, runtimeSettings) : undefined, - t('conversation.greeting') - ) - setConversations((current) => [created, ...current]) - setActiveId(created.id) + t("conversation.greeting"), + ); + setConversations((current) => [created, ...current]); + setActiveId(created.id); } - setView('chat') - } + setView("chat"); + }; const selectProject = (projectId: string): void => { - const project = projects.find((candidate) => candidate.id === projectId) + const project = projects.find((candidate) => candidate.id === projectId); if (!project) { - return + return; } - if ( - project.executionSpace.kind === 'ssh' && - !remoteProjectsEnabled - ) { - return + if (project.executionSpace.kind === "ssh" && !remoteProjectsEnabled) { + return; } - commitProjectSelection(project) + commitProjectSelection(project); if ( !isProjectRecoveryUnsettled( - projectRecoveryByProjectIdRef.current[project.id] + projectRecoveryByProjectIdRef.current[project.id], ) ) { - resumeProjectConversationQueues(project.id) + resumeProjectConversationQueues(project.id); } - } + }; const createProject = async ( - input: ProjectCreateInput + input: ProjectCreateInput, ): Promise => { - const project = await window.goodbuddy.projects.create(input) - setProjects((current) => [project, ...current]) - setActiveProjectId(project.id) - setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)) + const project = await window.goodbuddy.projects.create(input); + setProjects((current) => [project, ...current]); + setActiveProjectId(project.id); + setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); const conversation = createConversation( project.id, runtimeSettings ? getProjectDefaultRuntimeSelection(project, runtimeSettings) : undefined, - t('conversation.greeting') - ) - setConversations((current) => [conversation, ...current]) - setActiveId(conversation.id) - setView('chat') - return project - } + t("conversation.greeting"), + ); + setConversations((current) => [conversation, ...current]); + setActiveId(conversation.id); + setView("chat"); + return project; + }; const updateProject = async ( projectId: string, - input: ProjectCreateInput + input: ProjectCreateInput, ): Promise => { - const project = await window.goodbuddy.projects.update( - projectId, - input - ) + const project = await window.goodbuddy.projects.update(projectId, input); setProjects((current) => current.map((candidate) => - candidate.id === project.id ? project : candidate - ) - ) + candidate.id === project.id ? project : candidate, + ), + ); if (project.id === activeProjectId) { - setWorkMode( - normalizeInteractiveWorkMode(project.defaultWorkMode) - ) + setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); } - return project - } + return project; + }; const loadCommittedRemoteProject = async ( - project: AssistantProject + project: AssistantProject, ): Promise => { - if ( - !remoteProjectsEnabled || - project.executionSpace.kind !== 'ssh' - ) { - return + if (!remoteProjectsEnabled || project.executionSpace.kind !== "ssh") { + return; } setProjects((current) => current.some((candidate) => candidate.id === project.id) ? current.map((candidate) => - candidate.id === project.id ? project : candidate + candidate.id === project.id ? project : candidate, ) - : [project, ...current] - ) - commitProjectSelection(project) + : [project, ...current], + ); + commitProjectSelection(project); if ( !isProjectRecoveryUnsettled( - projectRecoveryByProjectIdRef.current[project.id] + projectRecoveryByProjectIdRef.current[project.id], ) ) { - resumeProjectConversationQueues(project.id) + resumeProjectConversationQueues(project.id); } - } + }; - const handleRemoteProjectsEnabledChange = ( - enabled: boolean - ): void => { - setRemoteProjectsEnabled(enabled) + const handleRemoteProjectsEnabledChange = (enabled: boolean): void => { + setRemoteProjectsEnabled(enabled); if (enabled) { - return + return; } - if (activeProject?.executionSpace.kind !== 'ssh') { - return + if (activeProject?.executionSpace.kind !== "ssh") { + return; } - const localProject = projects.find(isOrdinaryLocalProject) + const localProject = projects.find(isOrdinaryLocalProject); if (localProject) { - commitProjectSelection(localProject) + commitProjectSelection(localProject); } - } + }; const archiveProject = async (projectId: string): Promise => { - await window.goodbuddy.projects.setArchived(projectId, true) - const remaining = projects.filter((project) => project.id !== projectId) - setProjects(remaining) + await window.goodbuddy.projects.setArchived(projectId, true); + const remaining = projects.filter((project) => project.id !== projectId); + setProjects(remaining); if (projectId === activeProjectIdRef.current) { const next = remaining.find(isOrdinaryLocalProject) ?? - (remoteProjectsEnabled ? remaining[0] : undefined) + (remoteProjectsEnabled ? remaining[0] : undefined); if (next) { - selectProject(next.id) + selectProject(next.id); } } - } + }; - const removeProjectsFromUi = ( - projectIds: readonly string[] - ): void => { + const removeProjectsFromUi = (projectIds: readonly string[]): void => { if (projectIds.length === 0) { - return + return; } - const deletedProjectIds = new Set(projectIds) - const referencesDeletedProject = ( - projectId: string | undefined - ): boolean => - projectId !== undefined && deletedProjectIds.has(projectId) + const deletedProjectIds = new Set(projectIds); + const referencesDeletedProject = (projectId: string | undefined): boolean => + projectId !== undefined && deletedProjectIds.has(projectId); const remainingProjects = projectsRef.current.filter( - (project) => !deletedProjectIds.has(project.id) - ) - const currentConversations = conversationsRef.current + (project) => !deletedProjectIds.has(project.id), + ); + const currentConversations = conversationsRef.current; const deletedConversationIds = new Set( currentConversations .filter((conversation) => - referencesDeletedProject(conversation.projectId) + referencesDeletedProject(conversation.projectId), ) - .map((conversation) => conversation.id) - ) + .map((conversation) => conversation.id), + ); const remainingConversations = currentConversations.filter( - (conversation) => - !referencesDeletedProject(conversation.projectId) - ) + (conversation) => !referencesDeletedProject(conversation.projectId), + ); setProjects((current) => - current.filter( - (project) => !deletedProjectIds.has(project.id) - ) - ) + current.filter((project) => !deletedProjectIds.has(project.id)), + ); setConversations((current) => current.filter( - (conversation) => - !referencesDeletedProject(conversation.projectId) - ) - ) + (conversation) => !referencesDeletedProject(conversation.projectId), + ), + ); setAssistantTasks((current) => - current.filter( - (task) => !referencesDeletedProject(task.projectId) - ) - ) + current.filter((task) => !referencesDeletedProject(task.projectId)), + ); setAssistantArtifacts((current) => current.filter( - (artifact) => - !referencesDeletedProject(artifact.projectId) - ) - ) + (artifact) => !referencesDeletedProject(artifact.projectId), + ), + ); setAssistantMemories((current) => current.filter( (memory) => !( - memory.scope === 'project' && + memory.scope === "project" && referencesDeletedProject(memory.scopeId) ) && !( - memory.scope === 'conversation' && + memory.scope === "conversation" && memory.scopeId !== undefined && deletedConversationIds.has(memory.scopeId) - ) - ) - ) + ), + ), + ); setAssistantSchedules((current) => current.filter( - (schedule) => - !referencesDeletedProject(schedule.projectId) - ) - ) + (schedule) => !referencesDeletedProject(schedule.projectId), + ), + ); setAssistantHeartbeats((current) => current.flatMap((heartbeat) => { - if (heartbeat.scope.kind === 'global') { - return heartbeat + if (heartbeat.scope.kind === "global") { + return heartbeat; } const projectIds = heartbeat.scope.projectIds.filter( - (id) => !deletedProjectIds.has(id) - ) + (id) => !deletedProjectIds.has(id), + ); return projectIds.length > 0 - ? [{ ...heartbeat, scope: { kind: 'projects', projectIds } }] - : [] - }) - ) + ? [{ ...heartbeat, scope: { kind: "projects", projectIds } }] + : []; + }), + ); if (!deletedProjectIds.has(activeProjectIdRef.current)) { - return + return; } - const next = remainingProjects.find(isOrdinaryLocalProject) + const next = remainingProjects.find(isOrdinaryLocalProject); if (next) { - commitProjectSelection(next, remainingConversations) - return + commitProjectSelection(next, remainingConversations); + return; } - setActiveProjectId('') - setActiveId('') - setView('chat') - } + setActiveProjectId(""); + setActiveId(""); + setView("chat"); + }; const deleteProject = async ( projectId: string, - confirmation: string + confirmation: string, ): Promise => { - await window.goodbuddy.projects.delete(projectId, confirmation) - removeProjectsFromUi([projectId]) - } + await window.goodbuddy.projects.delete(projectId, confirmation); + removeProjectsFromUi([projectId]); + }; const newConversation = (): boolean => { - return startNewConversation(activeProjectId || undefined) - } + return startNewConversation(activeProjectId || undefined); + }; const setMemoryStatus = async ( memoryId: string, - status: AssistantMemory['status'] + status: AssistantMemory["status"], ): Promise => { - await window.goodbuddy.memory.setStatus(memoryId, status) + await window.goodbuddy.memory.setStatus(memoryId, status); setAssistantMemories((current) => - status === 'rejected' + status === "rejected" ? current.filter((memory) => memory.id !== memoryId) : current.map((memory) => - memory.id === memoryId ? { ...memory, status } : memory - ) - ) + memory.id === memoryId ? { ...memory, status } : memory, + ), + ); setHeartbeatMemories((current) => - status === 'rejected' + status === "rejected" ? current.filter((memory) => memory.id !== memoryId) : current.map((memory) => - memory.id === memoryId ? { ...memory, status } : memory - ) - ) - } + memory.id === memoryId ? { ...memory, status } : memory, + ), + ); + }; const useHeartbeatTask = (task: AssistantTask): void => { if (task.projectId && task.projectId !== activeProjectId) { - setActiveProjectId(task.projectId) + setActiveProjectId(task.projectId); } if ( - !startNewConversation( - task.projectId ?? (activeProjectId || undefined) - ) + !startNewConversation(task.projectId ?? (activeProjectId || undefined)) ) { - return + return; } - setWorkMode('ask') + setWorkMode("ask"); setInput( - [ - t('notices.heartbeatTaskPrompt'), - task.title, - task.instructions - ].join('\n\n') - ) + [t("notices.heartbeatTaskPrompt"), task.title, task.instructions].join( + "\n\n", + ), + ); notify({ - tone: 'info', - message: t('notices.heartbeatTaskAdded', { title: task.title }) - }) - requestAnimationFrame(() => inputRef.current?.focus()) - } + tone: "info", + message: t("notices.heartbeatTaskAdded", { title: task.title }), + }); + requestAnimationFrame(() => inputRef.current?.focus()); + }; const setHeartbeatTaskStatus = async ( taskId: string, - status: 'completed' | 'cancelled' + status: "completed" | "cancelled", ): Promise => { - await window.goodbuddy.tasks.setStatus(taskId, status) + await window.goodbuddy.tasks.setStatus(taskId, status); setAssistantTasks((current) => current.map((task) => task.id === taskId ? { ...task, status, - completedAt: new Date().toISOString() + completedAt: new Date().toISOString(), } - : task - ) - ) - } + : task, + ), + ); + }; - const deleteConversation = async ( - conversationId: string - ): Promise => { + const deleteConversation = async (conversationId: string): Promise => { if (deletingConversationId) { - return + return; } - setDeletingConversationId(conversationId) + setDeletingConversationId(conversationId); const activeRequests = [...activeRuns.current.entries()] .filter(([, run]) => run.conversationId === conversationId) - .map(([requestId]) => requestId) + .map(([requestId]) => requestId); try { await Promise.all( activeRequests.map((requestId) => - window.goodbuddy.agent.cancel(requestId) - ) - ) + window.goodbuddy.agent.cancel(requestId), + ), + ); } catch { notify({ - tone: 'error', - message: t('notices.deleteConversationCancelFailed') - }) - setDeletingConversationId('') - return + tone: "error", + message: t("notices.deleteConversationCancelFailed"), + }); + setDeletingConversationId(""); + return; } const deletingConversation = conversations.find( - (conversation) => conversation.id === conversationId - ) + (conversation) => conversation.id === conversationId, + ); if (deletingConversation && !deletingConversation.remote) { - deletingLocalConversationIdsRef.current.add(conversationId) + deletingLocalConversationIdsRef.current.add(conversationId); try { - await conversationPersistenceQueueRef.current - await window.goodbuddy.conversations.deleteLocal(conversationId) - persistedLocalConversationsRef.current.delete(conversationId) + await conversationPersistenceQueueRef.current; + await window.goodbuddy.conversations.deleteLocal(conversationId); + persistedLocalConversationsRef.current.delete(conversationId); } catch { - deletingLocalConversationIdsRef.current.delete(conversationId) + deletingLocalConversationIdsRef.current.delete(conversationId); notify({ - tone: 'error', - message: t( - 'notices.deleteConversationPersistenceFailed' - ) - }) - setDeletingConversationId('') - return + tone: "error", + message: t("notices.deleteConversationPersistenceFailed"), + }); + setDeletingConversationId(""); + return; } } - setConfirmingConversationId('') - setDeletingConversationId('') + setConfirmingConversationId(""); + setDeletingConversationId(""); if (conversationActionsId === conversationId) { - setConversationActionsId('') + setConversationActionsId(""); } if (renamingConversationId === conversationId) { - setRenamingConversationId('') + setRenamingConversationId(""); } - const browserStop = window.goodbuddy.browser?.stop(conversationId) + const browserStop = window.goodbuddy.browser?.stop(conversationId); if (browserStop) { void browserStop.catch(() => { notify({ - tone: 'error', - message: t('notices.deletedConversationBrowserCloseFailed') - }) - }) + tone: "error", + message: t("notices.deletedConversationBrowserCloseFailed"), + }); + }); } setBrowserStates((current) => { - const next = { ...current } - delete next[conversationId] - return next - }) - const draftAttachments = - attachmentsRef.current.get(conversationId) ?? [] - attachmentsRef.current.delete(conversationId) + const next = { ...current }; + delete next[conversationId]; + return next; + }); + const draftAttachments = attachmentsRef.current.get(conversationId) ?? []; + attachmentsRef.current.delete(conversationId); for (const attachment of draftAttachments) { - void window.goodbuddy.context.remove(attachment.id) + void window.goodbuddy.context.remove(attachment.id); } setAttachmentsByConversation((current) => { - const next = { ...current } - delete next[conversationId] - return next - }) + const next = { ...current }; + delete next[conversationId]; + return next; + }); setConversationDrafts((current) => { - const next = { ...current } - delete next[conversationId] - return next - }) + const next = { ...current }; + delete next[conversationId]; + return next; + }); setChatScrollSnapshots((current) => { - const next = { ...current } - delete next[conversationId] - return next - }) + const next = { ...current }; + delete next[conversationId]; + return next; + }); setVisibleMessageCounts((current) => { - const next = { ...current } - delete next[conversationId] - return next - }) - setConversationActivity(conversationId, false) + const next = { ...current }; + delete next[conversationId]; + return next; + }); + setConversationActivity(conversationId, false); const remaining = conversations.filter( - (conversation) => conversation.id !== conversationId - ) - conversationsRef.current = remaining - deletingLocalConversationIdsRef.current.delete(conversationId) + (conversation) => conversation.id !== conversationId, + ); + conversationsRef.current = remaining; + deletingLocalConversationIdsRef.current.delete(conversationId); const projectRemaining = remaining.filter( - (conversation) => conversation.projectId === activeProjectId - ) - setConversations(remaining) + (conversation) => conversation.projectId === activeProjectId, + ); + setConversations(remaining); if (projectRemaining.length > 0) { if (conversationId === activeId) { - setActiveId(projectRemaining[0]?.id ?? '') + setActiveId(projectRemaining[0]?.id ?? ""); } - return + return; } - if (activeProject?.kind === 'channel') { - setActiveId('') - return + if (activeProject?.kind === "channel") { + setActiveId(""); + return; } const replacement = createConversation( activeProjectId || undefined, runtimeSettings - ? getProjectDefaultRuntimeSelection( - activeProject, - runtimeSettings - ) + ? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings) : undefined, - t('conversation.greeting') - ) - setConversations((current) => [replacement, ...current]) - setActiveId(replacement.id) - } + t("conversation.greeting"), + ); + setConversations((current) => [replacement, ...current]); + setActiveId(replacement.id); + }; const branchConversation = async ( - sourceConversation: Conversation + sourceConversation: Conversation, ): Promise => { if ( branchingConversationId || @@ -6863,461 +6498,431 @@ function App(): React.JSX.Element { activeConversationIds.has(sourceConversation.id) || queuedConversationIds.has(sourceConversation.id) ) { - return + return; } - setBranchingConversationId(sourceConversation.id) + setBranchingConversationId(sourceConversation.id); try { - persistLocalConversationChanges() - await conversationPersistenceQueueRef.current + persistLocalConversationChanges(); + await conversationPersistenceQueueRef.current; if ( - persistedLocalConversationsRef.current.get( - sourceConversation.id - ) !== sourceConversation || + persistedLocalConversationsRef.current.get(sourceConversation.id) !== + sourceConversation || conversationsRef.current.find( - (conversation) => - conversation.id === sourceConversation.id + (conversation) => conversation.id === sourceConversation.id, ) !== sourceConversation ) { - throw new Error(t('notices.conversationPersistenceFailed')) + throw new Error(t("notices.conversationPersistenceFailed")); } const sourceTitle = getConversationDisplayTitle( sourceConversation, - t('conversation.defaultTitle') - ) + t("conversation.defaultTitle"), + ); const branch = await window.goodbuddy.conversations.branchLocal({ sourceConversationId: sourceConversation.id, title: createConversationBranchTitle( sourceTitle, - t('conversation.branch.suffix') - ) - }) - const nextBranch: Conversation = branch - persistedLocalConversationsRef.current.set( - nextBranch.id, - nextBranch - ) + t("conversation.branch.suffix"), + ), + }); + const nextBranch: Conversation = branch; + persistedLocalConversationsRef.current.set(nextBranch.id, nextBranch); setConversations((current) => [ nextBranch, - ...current.filter( - (conversation) => conversation.id !== nextBranch.id - ) - ]) - setSelectedAssistantTaskId(undefined) - setActiveId(nextBranch.id) - setView('chat') + ...current.filter((conversation) => conversation.id !== nextBranch.id), + ]); + setSelectedAssistantTaskId(undefined); + setActiveId(nextBranch.id); + setView("chat"); if (narrowWindow) { - closeNarrowSidebar() + closeNarrowSidebar(); } notify({ - tone: 'success', - message: t('notices.conversationBranched') - }) - requestAnimationFrame(() => inputRef.current?.focus()) + tone: "success", + message: t("notices.conversationBranched"), + }); + requestAnimationFrame(() => inputRef.current?.focus()); } catch (error) { notify({ - tone: 'error', + tone: "error", message: displayErrorMessage( error, - t('notices.conversationBranchFailed') - ) - }) - focusConversationActions(sourceConversation.id) + t("notices.conversationBranchFailed"), + ), + }); + focusConversationActions(sourceConversation.id); } finally { - setBranchingConversationId('') + setBranchingConversationId(""); } - } + }; const focusConversationActions = (conversationId: string): void => { requestAnimationFrame(() => - conversationActionTriggerRefs.current.get(conversationId)?.focus() - ) - } + conversationActionTriggerRefs.current.get(conversationId)?.focus(), + ); + }; - const saveTitle = ( - conversationId: string, - titleInput: string - ): void => { - const title = titleInput.trim().slice(0, 80) + const saveTitle = (conversationId: string, titleInput: string): void => { + const title = titleInput.trim().slice(0, 80); if (!title) { - return + return; } setConversations((current) => current.map((conversation) => conversation.id === conversationId ? { ...conversation, title, updatedAt: Date.now() } - : conversation - ) - ) - setRenamingConversationId('') - focusConversationActions(conversationId) - } + : conversation, + ), + ); + setRenamingConversationId(""); + focusConversationActions(conversationId); + }; const writeClipboardText = useCallback( - async ( - content: string, - successMessage: string - ): Promise => { + async (content: string, successMessage: string): Promise => { try { - await window.goodbuddy.clipboard.writeText(content) + await window.goodbuddy.clipboard.writeText(content); notify({ - tone: 'success', - message: successMessage - }) + tone: "success", + message: successMessage, + }); } catch { notify({ - tone: 'error', - message: t('notices.clipboardUnavailable') - }) + tone: "error", + message: t("notices.clipboardUnavailable"), + }); } }, - [t] - ) + [t], + ); const copyConversation = async ( - conversation: ConversationSnapshot + conversation: ConversationSnapshot, ): Promise => { const transcript = conversation.messages - .map( - (message) => - t('chat.exportSpeaker', { - speaker: - message.role === 'user' ? t('chat.user') : 'GoodBuddy', - content: `${message.content}${formatAttachmentList( - message.attachments, - t - )}` - }) + .map((message) => + t("chat.exportSpeaker", { + speaker: message.role === "user" ? t("chat.user") : "GoodBuddy", + content: `${message.content}${formatAttachmentList( + message.attachments, + t, + )}`, + }), ) - .join('\n\n') - await writeClipboardText( - transcript, - t('notices.conversationCopied') - ) - } + .join("\n\n"); + await writeClipboardText(transcript, t("notices.conversationCopied")); + }; const copyMessage = useCallback( (content: string): Promise => - writeClipboardText(content, t('notices.messageCopied')), - [t, writeClipboardText] - ) + writeClipboardText(content, t("notices.messageCopied")), + [t, writeClipboardText], + ); - const exportConversation = ( - conversation: ConversationSnapshot - ): void => { + const exportConversation = (conversation: ConversationSnapshot): void => { const markdown = [ `# ${conversation.title}`, - '', + "", ...conversation.messages.flatMap((message) => [ - `## ${message.role === 'user' ? t('chat.user') : 'GoodBuddy'}`, - '', - `${message.content}${formatAttachmentList( - message.attachments, - t - )}`, - '' - ]) - ].join('\n') + `## ${message.role === "user" ? t("chat.user") : "GoodBuddy"}`, + "", + `${message.content}${formatAttachmentList(message.attachments, t)}`, + "", + ]), + ].join("\n"); const blob = new Blob([markdown], { - type: 'text/markdown;charset=utf-8' - }) - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url + type: "text/markdown;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; anchor.download = `${ - conversation.title.replace(/[\\/:*?"<>|]/g, '_') || - t('conversation.exportFallbackName') - }.md` - anchor.click() - URL.revokeObjectURL(url) - notify({ tone: 'success', message: t('notices.conversationExported') }) - } + conversation.title.replace(/[\\/:*?"<>|]/g, "_") || + t("conversation.exportFallbackName") + }.md`; + anchor.click(); + URL.revokeObjectURL(url); + notify({ tone: "success", message: t("notices.conversationExported") }); + }; - const openImageViewer = useCallback(( - item: ImageViewerItem, - trigger: HTMLElement - ): void => { - if (!imageDataUrlPattern.test(item.src)) { - notify({ - tone: 'error', - message: tRef.current('notices.imageUnavailable') - }) - return - } - imageViewerTriggerRef.current = trigger - setImageViewerItem(item) - }, []) + const openImageViewer = useCallback( + (item: ImageViewerItem, trigger: HTMLElement): void => { + if (!imageDataUrlPattern.test(item.src)) { + notify({ + tone: "error", + message: tRef.current("notices.imageUnavailable"), + }); + return; + } + imageViewerTriggerRef.current = trigger; + setImageViewerItem(item); + }, + [], + ); const closeImageViewer = (): void => { - const trigger = imageViewerTriggerRef.current - setImageViewerItem(undefined) - imageViewerTriggerRef.current = undefined - requestAnimationFrame(() => trigger?.focus()) - } + const trigger = imageViewerTriggerRef.current; + setImageViewerItem(undefined); + imageViewerTriggerRef.current = undefined; + requestAnimationFrame(() => trigger?.focus()); + }; - const openCitationContext = useCallback(async ( - reference: KnowledgeSearchReference - ): Promise => { - setCitationDialog({ - reference, - loading: true - }) - if (!reference.chunkId) { + const openCitationContext = useCallback( + async (reference: KnowledgeSearchReference): Promise => { setCitationDialog({ reference, - loading: false, - error: tRef.current('chat.citations.contextUnavailable') - }) - return - } - try { - const context = - await window.goodbuddy.knowledge.getReferenceContext({ + loading: true, + }); + if (!reference.chunkId) { + setCitationDialog({ + reference, + loading: false, + error: tRef.current("chat.citations.contextUnavailable"), + }); + return; + } + try { + const context = await window.goodbuddy.knowledge.getReferenceContext({ knowledgeBaseId: reference.libraryId, documentId: reference.documentId, - chunkId: reference.chunkId - }) - setCitationDialog({ - reference, - loading: false, - context: { - libraryName: reference.libraryName, - documentName: context.documentTitle, - sourceName: context.sourceDisplayName, - locator: context.locator, - matchedContent: context.matchedContent, - contextContent: context.contextContent, - truncated: context.truncated - } - }) - } catch (reason) { - setCitationDialog({ - reference, - loading: false, - error: - reason instanceof Error - ? reason.message - : tRef.current('chat.citations.contextUnavailable') - }) - } - }, []) + chunkId: reference.chunkId, + }); + setCitationDialog({ + reference, + loading: false, + context: { + libraryName: reference.libraryName, + documentName: context.documentTitle, + sourceName: context.sourceDisplayName, + locator: context.locator, + matchedContent: context.matchedContent, + contextContent: context.contextContent, + truncated: context.truncated, + }, + }); + } catch (reason) { + setCitationDialog({ + reference, + loading: false, + error: + reason instanceof Error + ? reason.message + : tRef.current("chat.citations.contextUnavailable"), + }); + } + }, + [], + ); - const openCitationSource = useCallback(async ( - reference: KnowledgeSearchReference - ): Promise => { - if (!reference.chunkId) { - return - } - try { - await window.goodbuddy.knowledge.openReferenceSource({ - knowledgeBaseId: reference.libraryId, - documentId: reference.documentId, - chunkId: reference.chunkId - }) - } catch (reason) { - notify({ - tone: 'error', - message: - reason instanceof Error - ? reason.message - : tRef.current('chat.citations.openFailed') - }) - } - }, []) + const openCitationSource = useCallback( + async (reference: KnowledgeSearchReference): Promise => { + if (!reference.chunkId) { + return; + } + try { + await window.goodbuddy.knowledge.openReferenceSource({ + knowledgeBaseId: reference.libraryId, + documentId: reference.documentId, + chunkId: reference.chunkId, + }); + } catch (reason) { + notify({ + tone: "error", + message: + reason instanceof Error + ? reason.message + : tRef.current("chat.citations.openFailed"), + }); + } + }, + [], + ); const downloadImage = useCallback((item: ImageViewerItem): void => { if (!imageDataUrlPattern.test(item.src)) { notify({ - tone: 'error', - message: tRef.current('notices.imageUnavailable') - }) - return + tone: "error", + message: tRef.current("notices.imageUnavailable"), + }); + return; } - const anchor = document.createElement('a') - anchor.href = item.src + const anchor = document.createElement("a"); + anchor.href = item.src; anchor.download = getImageDownloadName( item.title, item.src, - tRef.current('chat.images.fallbackTitle') - ) - anchor.rel = 'noopener' - anchor.click() + tRef.current("chat.images.fallbackTitle"), + ); + anchor.rel = "noopener"; + anchor.click(); notify({ - tone: 'info', - message: tRef.current('notices.imageDownloadStarted') - }) - }, []) + tone: "info", + message: tRef.current("notices.imageDownloadStarted"), + }); + }, []); const submit = async ( - queuedDispatch?: ConversationQueueDispatch + queuedDispatch?: ConversationQueueDispatch, ): Promise => { - const queuedInput = queuedDispatch?.input + const queuedInput = queuedDispatch?.input; const releaseQueuedItem = async (): Promise => { if (!queuedDispatch) { - return + return; } try { await window.goodbuddy.conversationQueue.releaseUser( - queuedDispatch.item.id - ) + queuedDispatch.item.id, + ); } catch { notify({ - tone: 'error', - message: t('notices.conversationQueueReleaseFailed') - }) + tone: "error", + message: t("notices.conversationQueueReleaseFailed"), + }); } - } + }; const conversationSnapshot = queuedInput ? conversationsRef.current.find( - (conversation) => - conversation.id === queuedInput.conversationId + (conversation) => conversation.id === queuedInput.conversationId, ) - : activeConversation + : activeConversation; const recoveryProjectId = - queuedInput?.projectId ?? conversationSnapshot?.projectId + queuedInput?.projectId ?? conversationSnapshot?.projectId; if ( recoveryProjectId && - (((!projectRecoverySnapshotReadyRef.current && + ((!projectRecoverySnapshotReadyRef.current && isManagedSshProject( projectsRef.current.find( - (project) => project.id === recoveryProjectId - ) - ))) || + (project) => project.id === recoveryProjectId, + ), + )) || isProjectRecoveryUnsettled( - projectRecoveryByProjectIdRef.current[recoveryProjectId] + projectRecoveryByProjectIdRef.current[recoveryProjectId], )) ) { - await releaseQueuedItem() - return + await releaseQueuedItem(); + return; } const command = - !queuedInput && - activeRuntimeSelection?.provider === 'opencode' + !queuedInput && activeRuntimeSelection?.provider === "opencode" ? runtimeNativeSnapshot?.commands.find( - (candidate) => - candidate.id === selectedRuntimeCommand + (candidate) => candidate.id === selectedRuntimeCommand, ) - : undefined - const commandArguments = queuedInput ? '' : input.trim() - const prompt = queuedInput?.prompt ?? (command - ? `/${command.name}${ - commandArguments ? ` ${commandArguments}` : '' - }` - : commandArguments) + : undefined; + const commandArguments = queuedInput ? "" : input.trim(); + const prompt = + queuedInput?.prompt ?? + (command + ? `/${command.name}${commandArguments ? ` ${commandArguments}` : ""}` + : commandArguments); if (!prompt || !conversationSnapshot) { - await releaseQueuedItem() - return + await releaseQueuedItem(); + return; } if (!queuedInput && selectingContextFilesRef.current) { notify({ - tone: 'info', - message: t('composer.attachmentProgress.waitBeforeSending') - }) - return + tone: "info", + message: t("composer.attachmentProgress.waitBeforeSending"), + }); + return; } if (conversationSnapshot.remote) { notify({ - tone: 'info', - message: t('notices.remoteConversationReadOnly') - }) - await releaseQueuedItem() - return + tone: "info", + message: t("notices.remoteConversationReadOnly"), + }); + await releaseQueuedItem(); + return; } if (!queuedInput && !runtime) { notify({ - tone: 'info', - message: t('runtime.loadingRetry') - }) - return + tone: "info", + message: t("runtime.loadingRetry"), + }); + return; } if ( !queuedInput && - (runtimeSwitching || - runtimeStatusKey !== activeRuntimeSelectionKey) + (runtimeSwitching || runtimeStatusKey !== activeRuntimeSelectionKey) ) { notify({ - tone: 'info', - message: t('runtime.updatingRetry') - }) - return + tone: "info", + message: t("runtime.updatingRetry"), + }); + return; } if (!queuedInput && !runtime?.available) { - return + return; } - const requestId = crypto.randomUUID() - const conversationId = conversationSnapshot.id - const attachmentSnapshot = ( - queuedInput?.attachments ?? attachments - ).slice(0, 8) - const historySnapshot = conversationSnapshot.messages + const requestId = crypto.randomUUID(); + const conversationId = conversationSnapshot.id; + const attachmentSnapshot = (queuedInput?.attachments ?? attachments).slice( + 0, + 8, + ); + const historySnapshot = conversationSnapshot.messages; const retainedHistorySnapshot = historySnapshot .filter( - (message) => - message.state === 'complete' && message.content.trim() + (message) => message.state === "complete" && message.content.trim(), ) - .slice(-500) + .slice(-500); const projectIdSnapshot = queuedInput ? queuedInput.projectId - : activeProjectId || undefined + : activeProjectId || undefined; const knowledgeRetrievalModeSnapshot = queuedInput?.knowledgeRetrievalMode ?? conversationSnapshot.knowledgeRetrievalMode ?? - 'auto' + "auto"; const runtimeSelectionSnapshot = - queuedInput?.runtimeSelection ?? activeRuntimeSelection + queuedInput?.runtimeSelection ?? activeRuntimeSelection; if (!runtimeSelectionSnapshot) { - notify({ tone: 'info', message: t('runtime.notSelected') }) - await releaseQueuedItem() - return + notify({ tone: "info", message: t("runtime.notSelected") }); + await releaseQueuedItem(); + return; } - const runtimeControlSnapshot: RuntimeControl | undefined = - queuedInput - ? queuedInput.runtimeControl - : runtimeSelectionSnapshot.provider === 'opencode' && - (selectedRuntimeAgent || command) + const runtimeControlSnapshot: RuntimeControl | undefined = queuedInput + ? queuedInput.runtimeControl + : runtimeSelectionSnapshot.provider === "opencode" && + (selectedRuntimeAgent || command) + ? { + provider: "opencode", + ...(selectedRuntimeAgent ? { agent: selectedRuntimeAgent } : {}), + ...(command + ? { + command: { + name: command.name, + arguments: commandArguments, + }, + } + : {}), + } + : runtimeSelectionSnapshot.provider === "continue" && + selectedContinuePreset ? { - provider: 'opencode', - ...(selectedRuntimeAgent - ? { agent: selectedRuntimeAgent } - : {}), - ...(command - ? { - command: { - name: command.name, - arguments: commandArguments - } - } - : {}) + provider: "continue", + presetId: selectedContinuePreset, } - : runtimeSelectionSnapshot.provider === 'continue' && - selectedContinuePreset - ? { - provider: 'continue', - presetId: selectedContinuePreset - } - : undefined + : undefined; const selectedExpertSnapshot = queuedInput ? queuedInput.teamMode - ? 'team' - : queuedInput.expertId ?? '' - : runtime?.capability === 'image-generation' - ? '' - : selectedExpertId - const workModeSnapshot = - normalizeInteractiveWorkMode( - queuedInput?.workMode ?? effectiveWorkMode - ) + ? "team" + : (queuedInput.expertId ?? "") + : runtime?.capability === "image-generation" + ? "" + : selectedExpertId; + const workModeSnapshot = normalizeInteractiveWorkMode( + queuedInput?.workMode ?? effectiveWorkMode, + ); const knowledgeLibraryIdsSnapshot = - queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds + queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds; const smartRoutingSnapshot = queuedInput?.smartRouting ?? (!queuedInput && - runtime?.capability !== 'image-generation' && + runtime?.capability !== "image-generation" && runtimeSettings?.subagentSmartRoutingEnabled === true && !selectedExpertSnapshot && supportsSubagentSmartRouting(workModeSnapshot) ? true - : undefined) + : undefined); if (!queuedInput) { const queueInput: ConversationQueueUserInput = { @@ -7327,112 +6932,91 @@ function App(): React.JSX.Element { ...(runtimeControlSnapshot ? { runtimeControl: runtimeControlSnapshot } : {}), - ...(selectedExpertSnapshot && - selectedExpertSnapshot !== 'team' + ...(selectedExpertSnapshot && selectedExpertSnapshot !== "team" ? { expertId: selectedExpertSnapshot } : {}), - ...(selectedExpertSnapshot === 'team' - ? { teamMode: true } - : {}), - ...(smartRoutingSnapshot - ? { smartRouting: true } - : {}), + ...(selectedExpertSnapshot === "team" ? { teamMode: true } : {}), + ...(smartRoutingSnapshot ? { smartRouting: true } : {}), workMode: workModeSnapshot, includeMemoryContext: !command, prompt, attachments: attachmentSnapshot, knowledgeLibraryIds: knowledgeLibraryIdsSnapshot, - knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot - } + knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot, + }; try { - persistLocalConversationChanges() - await conversationPersistenceQueueRef.current + persistLocalConversationChanges(); + await conversationPersistenceQueueRef.current; if ( - !persistedLocalConversationsRef.current.has( - conversationSnapshot.id - ) + !persistedLocalConversationsRef.current.has(conversationSnapshot.id) ) { - throw new Error( - t('notices.conversationPersistenceFailed') - ) + throw new Error(t("notices.conversationPersistenceFailed")); } - await window.goodbuddy.conversationQueue.enqueueUser(queueInput) - setComposerMenuOpen(undefined) - setRuntimeMenuOpen(false) - setInput('') - updateAttachments([]) + await window.goodbuddy.conversationQueue.enqueueUser(queueInput); + setComposerMenuOpen(undefined); + setRuntimeMenuOpen(false); + setInput(""); + updateAttachments([]); if (command) { - setSelectedRuntimeCommand('') + setSelectedRuntimeCommand(""); } } catch (reason) { notify({ - tone: 'error', - message: displayErrorMessage( - reason, - t('notices.sendFailed') - ) - }) + tone: "error", + message: displayErrorMessage(reason, t("notices.sendFailed")), + }); } - return + return; } - if ( - dispatchedConversationQueueItems.current.has( - queuedDispatch.item.id - ) - ) { - return + if (dispatchedConversationQueueItems.current.has(queuedDispatch.item.id)) { + return; } - dispatchedConversationQueueItems.current.add( - queuedDispatch.item.id - ) - setComposerMenuOpen(undefined) - setRuntimeMenuOpen(false) - preparingConversations.current.add(conversationId) - setConversationActivity(conversationId, true) + dispatchedConversationQueueItems.current.add(queuedDispatch.item.id); + setComposerMenuOpen(undefined); + setRuntimeMenuOpen(false); + preparingConversations.current.add(conversationId); + setConversationActivity(conversationId, true); const userMessage: Message = { id: crypto.randomUUID(), queueItemId: queuedDispatch.item.id, - role: 'user', + role: "user", content: prompt, createdAt: Date.now(), - state: 'complete', + state: "complete", attachments: - attachmentSnapshot.length > 0 ? attachmentSnapshot : undefined - } + attachmentSnapshot.length > 0 ? attachmentSnapshot : undefined, + }; setConversations((current) => current.map((conversation) => conversation.id === conversationId ? { ...conversation, title: - conversation.title === '新对话' + conversation.title === "新对话" ? prompt.slice(0, 24) : conversation.title, updatedAt: Date.now(), - messages: [ - ...conversation.messages.slice(-499), - userMessage - ] + messages: [...conversation.messages.slice(-499), userMessage], } - : conversation - ) - ) + : conversation, + ), + ); const memoryContext = queuedInput.includeMemoryContext ? buildMemoryContext(assistantMemories) - : '' + : ""; const executionPrompt = memoryContext ? `${prompt}\n\n${memoryContext}` - : prompt + : prompt; const assistantMessage: Message = { id: crypto.randomUUID(), - role: 'assistant', - content: '', + role: "assistant", + content: "", blocks: [], createdAt: Date.now(), - state: 'streaming', - status: t('runtime.connecting') - } + state: "streaming", + status: t("runtime.connecting"), + }; activeRuns.current.set(requestId, { conversationId, @@ -7442,13 +7026,13 @@ function App(): React.JSX.Element { runtimeSettings ? resolveContextMetricsRuntimeSelection( runtimeSelectionSnapshot, - runtimeSettings + runtimeSettings, ) - : runtimeSelectionSnapshot - ) - }) - preparingConversations.current.delete(conversationId) - const startedAt = new Date().toISOString() + : runtimeSelectionSnapshot, + ), + }); + preparingConversations.current.delete(conversationId); + const startedAt = new Date().toISOString(); setAssistantTasks((current) => [ { @@ -7457,22 +7041,22 @@ function App(): React.JSX.Element { conversationId, title: prompt.slice(0, 120), instructions: prompt, - origin: 'user' as const, - status: 'running' as const, + origin: "user" as const, + status: "running" as const, createdAt: startedAt, - startedAt + startedAt, }, - ...current - ].slice(0, 100) - ) + ...current, + ].slice(0, 100), + ); recordActivity({ conversationId, requestId, - kind: 'request', + kind: "request", title: prompt.slice(0, 120), - detail: t('notices.userStartedTask'), - status: 'running' - }) + detail: t("notices.userStartedTask"), + status: "running", + }); setConversations((current) => current.map((conversation) => conversation.id === conversationId @@ -7481,12 +7065,12 @@ function App(): React.JSX.Element { updatedAt: Date.now(), messages: [ ...conversation.messages.slice(-499), - assistantMessage - ] + assistantMessage, + ], } - : conversation - ) - ) + : conversation, + ), + ); try { await window.goodbuddy.agent.run({ requestId, @@ -7496,39 +7080,34 @@ function App(): React.JSX.Element { runtimeSelection: runtimeSelectionSnapshot, runtimeControl: runtimeControlSnapshot, expertId: - selectedExpertSnapshot && selectedExpertSnapshot !== 'team' + selectedExpertSnapshot && selectedExpertSnapshot !== "team" ? selectedExpertSnapshot : undefined, - teamMode: selectedExpertSnapshot === 'team', + teamMode: selectedExpertSnapshot === "team", smartRouting: smartRoutingSnapshot, workMode: workModeSnapshot, prompt: executionPrompt, knowledgeLibraryIds: knowledgeLibraryIdsSnapshot, knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot, - contextIds: attachmentSnapshot.map( - (attachment) => attachment.id - ), - contextCompressionState: - conversationSnapshot.contextCompressionState, + contextIds: attachmentSnapshot.map((attachment) => attachment.id), + contextCompressionState: conversationSnapshot.contextCompressionState, history: retainedHistorySnapshot.map((message) => ({ role: message.role, - content: message.content + content: message.content, })), - historyMessageIds: retainedHistorySnapshot.map( - (message) => message.id - ), + historyMessageIds: retainedHistorySnapshot.map((message) => message.id), currentUserMessageId: userMessage.id, - currentAssistantMessageId: assistantMessage.id - }) + currentAssistantMessageId: assistantMessage.id, + }); for (const attachment of attachmentSnapshot) { - void window.goodbuddy.context.remove(attachment.id) + void window.goodbuddy.context.remove(attachment.id); } } catch (error) { - preparingConversations.current.delete(conversationId) - activeRuns.current.delete(requestId) - setConversationActivity(conversationId, false) + preparingConversations.current.delete(conversationId); + activeRuns.current.delete(requestId); + setConversationActivity(conversationId, false); for (const attachment of attachmentSnapshot) { - void window.goodbuddy.context.remove(attachment.id) + void window.goodbuddy.context.remove(attachment.id); } setConversations((current) => current.map((conversation) => @@ -7536,7 +7115,7 @@ function App(): React.JSX.Element { ? { ...conversation, title: - conversationSnapshot.title === '新对话' && + conversationSnapshot.title === "新对话" && conversation.title === prompt.slice(0, 24) ? conversationSnapshot.title : conversation.title, @@ -7544,95 +7123,87 @@ function App(): React.JSX.Element { messages: conversation.messages.filter( (message) => message.id !== userMessage.id && - message.id !== assistantMessage.id - ) + message.id !== assistantMessage.id, + ), } - : conversation - ) - ) + : conversation, + ), + ); setAssistantTasks((current) => - current.filter((task) => task.id !== requestId) - ) + current.filter((task) => task.id !== requestId), + ); setActivityRecords((current) => - current.filter((record) => record.requestId !== requestId) - ) + current.filter((record) => record.requestId !== requestId), + ); notify({ - tone: 'error', - message: displayErrorMessage( - error, - t('notices.sendFailed') - ) - }) - await releaseQueuedItem() + tone: "error", + message: displayErrorMessage(error, t("notices.sendFailed")), + }); + await releaseQueuedItem(); } finally { - dispatchedConversationQueueItems.current.delete( - queuedDispatch.item.id - ) + dispatchedConversationQueueItems.current.delete(queuedDispatch.item.id); } - } + }; useLayoutEffect(() => { conversationQueueDispatchRef.current = (dispatch) => { - void submit(dispatch) - } - }) + void submit(dispatch); + }; + }); const compactRuntimeContext = async (): Promise => { if ( !activeConversation || !activeRuntimeSelection || - (activeRuntimeSelection.provider !== 'opencode' && - activeRuntimeSelection.provider !== 'continue') || + (activeRuntimeSelection.provider !== "opencode" && + activeRuntimeSelection.provider !== "continue") || runtimeContextCompacting || isRunning ) { - return + return; } const history = activeConversation.messages .filter( - (message) => - message.state === 'complete' && message.content.trim() + (message) => message.state === "complete" && message.content.trim(), ) - .slice(-500) + .slice(-500); if (history.length < 2) { notify({ - tone: 'info', - message: t('composer.context.nothingToCompact'), - dedupeKey: 'runtime-context-compact' - }) - return + tone: "info", + message: t("composer.context.nothingToCompact"), + dedupeKey: "runtime-context-compact", + }); + return; } - const requestId = crypto.randomUUID() - setRuntimeContextCompacting(true) + const requestId = crypto.randomUUID(); + setRuntimeContextCompacting(true); try { - const result = - await window.goodbuddy.agent.compactConversation({ - requestId, - conversationId: activeConversation.id, - projectId: activeConversation.projectId, - runtimeSelection: activeRuntimeSelection, - history: history.map((message) => ({ - role: message.role, - content: message.content - })), - historyMessageIds: history.map((message) => message.id), - contextCompressionState: - activeConversation.contextCompressionState - }) + const result = await window.goodbuddy.agent.compactConversation({ + requestId, + conversationId: activeConversation.id, + projectId: activeConversation.projectId, + runtimeSelection: activeRuntimeSelection, + history: history.map((message) => ({ + role: message.role, + content: message.content, + })), + historyMessageIds: history.map((message) => message.id), + contextCompressionState: activeConversation.contextCompressionState, + }); if (result.contextCompressionState) { - const state = result.contextCompressionState + const state = result.contextCompressionState; const remainingHistory = history.slice( - Math.min(state.coveredMessageCount, history.length) - ) + Math.min(state.coveredMessageCount, history.length), + ); const estimatedAfterTokens = estimatedContextRequestOverheadTokens + estimateMessagesTokens([ ...buildConversationSummaryHistory(state.summary), ...remainingHistory.map((message) => ({ role: message.role, - content: message.content - })) - ]) + content: message.content, + })), + ]); setConversations((current) => current.map((conversation) => conversation.id === activeConversation.id @@ -7640,769 +7211,744 @@ function App(): React.JSX.Element { ...conversation, contextCompressionState: state, contextMetrics: { - runtimeSelectionKey: - activeRuntimeSelectionKey, + runtimeSelectionKey: activeRuntimeSelectionKey, contextTokens: estimatedAfterTokens, - source: 'estimated', - basis: 'conversation' + source: "estimated", + basis: "conversation", }, - updatedAt: Date.now() + updatedAt: Date.now(), } - : conversation - ) - ) + : conversation, + ), + ); } notify({ - tone: result.compacted ? 'success' : 'info', + tone: result.compacted ? "success" : "info", message: result.detail, - dedupeKey: 'runtime-context-compact' - }) + dedupeKey: "runtime-context-compact", + }); } catch (reason) { notify({ - tone: 'error', + tone: "error", message: reason instanceof Error ? reason.message - : t('composer.context.compactFailed'), - dedupeKey: 'runtime-context-compact' - }) + : t("composer.context.compactFailed"), + dedupeKey: "runtime-context-compact", + }); } finally { - setRuntimeContextCompacting(false) + setRuntimeContextCompacting(false); } - } + }; const stop = async (): Promise => { const requestId = [...activeRuns.current.entries()].find( - ([, run]) => run.conversationId === activeId - )?.[0] + ([, run]) => run.conversationId === activeId, + )?.[0]; if (requestId) { try { - await window.goodbuddy.agent.cancel(requestId) + await window.goodbuddy.agent.cancel(requestId); } catch { - notify({ tone: 'error', message: t('notices.stopFailed') }) + notify({ tone: "error", message: t("notices.stopFailed") }); } } - } + }; - const respondToApproval = useCallback(async ( - conversationId: string, - messageId: string, - approvalId: string, - decision: ApprovalDecision - ): Promise => { - try { - await window.goodbuddy.agent.respondApproval(approvalId, decision) - const approved = decision !== 'deny' - const decisionLabel = { - deny: tRef.current('chat.approval.decisionDeny'), - once: tRef.current('chat.approval.decisionOnce'), - session: tRef.current('chat.approval.decisionSession'), - permanent: tRef.current('chat.approval.decisionPermanent') - }[decision] - setActivityRecords((current) => { - let updated = false - return current.map((record) => { - if ( - !updated && - record.conversationId === conversationId && - record.kind === 'approval' && - record.status === 'pending' - ) { - updated = true - return { - ...record, - status: approved ? ('completed' as const) : ('denied' as const), - detail: `${record.detail}\n${tRef.current( - 'notices.userDecision', - { decision: decisionLabel } - )}` + const respondToApproval = useCallback( + async ( + conversationId: string, + messageId: string, + approvalId: string, + decision: ApprovalDecision, + ): Promise => { + try { + await window.goodbuddy.agent.respondApproval(approvalId, decision); + const approved = decision !== "deny"; + const decisionLabel = { + deny: tRef.current("chat.approval.decisionDeny"), + once: tRef.current("chat.approval.decisionOnce"), + session: tRef.current("chat.approval.decisionSession"), + permanent: tRef.current("chat.approval.decisionPermanent"), + }[decision]; + setActivityRecords((current) => { + let updated = false; + return current.map((record) => { + if ( + !updated && + record.conversationId === conversationId && + record.kind === "approval" && + record.status === "pending" + ) { + updated = true; + return { + ...record, + status: approved ? ("completed" as const) : ("denied" as const), + detail: `${record.detail}\n${tRef.current( + "notices.userDecision", + { decision: decisionLabel }, + )}`, + }; } - } - return record - }) - }) - updateMessage(conversationId, messageId, (message) => ({ - ...message, - approval: undefined, - status: - approved && message.task - ? undefined - : approved - ? tRef.current('chat.approval.executing', { - decision: decisionLabel - }) - : tRef.current('chat.approval.denied') - })) - } catch { - updateMessage(conversationId, messageId, (message) => ({ - ...message, - status: tRef.current('chat.approval.responseFailed') - })) - } - }, [updateMessage]) + return record; + }); + }); + updateMessage(conversationId, messageId, (message) => ({ + ...message, + approval: undefined, + status: + approved && message.task + ? undefined + : approved + ? tRef.current("chat.approval.executing", { + decision: decisionLabel, + }) + : tRef.current("chat.approval.denied"), + })); + } catch { + updateMessage(conversationId, messageId, (message) => ({ + ...message, + status: tRef.current("chat.approval.responseFailed"), + })); + } + }, + [updateMessage], + ); - const respondToQuestion = useCallback(async ( - conversationId: string, - messageId: string, - questionId: string, - answers?: AgentQuestionAnswer[] - ): Promise => { - await window.goodbuddy.agent.respondQuestion(questionId, answers) - updateMessage(conversationId, messageId, (message) => ({ - ...message, - question: undefined, - status: answers - ? tRef.current('chat.status.answerSubmitted') - : tRef.current('chat.status.questionSkipped') - })) - }, [updateMessage]) + const respondToQuestion = useCallback( + async ( + conversationId: string, + messageId: string, + questionId: string, + answers?: AgentQuestionAnswer[], + ): Promise => { + await window.goodbuddy.agent.respondQuestion(questionId, answers); + updateMessage(conversationId, messageId, (message) => ({ + ...message, + question: undefined, + status: answers + ? tRef.current("chat.status.answerSubmitted") + : tRef.current("chat.status.questionSkipped"), + })); + }, + [updateMessage], + ); const addContext = async ( - action: () => Promise + action: () => Promise, ): Promise => { - const conversationId = activeId - setContextError(undefined) + const conversationId = activeId; + setContextError(undefined); try { - const result = await action() - const selected = Array.isArray(result) ? result : [result] - const current = - attachmentsRef.current.get(conversationId) ?? [] + const result = await action(); + const selected = Array.isArray(result) ? result : [result]; + const current = attachmentsRef.current.get(conversationId) ?? []; const unique = selected.filter( - (item) => - !current.some((existing) => existing.id === item.id) - ) - const accepted = unique.slice( - 0, - Math.max(0, 8 - current.length) - ) + (item) => !current.some((existing) => existing.id === item.id), + ); + const accepted = unique.slice(0, Math.max(0, 8 - current.length)); for (const attachment of unique.slice(accepted.length)) { - void window.goodbuddy.context.remove(attachment.id) + void window.goodbuddy.context.remove(attachment.id); } - updateAttachments([...current, ...accepted]) + updateAttachments([...current, ...accepted]); if (accepted.length < unique.length) { - setContextError(t('composer.errors.attachmentLimit')) + setContextError(t("composer.errors.attachmentLimit")); } } catch (reason) { setContextError( reason instanceof Error ? reason.message - : t('composer.errors.addContext') - ) + : t("composer.errors.addContext"), + ); } - } + }; const selectContextFiles = async (): Promise => { if (selectingContextFilesRef.current) { - return + return; } - selectingContextFilesRef.current = true - setSelectingContextFiles(true) - setFileSelectionProgress(undefined) + selectingContextFilesRef.current = true; + setSelectingContextFiles(true); + setFileSelectionProgress(undefined); try { - await addContext(() => window.goodbuddy.context.selectFiles()) + await addContext(() => window.goodbuddy.context.selectFiles()); } finally { - selectingContextFilesRef.current = false - setSelectingContextFiles(false) - setFileSelectionProgress(undefined) + selectingContextFilesRef.current = false; + setSelectingContextFiles(false); + setFileSelectionProgress(undefined); } - } + }; const startWebSpeechInput = async (): Promise => { - const SpeechRecognition = - getSpeechRecognitionConstructor(window) + const SpeechRecognition = getSpeechRecognitionConstructor(window); if (!SpeechRecognition) { notify({ - tone: 'info', - message: t('composer.voice.unsupported') - }) - return + tone: "info", + message: t("composer.voice.unsupported"), + }); + return; } - setVoiceListening(true) - setVoiceRecording(false) - let started = false + setVoiceListening(true); + setVoiceRecording(false); + let started = false; try { const prepared = await prepareSpeechRecognition( SpeechRecognition, - 'zh-CN', + "zh-CN", () => { notify({ - tone: 'info', - message: t('composer.voice.downloadingPack'), - dedupeKey: 'speech-status' - }) - } - ) - const { recognition } = prepared + tone: "info", + message: t("composer.voice.downloadingPack"), + dedupeKey: "speech-status", + }); + }, + ); + const { recognition } = prepared; recognition.onresult = (event) => { - const transcript = event.results[0]?.[0]?.transcript?.trim() + const transcript = event.results[0]?.[0]?.transcript?.trim(); if (transcript) { setInput((current) => - current ? `${current} ${transcript}` : transcript - ) + current ? `${current} ${transcript}` : transcript, + ); notify({ - tone: 'success', - message: t('composer.voice.transcribed'), - dedupeKey: 'speech-status' - }) + tone: "success", + message: t("composer.voice.transcribed"), + dedupeKey: "speech-status", + }); } - } + }; recognition.onerror = (event) => { notify({ - tone: 'error', + tone: "error", message: describeSpeechRecognitionError(event), - dedupeKey: 'speech-status' - }) - setVoiceListening(false) - setVoiceRecording(false) - } + dedupeKey: "speech-status", + }); + setVoiceListening(false); + setVoiceRecording(false); + }; recognition.onend = () => { - setVoiceListening(false) - setVoiceRecording(false) - } - recognition.start() - started = true - setVoiceRecording(true) + setVoiceListening(false); + setVoiceRecording(false); + }; + recognition.start(); + started = true; + setVoiceRecording(true); notify({ - tone: 'info', + tone: "info", message: prepared.local - ? t('composer.voice.localListening') - : t('composer.voice.systemListening'), - dedupeKey: 'speech-status' - }) + ? t("composer.voice.localListening") + : t("composer.voice.systemListening"), + dedupeKey: "speech-status", + }); } catch (reason) { notify({ - tone: 'error', + tone: "error", message: reason instanceof Error ? reason.message - : t('composer.voice.startFailed'), - dedupeKey: 'speech-status' - }) + : t("composer.voice.startFailed"), + dedupeKey: "speech-status", + }); } finally { if (!started) { - setVoiceListening(false) - setVoiceRecording(false) + setVoiceListening(false); + setVoiceRecording(false); } } - } + }; const startVoiceInput = async (): Promise => { - const speech = window.goodbuddy.speech + const speech = window.goodbuddy.speech; if (!speech) { - await startWebSpeechInput() - return + await startWebSpeechInput(); + return; } const audioWindow = window as typeof window & { - webkitAudioContext?: typeof AudioContext - } + webkitAudioContext?: typeof AudioContext; + }; const AudioContextType = - audioWindow.AudioContext ?? audioWindow.webkitAudioContext + audioWindow.AudioContext ?? audioWindow.webkitAudioContext; if (!navigator.mediaDevices?.getUserMedia || !AudioContextType) { notify({ - tone: 'error', - message: t('composer.voice.microphoneUnavailable'), - dedupeKey: 'speech-status' - }) - return + tone: "error", + message: t("composer.voice.microphoneUnavailable"), + dedupeKey: "speech-status", + }); + return; } - setVoiceListening(true) - setVoiceRecording(false) - voiceStartingRef.current = true + setVoiceListening(true); + setVoiceRecording(false); + voiceStartingRef.current = true; try { const recording = await startPcmRecording( navigator.mediaDevices, - AudioContextType - ) - voiceStartingRef.current = false + AudioContextType, + ); + voiceStartingRef.current = false; if (voiceDisposedRef.current) { - void recording.result.catch(() => undefined) - recording.cancel() - return + void recording.result.catch(() => undefined); + recording.cancel(); + return; } - voiceRecordingRef.current = recording - setVoiceRecording(true) + voiceRecordingRef.current = recording; + setVoiceRecording(true); notify({ - tone: 'info', - message: t('composer.voice.recording'), - dedupeKey: 'speech-status' - }) + tone: "info", + message: t("composer.voice.recording"), + dedupeKey: "speech-status", + }); void recording.result .then(async ({ audio, sampleRate }) => { - voiceRecordingRef.current = undefined - setVoiceRecording(false) - const requestId = crypto.randomUUID() - voiceRequestIdRef.current = requestId + voiceRecordingRef.current = undefined; + setVoiceRecording(false); + const requestId = crypto.randomUUID(); + voiceRequestIdRef.current = requestId; notify({ - tone: 'info', - message: t('composer.voice.localRecognizing'), - dedupeKey: 'speech-status' - }) + tone: "info", + message: t("composer.voice.localRecognizing"), + dedupeKey: "speech-status", + }); const result = await speech.transcribe({ requestId, sampleRate, - audio - }) + audio, + }); if (voiceRequestIdRef.current !== requestId) { - return + return; } - const transcript = result.text.trim() + const transcript = result.text.trim(); if (!transcript) { notify({ - tone: 'info', - message: t('composer.voice.noSpeech'), - dedupeKey: 'speech-status' - }) - return + tone: "info", + message: t("composer.voice.noSpeech"), + dedupeKey: "speech-status", + }); + return; } setInput((current) => - current ? `${current} ${transcript}` : transcript - ) + current ? `${current} ${transcript}` : transcript, + ); notify({ - tone: 'success', - message: t('composer.voice.transcribed'), - dedupeKey: 'speech-status' - }) + tone: "success", + message: t("composer.voice.transcribed"), + dedupeKey: "speech-status", + }); }) .catch((reason: unknown) => { notify({ tone: - reason instanceof Error && - reason.name === 'AbortError' - ? 'info' - : 'error', + reason instanceof Error && reason.name === "AbortError" + ? "info" + : "error", message: - reason instanceof Error && - reason.name === 'AbortError' - ? t('composer.voice.cancelled') + reason instanceof Error && reason.name === "AbortError" + ? t("composer.voice.cancelled") : reason instanceof Error ? reason.message - : t('composer.voice.localFailed'), - dedupeKey: 'speech-status' - }) + : t("composer.voice.localFailed"), + dedupeKey: "speech-status", + }); }) .finally(() => { - voiceRequestIdRef.current = undefined - setVoiceListening(false) - setVoiceRecording(false) - }) + voiceRequestIdRef.current = undefined; + setVoiceListening(false); + setVoiceRecording(false); + }); } catch (reason) { - voiceStartingRef.current = false - setVoiceListening(false) - setVoiceRecording(false) + voiceStartingRef.current = false; + setVoiceListening(false); + setVoiceRecording(false); notify({ - tone: 'error', + tone: "error", message: - reason instanceof Error && - reason.name === 'NotAllowedError' - ? t('composer.voice.permissionDenied') + reason instanceof Error && reason.name === "NotAllowedError" + ? t("composer.voice.permissionDenied") : reason instanceof Error ? reason.message - : t('composer.voice.recordingStartFailed'), - dedupeKey: 'speech-status' - }) + : t("composer.voice.recordingStartFailed"), + dedupeKey: "speech-status", + }); } - } + }; const toggleVoiceInput = (): void => { if (voiceStartingRef.current) { - return + return; } - const recording = voiceRecordingRef.current + const recording = voiceRecordingRef.current; if (recording) { - setVoiceRecording(false) - recording.stop() + setVoiceRecording(false); + recording.stop(); notify({ - tone: 'info', - message: t('composer.voice.preparing'), - dedupeKey: 'speech-status' - }) - return + tone: "info", + message: t("composer.voice.preparing"), + dedupeKey: "speech-status", + }); + return; } - const requestId = voiceRequestIdRef.current + const requestId = voiceRequestIdRef.current; if (requestId) { - voiceRequestIdRef.current = undefined - void window.goodbuddy.speech?.cancel(requestId) + voiceRequestIdRef.current = undefined; + void window.goodbuddy.speech?.cancel(requestId); notify({ - tone: 'info', - message: t('composer.voice.cancelled'), - dedupeKey: 'speech-status' - }) - setVoiceListening(false) - setVoiceRecording(false) - return + tone: "info", + message: t("composer.voice.cancelled"), + dedupeKey: "speech-status", + }); + setVoiceListening(false); + setVoiceRecording(false); + return; } - void startVoiceInput() - } + void startVoiceInput(); + }; const refreshSelectedKnowledge = async (): Promise => { - await refreshKnowledge(knowledgeSnapshot.selectedLibraryId) - } + await refreshKnowledge(knowledgeSnapshot.selectedLibraryId); + }; const createKnowledgeLibrary = async ( - input: Parameters< - typeof window.goodbuddy.knowledge.createLibrary - >[0] + input: Parameters[0], ): Promise => { - const library = await window.goodbuddy.knowledge.createLibrary(input) - setEnabledKnowledgeLibraryIds((current) => [...current, library.id]) - await refreshKnowledge(library.id) - } + const library = await window.goodbuddy.knowledge.createLibrary(input); + setEnabledKnowledgeLibraryIds((current) => [...current, library.id]); + await refreshKnowledge(library.id); + }; const deleteKnowledgeLibrary = async (libraryId: string): Promise => { - await window.goodbuddy.knowledge.deleteLibrary(libraryId) - await refreshKnowledge() - } + await window.goodbuddy.knowledge.deleteLibrary(libraryId); + await refreshKnowledge(); + }; const runKnowledgeSourceAction = async ( - action: () => Promise + action: () => Promise, ): Promise => { setKnowledgeOperationCount((count) => { - const next = count + 1 - knowledgeOperationCountRef.current = next - return next - }) + const next = count + 1; + knowledgeOperationCountRef.current = next; + return next; + }); try { - const result = await action() - await refreshSelectedKnowledge() - return result + const result = await action(); + await refreshSelectedKnowledge(); + return result; } catch (error) { - await refreshSelectedKnowledge().catch(() => undefined) - throw error + await refreshSelectedKnowledge().catch(() => undefined); + throw error; } finally { setKnowledgeOperationCount((count) => { - const next = Math.max(0, count - 1) - knowledgeOperationCountRef.current = next - return next - }) + const next = Math.max(0, count - 1); + knowledgeOperationCountRef.current = next; + return next; + }); } - } + }; const openActivityConversation = (conversationId: string): void => { const conversation = conversations.find( - (candidate) => candidate.id === conversationId - ) + (candidate) => candidate.id === conversationId, + ); if (!conversation) { notify({ - tone: 'info', - message: t('notices.conversationDeleted') - }) - return + tone: "info", + message: t("notices.conversationDeleted"), + }); + return; } if (conversation.projectId) { const project = projects.find( - (candidate) => candidate.id === conversation.projectId - ) - setActiveProjectId(conversation.projectId) + (candidate) => candidate.id === conversation.projectId, + ); + setActiveProjectId(conversation.projectId); if (project) { - setWorkMode( - normalizeInteractiveWorkMode(project.defaultWorkMode) - ) + setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); } } - setActiveId(conversationId) + setActiveId(conversationId); setUnreadConversationIds((current) => { if (!current.has(conversationId)) { - return current + return current; } - const next = new Set(current) - next.delete(conversationId) - return next - }) - setView('chat') - } + const next = new Set(current); + next.delete(conversationId); + return next; + }); + setView("chat"); + }; const openAssistantTask = (task: AssistantTask): void => { if (!task.conversationId) { notify({ - tone: 'info', - message: t('notices.conversationDeleted') - }) - return + tone: "info", + message: t("notices.conversationDeleted"), + }); + return; } const conversation = conversations.find( - (candidate) => candidate.id === task.conversationId - ) + (candidate) => candidate.id === task.conversationId, + ); if (!conversation) { notify({ - tone: 'info', - message: t('notices.conversationDeleted') - }) - return + tone: "info", + message: t("notices.conversationDeleted"), + }); + return; } if (task.projectId) { const project = projects.find( - (candidate) => candidate.id === task.projectId - ) - setActiveProjectId(task.projectId) + (candidate) => candidate.id === task.projectId, + ); + setActiveProjectId(task.projectId); if (project) { - setWorkMode( - normalizeInteractiveWorkMode(project.defaultWorkMode) - ) + setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); } } - setSelectedAssistantTaskId(task.id) + setSelectedAssistantTaskId(task.id); setExpandedTaskConversationIds((current) => { - const next = new Set(current) - next.add(conversation.id) - return next - }) - setActiveId(conversation.id) - setView('chat') - } + const next = new Set(current); + next.add(conversation.id); + return next; + }); + setActiveId(conversation.id); + setView("chat"); + }; const openCustomTaskDialog = ( - defaultDestination: CustomTaskDestination + defaultDestination: CustomTaskDestination, ): void => { - if (!activeProject || activeProject.kind !== 'user') { + if (!activeProject || activeProject.kind !== "user") { notify({ - tone: 'info', - message: t('customTask.errors.projectUnavailable') - }) - return + tone: "info", + message: t("customTask.errors.projectUnavailable"), + }); + return; } - setCustomTaskDialog({ defaultDestination }) - } + setCustomTaskDialog({ defaultDestination }); + }; const createCustomTask = async ( - input: Parameters[0] + input: Parameters[0], ): Promise => { if (input.conversationId) { - persistLocalConversationChanges() - await conversationPersistenceQueueRef.current + persistLocalConversationChanges(); + await conversationPersistenceQueueRef.current; } - const schedule = await window.goodbuddy.schedules.create(input) + const schedule = await window.goodbuddy.schedules.create(input); setAssistantSchedules((current) => [ schedule, - ...current.filter((item) => item.id !== schedule.id) - ]) - setSelectedAssistantTaskId(schedule.taskId) + ...current.filter((item) => item.id !== schedule.id), + ]); + setSelectedAssistantTaskId(schedule.taskId); setExpandedTaskConversationIds((current) => { - const next = new Set(current) - next.add(schedule.conversationId) - return next - }) + const next = new Set(current); + next.add(schedule.conversationId); + return next; + }); const [conversationResult, taskResult, scheduleResult] = await Promise.allSettled([ window.goodbuddy.conversations.list(), window.goodbuddy.tasks.list(), - window.goodbuddy.schedules.list() - ]) - if (conversationResult.status === 'fulfilled') { + window.goodbuddy.schedules.list(), + ]); + if (conversationResult.status === "fulfilled") { setConversations((current) => mergePersistedConversations( current, conversationResult.value, - persistedLocalConversationsRef.current - ) - ) + persistedLocalConversationsRef.current, + ), + ); } else { notify({ - tone: 'error', - message: t('notices.remoteConversationRefreshFailed'), - dedupeKey: 'custom-task-conversation-refresh' - }) + tone: "error", + message: t("notices.remoteConversationRefreshFailed"), + dedupeKey: "custom-task-conversation-refresh", + }); } - if (taskResult.status === 'fulfilled') { - setAssistantTasks(taskResult.value) + if (taskResult.status === "fulfilled") { + setAssistantTasks(taskResult.value); } - if (scheduleResult.status === 'fulfilled') { - setAssistantSchedules(scheduleResult.value) + if (scheduleResult.status === "fulfilled") { + setAssistantSchedules(scheduleResult.value); } if ( - taskResult.status === 'rejected' || - scheduleResult.status === 'rejected' + taskResult.status === "rejected" || + scheduleResult.status === "rejected" ) { notify({ - tone: 'error', - message: t('notices.taskHistoryReadFailed'), - dedupeKey: 'custom-task-discovery-refresh' - }) + tone: "error", + message: t("notices.taskHistoryReadFailed"), + dedupeKey: "custom-task-discovery-refresh", + }); } if (schedule.projectId) { const project = projects.find( - (candidate) => candidate.id === schedule.projectId - ) - setActiveProjectId(schedule.projectId) + (candidate) => candidate.id === schedule.projectId, + ); + setActiveProjectId(schedule.projectId); if (project) { - setWorkMode( - normalizeInteractiveWorkMode(project.defaultWorkMode) - ) + setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); } } - setActiveId(schedule.conversationId) - setView('chat') - return schedule - } + setActiveId(schedule.conversationId); + setView("chat"); + return schedule; + }; - const runAssistantSchedule = async ( - scheduleId: string - ): Promise => { - await window.goodbuddy.schedules.runNow(scheduleId) + const runAssistantSchedule = async (scheduleId: string): Promise => { + await window.goodbuddy.schedules.runNow(scheduleId); notify({ - tone: 'success', - message: t('notices.scheduleStarted') - }) - } + tone: "success", + message: t("notices.scheduleStarted"), + }); + }; const setAssistantScheduleEnabled = async ( scheduleId: string, - enabled: boolean + enabled: boolean, ): Promise => { - await window.goodbuddy.schedules.setEnabled(scheduleId, enabled) + await window.goodbuddy.schedules.setEnabled(scheduleId, enabled); setAssistantSchedules((current) => current.map((schedule) => - schedule.id === scheduleId - ? { ...schedule, enabled } - : schedule - ) - ) - } + schedule.id === scheduleId ? { ...schedule, enabled } : schedule, + ), + ); + }; - const removeAssistantSchedule = async ( - scheduleId: string - ): Promise => { - await window.goodbuddy.schedules.remove(scheduleId) + const removeAssistantSchedule = async (scheduleId: string): Promise => { + await window.goodbuddy.schedules.remove(scheduleId); setAssistantSchedules((current) => - current.filter((schedule) => schedule.id !== scheduleId) - ) - } + current.filter((schedule) => schedule.id !== scheduleId), + ); + }; const clearLocalData = async (): Promise => { - conversationPersistencePausedRef.current = true + conversationPersistencePausedRef.current = true; try { - await conversationPersistenceQueueRef.current + await conversationPersistenceQueueRef.current; for (const requestId of activeRuns.current.keys()) { - await window.goodbuddy.agent.cancel(requestId) + await window.goodbuddy.agent.cancel(requestId); } - activeRuns.current.clear() - setActiveConversationIds(new Set()) + activeRuns.current.clear(); + setActiveConversationIds(new Set()); for (const attachments of attachmentsRef.current.values()) { for (const attachment of attachments) { - await window.goodbuddy.context.remove(attachment.id) + await window.goodbuddy.context.remove(attachment.id); } } - attachmentsRef.current.clear() - setAttachmentsByConversation({}) + attachmentsRef.current.clear(); + setAttachmentsByConversation({}); for (const library of knowledgeSnapshot.libraries) { - await window.goodbuddy.knowledge.deleteLibrary(library.id) + await window.goodbuddy.knowledge.deleteLibrary(library.id); } - await window.goodbuddy.app.clearLocalData() + await window.goodbuddy.app.clearLocalData(); const conversation = createConversation( activeProjectId || undefined, runtimeSettings - ? getProjectDefaultRuntimeSelection( - activeProject, - runtimeSettings - ) + ? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings) : undefined, - t('conversation.greeting') - ) - conversationsRef.current = [conversation] - persistedLocalConversationsRef.current.clear() - setConversations([conversation]) - setActiveId(conversation.id) - legacyActivityHistoryMayBeIncompleteRef.current = false - setLegacyActivityHistoryMayBeIncomplete(false) - setActivityRecords([]) - setAssistantTasks([]) - setTokenUsage(emptyTokenUsage) - setAssistantArtifacts([]) - setAssistantMemories([]) - setAssistantSchedules([]) - setAssistantHeartbeats([]) - setHeartbeatEntries([]) - setHeartbeatRuns([]) - setHeartbeatMemories([]) + t("conversation.greeting"), + ); + conversationsRef.current = [conversation]; + persistedLocalConversationsRef.current.clear(); + setConversations([conversation]); + setActiveId(conversation.id); + legacyActivityHistoryMayBeIncompleteRef.current = false; + setLegacyActivityHistoryMayBeIncomplete(false); + setActivityRecords([]); + setAssistantTasks([]); + setTokenUsage(emptyTokenUsage); + setAssistantArtifacts([]); + setAssistantMemories([]); + setAssistantSchedules([]); + setAssistantHeartbeats([]); + setHeartbeatEntries([]); + setHeartbeatRuns([]); + setHeartbeatMemories([]); setKnowledgeSnapshot({ libraries: [], sources: [], documents: [], graphNodes: [], graphRelations: [], - evidence: [] - }) - updateAttachments([]) - setInput('') - setView('chat') + evidence: [], + }); + updateAttachments([]); + setInput(""); + setView("chat"); notify({ - tone: 'success', - message: t('notices.localDataCleared') - }) + tone: "success", + message: t("notices.localDataCleared"), + }); } finally { - conversationPersistencePausedRef.current = false - persistLocalConversationChanges() + conversationPersistencePausedRef.current = false; + persistLocalConversationChanges(); } - } + }; const isRunning = activeConversation?.messages.some( - (message) => message.state === 'streaming' - ) ?? false + (message) => message.state === "streaming", + ) ?? false; const activeConversationQueueItems = useMemo( () => - conversationQueueItems.filter( - (item) => item.conversationId === activeId - ), - [activeId, conversationQueueItems] - ) + conversationQueueItems.filter((item) => item.conversationId === activeId), + [activeId, conversationQueueItems], + ); const conversationExecutionRunning = isRunning || assistantTasks.some( (task) => task.conversationId === activeId && - (task.status === 'running' || - task.status === 'waiting_approval') - ) + (task.status === "running" || task.status === "waiting_approval"), + ); const handleConversationQueueError = useCallback( (message: string): void => { notify({ - tone: 'error', - message - }) + tone: "error", + message, + }); }, - [notify] - ) + [notify], + ); const interruptConversationQueueItem = useCallback( (itemId: string) => window.goodbuddy.conversationQueue.interruptAndRun(itemId), - [] - ) + [], + ); const removeConversationQueueItem = useCallback( - (itemId: string) => - window.goodbuddy.conversationQueue.remove(itemId), - [] - ) + (itemId: string) => window.goodbuddy.conversationQueue.remove(itemId), + [], + ); const runtimeAgentControlAvailable = - activeRuntimeSelection?.provider === 'opencode' && - runtimeAgentOptions.length > 1 + activeRuntimeSelection?.provider === "opencode" && + runtimeAgentOptions.length > 1; const runtimePresetControlAvailable = - activeRuntimeSelection?.provider === 'continue' && - runtimePresetOptions.length > 1 + activeRuntimeSelection?.provider === "continue" && + runtimePresetOptions.length > 1; const runtimeActionControlAvailable = - (activeRuntimeSelection?.provider === 'opencode' || - activeRuntimeSelection?.provider === 'continue') && - runtimeActionOptions.length > 1 + (activeRuntimeSelection?.provider === "opencode" || + activeRuntimeSelection?.provider === "continue") && + runtimeActionOptions.length > 1; const runtimeControlsAvailable = runtimeAgentControlAvailable || runtimePresetControlAvailable || - runtimeActionControlAvailable + runtimeActionControlAvailable; const runtimeControlsProvider = runtimeControlsAvailable - ? activeRuntimeSelection?.provider === 'opencode' - ? 'OpenCode' - : activeRuntimeSelection?.provider === 'continue' - ? 'Continue' + ? activeRuntimeSelection?.provider === "opencode" + ? "OpenCode" + : activeRuntimeSelection?.provider === "continue" + ? "Continue" : undefined - : undefined + : undefined; const runtimeControlsLabel = runtimeControlsProvider - ? t('composer.runtimeControls.groupLabel', { - runtime: runtimeControlsProvider + ? t("composer.runtimeControls.groupLabel", { + runtime: runtimeControlsProvider, }) - : '' + : ""; const runtimeContextCompactAvailable = - (activeRuntimeSelection?.provider === 'opencode' || - activeRuntimeSelection?.provider === 'continue') && - runtimeNativeSnapshot?.context.manualCompact === true + (activeRuntimeSelection?.provider === "opencode" || + activeRuntimeSelection?.provider === "continue") && + runtimeNativeSnapshot?.context.manualCompact === true; const composerContextMetrics = useMemo(() => { if ( @@ -8411,146 +7957,136 @@ function App(): React.JSX.Element { !activeRuntimeSelection || activeConversation.remote ) { - return undefined + return undefined; } - const resolvedRuntimeSelection = - resolveContextMetricsRuntimeSelection( - activeRuntimeSelection, - runtimeSettings - ) + const resolvedRuntimeSelection = resolveContextMetricsRuntimeSelection( + activeRuntimeSelection, + runtimeSettings, + ); const activeModelProfile = - 'profileId' in resolvedRuntimeSelection && + "profileId" in resolvedRuntimeSelection && resolvedRuntimeSelection.profileId ? runtimeSettings.modelProfiles.find( - (candidate) => - candidate.id === resolvedRuntimeSelection.profileId + (candidate) => candidate.id === resolvedRuntimeSelection.profileId, ) - : undefined - if (activeModelProfile?.protocol === 'openai-images-generations') { - return undefined + : undefined; + if (activeModelProfile?.protocol === "openai-images-generations") { + return undefined; } - const latest = activeConversation.contextMetrics + const latest = activeConversation.contextMetrics; const applicableLatest = latest?.runtimeSelectionKey === agentRuntimeSelectionKey(resolvedRuntimeSelection) ? latest - : undefined + : undefined; if (!applicableLatest) { - return undefined + return undefined; } const compressionSettings = - runtimeSettings.contextCompression ?? - defaultContextCompressionSettings - const contextTokens = applicableLatest.contextTokens - const contextWindowTokens = - activeModelProfile?.contextWindowTokens + runtimeSettings.contextCompression ?? defaultContextCompressionSettings; + const contextTokens = applicableLatest.contextTokens; + const contextWindowTokens = activeModelProfile?.contextWindowTokens; const effectiveTriggerTokens = getEffectiveContextTriggerTokens({ triggerTokens: compressionSettings.triggerTokens, - contextWindowTokens - }) - const denominatorTokens = contextWindowTokens + contextWindowTokens, + }); + const denominatorTokens = contextWindowTokens; const percentage = denominatorTokens === undefined ? undefined - : Math.round( - (contextTokens / denominatorTokens) * 100 - ) + : Math.round((contextTokens / denominatorTokens) * 100); return { contextTokens, effectiveTriggerTokens, contextWindowTokens, compressionEnabled: - resolvedRuntimeSelection.provider === 'model' && + resolvedRuntimeSelection.provider === "model" && compressionSettings.enabled, source: applicableLatest.source, basis: applicableLatest.basis ?? - (applicableLatest.source === 'estimated' && + (applicableLatest.source === "estimated" && activeConversation.contextCompressionState - ? 'conversation' - : 'model-call'), + ? "conversation" + : "model-call"), denominatorTokens, - percentage - } - }, [ - activeConversation, - activeRuntimeSelection, - runtimeSettings - ]) + percentage, + }; + }, [activeConversation, activeRuntimeSelection, runtimeSettings]); - const mainSidebarOpen = narrowWindow && sidebarOpen - const canResizePrimarySidebar = sidebarOpen && !narrowWindow - const primarySidebarWidthLimits = - getPrimarySidebarWidthLimits(window.innerWidth) - const backgroundIsolated = mainSidebarOpen + const mainSidebarOpen = narrowWindow && sidebarOpen; + const canResizePrimarySidebar = sidebarOpen && !narrowWindow; + const primarySidebarWidthLimits = getPrimarySidebarWidthLimits( + window.innerWidth, + ); + const backgroundIsolated = mainSidebarOpen; const runtimeState = runtimeSwitching || !runtime || runtimeStatusKey !== activeRuntimeSelectionKey - ? 'connecting' + ? "connecting" : runtime.available - ? 'ready' - : 'unavailable' - const runtimeDetailId = 'topbar-runtime-detail' + ? "ready" + : "unavailable"; + const runtimeDetailId = "topbar-runtime-detail"; const runtimeDetail = - runtimeState === 'connecting' - ? t('runtime.connecting') + runtimeState === "connecting" + ? t("runtime.connecting") : runtime?.detail || - (runtimeState === 'unavailable' - ? t('runtime.unavailable') - : t('runtime.state.ready')) - const composerContextErrorId = 'composer-context-error' + (runtimeState === "unavailable" + ? t("runtime.unavailable") + : t("runtime.state.ready")); + const composerContextErrorId = "composer-context-error"; const runBrowserCommand = async ( command: ( browserApi: NonNullable, - conversationId: string - ) => Promise + conversationId: string, + ) => Promise, ): Promise => { if (!activeId) { - return + return; } - const browserApi = window.goodbuddy.browser + const browserApi = window.goodbuddy.browser; if (!browserApi) { notify({ - tone: 'error', - message: t('notices.browserControlUnavailable') - }) - return + tone: "error", + message: t("notices.browserControlUnavailable"), + }); + return; } - await command(browserApi, activeId) - } + await command(browserApi, activeId); + }; return (