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 (